1. 程式人生 > 實用技巧 >一探 Vue 資料響應式原理

一探 Vue 資料響應式原理

一探 Vue 資料響應式原理

本文寫於 2020 年 8 月 5 日

相信在很多新人第一次使用 Vue 這種框架的時候,就會被其修改資料便自動更新檢視的操作所震撼。

Vue 的文件中也這麼寫道:

Vue 最獨特的特性之一,是其非侵入性的響應式系統。資料模型僅僅是普通的 JavaScript 物件。而當你修改它們時,檢視會進行更新。

單看這句話,像我這種菜鳥程式設計師必然是看不懂的。我只知道,在 new Vue() 時傳入的 data 屬性一旦產生變化,那麼在視圖裡的變數也會隨之而變。

但這個變化是如何實現的呢?接下來讓我們,一探究竟。

1 偷偷變化的 data

我們先來新建一個變數:let data = { msg: 'hello world' }

接著我們將這個 data 傳給 Vue 的 data:

let data = { msg: 'hello world' }

/*****留空處*****/

new Vue({
  data,
  methods: {
    showData() {
      console.log(data)
    }
  }
})

這看似是非常平常的操作,但是我們在觸發 showData 的時候,會發現打出來 data 不太對勁:

msg: (...)
__ob__: Observer {value: {…}, dep: Dep, vmCount: 1}
get msg: ƒ reactiveGetter()
set msg: ƒ reactiveSetter(newVal)
__proto__: Object

它不僅多了很多沒見過的屬性,還把裡面的 msg: hello world 變成了 msg: (...)

接下來我們嘗試在留空處打印出 data,即在定義完 data 之後立即將其列印。

但是很不幸,打印出來依然是上面這個不對勁的值。

可是很明顯,當我們不去 new Vue(),並且傳入 data 的時候,data 的列印結果絕對不是這樣。

所以我們可以嘗試利用 setTimeout()new Vue() 延遲 3 秒執行。

這個時候我們就會驚訝的發現:

  1. 當我們在 3s 內點開 console 的結果時,data 是普通的形式;
  2. 當我們在 3s 後點開 console 的結果時,data 又變成了奇怪的形式。

這說明就是 new Vue() 的過程中,Vue 偷偷的對 data 進行了修改!正是這個修改,讓 data 的資料,變成了響應式資料。

2 (...) 的由來

為什麼好好的一個 msg 屬性會變成 (...) 呢?

這就涉及到了 ES6 中的 getter 和 setter。(如果理解 getter/setter,可跳至下一節)

一般我們如果需要計算後的值,會定義一個函式,例如:

const obj = {
  number: 5,
  double() {
    return this.number * 2;
  }
};

在使用的時候,我們寫上 obj.double(obj.number) 即可。

但是函式是需要加括號的,我太懶了,以至於括號都不想要了。

於是就有了 getter 方法:

const obj = {
  number: 5,
  get double() {
    return this.number * 2;
  }
};

const newNumber = obj.double;

這樣一來,就能夠不需要括號,就可以得到 return 的值。

setter 同理:

const obj = {
  number: 5,
  set double(value) {
    if(this.number * 2 != value;)
    this.number = value;
  }
};

obj.double = obj.number * 2;

由此我們可以看出:通過 setter,我們可以達到給賦值設限的效果,例如這裡我就要求新值必須是原值的兩倍才可以。

但經常的,我們會用 getter/setter 來隱藏一個變數

比如:

const obj = {
  _number: 5,
  get number() {
    return this._number;
  },
  set number(value) {
    this._number = value;
  }
};

這個時候我們打印出 obj,就會驚訝的發現 (...) 出現了:

number: (...)
_number: 5

現在我們明白了,Vue 偷偷做的事情,就是把 data 裡面的資料全變成了 getter/setter。

3 利用 Object.defineProperty() 實現代理

這個時候我們想一個問題,原來我們可以通過 obj.c = 'c'; 來定義 c 的值——即使 c 本身不在 obj 中。

但如何定義一個 getter/setter 呢?答:使用 Object.defineProperty()

Object.defineProperty() 方法會直接在一個物件上定義一個新屬性,或者修改一個物件的現有屬性,並返回此物件。

例如我們上面寫的 obj.c = 'c';,就可以通過

const obj = {
  a: 'a',
  b: 'b'
}
Object.defineProperty(obj, 'c', {
  value: 'c'
})

Object.defineProperty() 接收三個引數:第一個是要定義屬性的物件;第二個是要定義或修改的屬性的名稱或 Symbol;第三個則是要定義或修改的屬性描述符。

在第三個引數中,可以接收多個屬性,value 代表「值」,除此之外還有 configurable, enumerable, writable, get, set 一共六個屬性。

這裡我們只看 getset

之前我們說了,通過 getter/setter 我們可以把不想讓別人直接操作的資料“藏起來”。

可是本質上,我們只是在前面加了一個 _ 而已,直接訪問是可以繞過我們的 getter/setter 的!

那麼我們怎麼辦呢?

利用代理。這個代理不是 ES6 新增的 Proxy,而是設計模式的一種。

我們剛剛為什麼可以去修改我們“藏起來”的屬性值?

因為我們知道它的名字呀!如果我不給他名字,自然別人就不可能修改了。

例如我們寫一個函式,然後把資料傳進去:

proxy({ a: 'a' })

這樣一來我們的 { a: 'a' } 就根本沒有名字了,無從改起!

接下來我們在定義 proxy 函式時,可以新建一個空物件,然後遍歷傳入的值,分別進行 Object.defineProperty()將傳入的物件的 keys 作為 getter/setter 賦給新建的空物件

最後,我們 return 這個物件即可。

