一探 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 秒執行。
這個時候我們就會驚訝的發現:
- 當我們在 3s 內點開 console 的結果時,data 是普通的形式;
- 當我們在 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
一共六個屬性。
這裡我們只看 get
與 set
。
之前我們說了,通過 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 的響應式原理應該有了更深的認識了。
(完)