let data = proxy({
  a: 'a',
  b: 'b'
});

function proxy(data) {
  const obj = {};
  const keys = Object.keys(data);
  for (let i = 0; i < keys.length; i++) {
    Object.defineProperty(obj, keys[i], {
      get() {
        return data[keys[i]];
      },
      set(value) {
        if (value < 0) return;
        data[keys[i]] = value;
      }
    });
  }
  return obj;
}

這樣一來,我們一開始宣告的 data,就是我們 return 的物件了。在這個物件裡,沒有原始的資料,別人無法繞過 getter/setter 進行操作!

但是往往並沒有這麼簡單,如果我一定需要一個變數名呢?

const sourceData = {
  a: 'a',
  b: 'b'
};

let data = proxy(sourceData);

如此一來,通過直接操作 sourceData.a,時可以直接繞過我們在 proxy 中設定的 set a 進行賦值的。這個時候我們怎麼處理?

很簡單嘛,當我們遍歷傳入的資料時,我們可以對傳入的資料新增 getter/setter,此後原始的資料就會被 getter/setter 所替代

在剛剛的程式碼中,我們在迴圈的剛開始新增這樣一段程式碼:

for(/*......*/) {
  const value = data[keys[i]];
  Object.defineProperty(data, keys[i], {
    get() {
      return value;
    },
    set(newValue) {
      if (newValue < 0) return;
      value = newValue;
    }
  });
  /*......*/
}

這是什麼意思呢?

我們利用了閉包,將原始值單獨拎出來,每一次對原始屬性進行讀寫,其實都是 get 和 set 在讀取閉包時被拎出來的值。

那麼不管別人是操作我們的 let data = proxy(sourceData); 的 data,還是操作 sourceData,都會被我們的 getter/setter 所攔截。

4 回到 Vue

我們剛剛寫的程式碼是這樣的:

let data = proxy({
  a: 'a'
})

function proxy(data) {

}

那如果我改成這樣呢:

let data = proxy({
  data: {
    a: 'a'
  }
})

function proxy({ data }) {
  // 結構賦值
}

是不是和 Vue 就非常非常像了!

const vm = new Vue({ data: {} }) 也是讓 vm 成為 data 的代理,並且就算你從外部將資料傳給 data,也會被 Vue 所捕捉。

而在每一次捕獲到你操作資料之後,就會對需要改變的 UI 進行重新渲染。

同理,Vue 對 computed 和 watch 也存在著各種偷偷的處理。

5 Vue 資料響應式的 Bug

如果我們的資料是這樣:

data: {
  obj: {
    a: 'a'
  }
}

我們在 Vue 的 template 裡卻寫了 <div>{{ obj.b }}<div> 會怎樣?

Vue 對於不存在或者為 undefined 和 null 的資料是不予以顯示的。但是當我們往 obj 中新增 b 的時候,他會顯示嗎?

寫法一:

const vm = new Vue({
  data: {
    obj: {
      a: 'a'
    }
  },
  methods: {
    changeObj() {
      this.obj.b = 'b';
    }
  }
})

我們可以給一個按鈕繫結 changeObj 事件,但是很遺憾,這樣並不能使檢視中的 obj.b 顯示出來。

回想一下剛剛我們對於資料的處理,是不是隻遍歷了外層?這就是因為 Vue 並沒有對 b 進行監聽,他根本不知道你的 b 是如何變化的,自然也就不會去更新檢視層了。

寫法 2:

const vm = new Vue({
  data: {
    obj: {
      a: 'a'
    }
  },
  methods: {
    changeObj() {
      this.obj.a = 'a2'
      this.obj.b = 'b';
    }
  }
})

我們僅僅只是新增了一行程式碼,在改變 b 之前先改變了 a,居然就讓 b 實現了更新!

這是為什麼?

因為檢視更新其實是非同步的。

當我們讓 a'a' 變成 'a2' 時,Vue 會監聽到這個變化,但是 Vue 並不能馬上更新檢視,因為 Vue 是使用 Object.defineProperty() 這樣的方式來監聽變化的,監聽到變化後會建立一個檢視更新任務到任務佇列裡。

所以在檢視更新之前,要先把餘下的程式碼執行完才行,也就是會執行 b = 'b'

最後等到檢視更新的時候,由於 Vue 會去做 diff 演算法,於是 Vue 就會發現 a 和 b 都變了,自然會去更新相對應的檢視。

但是這並不是我們解決問題的辦法,寫法 2 充其量只能算是“副作用”。

Vue 其實提供了方法讓我們來新增以前沒有生命的屬性:Vue.set() 或者 this.$set()

Vue.set(this.obj, 'b', 'b'); 會代替我們進行 obj.b = 'b';,然後監聽 b 的變化,觸發檢視更新。

那陣列怎麼響應呢?

每當我們往數組裡新增元素的時候,陣列就在不斷的變長。對於沒有宣告的陣列下標,很明顯 Vue 不會給予監聽呀。

比如 a: [1, 2, 3],當我新增一個元素,讓 a === [1, 2, 3, 4] 的時候,a[3] 是不會被監聽的

總不能每次 push 陣列,都要手寫剛剛說的 Vue.set 方法吧。

可實際操作中,我們發現並沒有呀,Vue 監聽了新增的資料。

這是因為 Vue 又偷偷的幹了一件事兒,它把你原本的陣列方法給改了一些。

  • push()
  • pop()
  • shift()
  • unshift()
  • splice()
  • sort()
  • reverse()

在 Vue 中的陣列所帶的這七個方法都不是原生的方法了。Vue 考慮到這些操作極為常用,所在中間為我們添加了監聽。

講到這裡,相信大家對 Vue 的響應式原理應該有了更深的認識了。

(完)