前言
從一段基礎程式碼入手
下面這段程式碼非常簡單,編寫過Vue的同學都能看懂它在幹什麼,但是你能準確的說出這段程式碼在第一秒,第二秒,第三秒頁面上分別有什麼變化嗎?
<!DOCTYPE html>
<html>
<script src="https://unpkg.com/vue/dist/vue.js"></script>
<body>
<div id="app">
<div>{{ list }}</div>
</div>
<script>
new Vue({
el: '#app',
data: {
list: [],
},
mounted() {
setTimeout(()=>{
this.list[0] = 3
}, 1000)
setTimeout(()=>{
this.list.length = 5
}, 2000)
setTimeout(()=>{
this.$set(this.list, this.list)
}, 3000)
}
})
</script>
</body>
</html>複製程式碼
大家最好能動手拷貝上面的程式碼,本地新建HTML檔案儲存後開啟除錯檢視,我這裡直接說一下結果。當執行這段程式碼後,頁面在第一秒和第二秒無變化,直到第三秒時候才會發生變化,思考一下第一秒和第二秒改變了list的值,為什麼Vue的雙向繫結在這裡失效了呢?圍繞這個問題下面開始一步一步看看Vue的資料變化監聽實現機制。
Vue2.0的資料變化監聽
這裡由淺入深的去看,先從要監聽普通資料型別看起。
1、檢測屬性為基本資料型別
監聽普通資料型別,即要監聽的物件屬性的值為非物件的五種基本型別變化,這裡不直接看原始碼,每一步都自己手動的去實現,更加便於理解。
<!DOCTYPE html>
<html>
<div>
name: <input id="name" />
</div>
</html>
<script>
// 監聽Model下的name屬性,當name屬性有變化時要引起頁面id=name的響應變化
const model = {
name: 'vue',
};
// 利用Object.defineProperty建立一個監聽器
function observe(obj) {
let val = obj.name;
Object.defineProperty(obj, 'name', {
get() {
return val;
},
set(newVal) {
// 當有新值設定時,執行setter
console.log(`name變化:從${val}到${newVal}`);
// 解析到頁面
compile(newVal);
val = newVal;
}
})
}
// 解析器,將變化的資料響應到頁面上
function compile(val) {
document.querySelector('#name').value = val;
}
// 呼叫監聽器,對model開始監聽
observe(model);
</script>複製程式碼
在控制檯除錯過程。複製程式碼
上面的程式碼在除錯的時候,我先檢視了model.name初始值後,進行了重新設定,可以引起setter函式的觸發執行,從而頁面達到響應式效果。
但是當給name屬性賦值為物件型別後,再給新物件裡插入key1一個屬性後,接著改變這個key1的值,這時候頁面並不能得到響應式觸發。
所以上面的observe的實現中,當name是普通資料型別的時候監聽沒有問題,而要監聽的內容是物件的變化裡的時候,上面的寫法就有問題了。
下面看看監聽物件型別屬性observe函式要怎麼實現。
2、檢測屬性為物件型別
從上面的例子裡,檢測屬性值為物件時,不能滿足監聽需求,接下來進一步改造observe監聽函式,解決思路很簡單,如果是物件,只需再一次將當前物件下的所有普通型別的監聽變化即可,如果該物件下還有物件屬性,繼續監聽就可以了,如果你對遞迴很熟,馬上就知道該如何解決這個問題。
<!DOCTYPE html>
<html>
<div>
name: <input id="name" />
val: <input id="val" />
list: <input id="list" />
</div>
</html>
<script>
// 監聽Model下的name屬性,當name屬性有變化時要引起頁面id=name的響應變化
const model = {
name: 'vue',
data: {
val: 1
},
list: [1]
};
// 監聽函式
function observe(obj) {
// 遍歷所有屬性,各自監聽
Object.keys(obj).map(key => {
// 將object屬性特殊處理
if (typeof obj[key] === 'object') {
// 是物件屬性的再次監聽
observe(obj[key]);
} else {
// 非物件屬性的做監聽
defineReactive(obj, key, obj[key]);
}
})
}
// 利用Object.defineProperty做物件屬性的做監聽
function defineReactive(obj, key, val) {
Object.defineProperty(obj, key, {
get() {
return val;
},
set(newVal) {
// 當有新值設定時,執行setter
console.log(`${key}變化:從${val}到${newVal}`);
if (Array.isArray(obj)) {
document.querySelector(`#list`).value = newVal;
} else {
document.querySelector(`#${key}`).value = newVal;
}
val = newVal;
// 新增的屬性再次進行監聽
observe(newVal);
}
})
}
// 監聽model下的所有屬性
observe(model);
</script>複製程式碼
在控制檯除錯過程。複製程式碼
在上面的實際操作中,我先改變了屬性name的值,觸發了setter,頁面收到響應,再次改變了model.data這個物件下的val屬性,頁面也得到響應式變化,這說明我們在之前是想observe監聽不到物件屬性變化的問題在上面的改造下得到了解決。
接下來要注意,在最後我改變了陣列屬性list下的第一個下標裡的值為5,頁面也得到了監聽結果,但是我改變了第二個下標後,沒有觸發setter,接著特意去改變list的length,或者push都沒有觸發陣列的setter,頁面沒有變化響應。
這裡丟擲兩個問題:
a、我修改了陣列list的第二個下標的值,並且呼叫length、push改變陣列list後頁面也沒有響應到變化,是怎麼回事?
b、回到文章開始示例的那一段Vue程式碼裡的實現,我改變了Vue的data下list的下標屬性值,頁面是沒有響應變化的,但是這裡我改了list的內的值從1到5,頁面響應了,這又是怎麼回事?
請帶著a、b兩個問題繼續往下看。
3、檢測屬性為陣列物件型別
這裡分析一下a問題修改陣列下標的值和呼叫length、push方法改變陣列時不觸發監聽器的setter函式的原因。我之前看到很多文章寫Object.defineProperty不能監聽到陣列內的值變化,真的是這樣麼?
請看下面的例子,這裡不繫結頁面,只觀察Object.defineProperty監聽的陣列元素,是否能監聽到變化。
從上面程式碼裡,首先監聽了model陣列裡所有的屬性,然後通過各種陣列的方法來修改當前陣列,得出以下幾個結論。
1、直接修改陣列中已有的元素是可以被監聽的。
2、陣列的操作方法如果是操作已經存在的被監聽的元素也是可以觸發setter被監聽的。
3、只有push、length、pop一些特殊的方法確實不能觸發setter,這跟方法的內部實現與Object.defineProperty的setter鉤子的觸發實現有關係,是語言層面的原因。
4、改變超過陣列長度的下標的值時,值變化是不能監聽到的。這個其實很好理解,不存在的屬性當然是不能監聽到,因為繫結監聽操作在之前已經執行過了,後新增的元素屬性在繫結當時都還沒有存在,當然沒有辦法提前去監聽它了。
所以綜上,Object.defineProperty不能監聽到陣列內的值變化的說法是錯誤的,同時也得出了a問題的答案,語言層面不支援用Object.defineProperty監聽不存在的陣列元素,並且通過一些能造成陣列的方法造成陣列改變也不能監聽到。
4、探究Vue原始碼,看陣列的監聽如何實現
對於b問題,則需要去看看Vue的原始碼裡,為何Object.defineProperty明明能監聽到陣列值的變化,而它卻沒有實現呢?
這裡分享一下我看原始碼的技巧,如果直接開啟github一行一行看看原始碼是很懵逼的,我這裡是直接用Vue-cli在本地生成一個Vue專案,然後在安裝的node_modules下的Vue包裡進行斷點檢視的,大家可以嘗試下。
測試程式碼很簡單,如下;
import Vue from './node_modules/_vue@2.6.11@vue/dist/vue.runtime.common.dev'
// 例項化Vue,啟動起來後直接
new Vue({
data () {
return {
list: [1, 3]
}
},
})複製程式碼
解釋一下這一塊兒的原始碼,下面的hasProto的原始碼是看是否有原型存在,arrayMethods是被重寫的陣列方法,程式碼流程是如果有原型,直接修改原型上的push,pop,shift,unshift,splice, sort,reverse七個方法,如果沒有原型的情況下,走copyAugment去新增這七個屬性後賦值這七個方法,並沒有監聽。
/**
* Observe a list of Array items.
*/
observeArray (items: Array<any>) {
for (let i = 0, l = items.length; i < l; i++) {
// 監聽陣列元素
observe(items[i])
}
}複製程式碼
最後就是this.observeArray函式了,它的內部實現非常簡單,它對陣列元素進行了監聽,什麼意思呢,就是改變陣列裡的元素不能監聽到,但是陣列內的值是物件型別的,修改它依舊能得到監聽響應,如改變list[0].val可以得到監聽,但是改變list[0]不能,但是依舊沒有對陣列本身的變化進行監聽。
再看看arrayMethods是如何重寫陣列的操作方法的。
// 記錄原始Array未重寫之前的API原型方法
const arrayProto = Array.prototype
// 拷貝一份上面的原型出來
const arrayMethods = Object.create(arrayProto)
// 將要重寫的方法
const methodsToPatch = [ 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse' ]
/**
* Intercept mutating methods and emit events
*/
methodsToPatch.forEach(function (method) {
def(arrayMethods, method, function mutator (...args) {
// 原有的陣列方法呼叫執行
const result = arrayProto[method].apply(this, args)
const ob = this.__ob__
let inserted
switch (method) {
case 'push':
case 'unshift':
inserted = args
break
case 'splice':
inserted = args.slice(2)
break
}
// 如果是插入的資料,將其再次監聽起來
if (inserted) ob.observeArray(inserted)
// 觸發訂閱,像頁面更新響應就在這裡觸發
ob.dep.notify()
return result
})
})複製程式碼
從上面的原始碼裡可以完整的看到了Vue2.x中重寫陣列方法的思路,重寫之後的陣列會在每次在執行陣列的原始方法之後手動觸發響應頁面的效果。
看完原始碼後,問題a也水落石出了,Vue2.x中並沒有實現將已存在的陣列元素做監聽,而是去監聽造成陣列變化的方法,觸發這個方法的同時去呼叫掛載好的響應頁面方法,達到頁面響應式的效果。
但是也請注意並非所有的陣列方法都重新寫了一遍,只有push,pop,shift,unshift,splice, sort,reverse這七個。至於為什麼不用Object.defineProperty去監聽陣列中已存在的元素變化。
作者尤雨溪的考慮是因為效能原因,給每一個陣列元素繫結上監聽,實際消耗很大,而受益並不大。
issue地址:https://github.com/vuejs/vue/issues/8562。
Vue3.0的資料變化監聽
前一篇說了Vue3.0的監聽採用的是ES6新的構造方法Proxy來代理原物件做變化檢測,(對於Proxy不熟的同學可以翻看上一篇內容)而Proxy作為代理的存在,當非同步觸發Model裡的資料變化時,必須經過Proxy這一層,在這一層則可以監聽陣列以及各種資料型別的變化,看看下面的例子。
簡直完美,無論是陣列下標賦值引起變化還是陣列方法引起變化,都可以被監聽到,而且既可以避開監聽陣列每個屬性下造成的效能問題,還可以解決像pop、push方法,length方法改變陣列時監聽不到陣列變化的問題。
接下來使用Proxy和Reflect實現Vue3.0下的雙向繫結。
<!DOCTYPE html>
<html>
<div>
name: <input id="name" />
val: <input id="val" />
list: <input id="list" />
</div>
</html>
<script>
let model = {
name: 'vue',
data: {
val: 1,
},
list: [1]
}
function isObj (obj) {
return typeof obj === 'object';
}
// 監控器
function observe(data) {
// 將屬性都做監控
Object.keys(data).map(key => {
if (isObj(data[key])) {
// 物件型別的繼續監聽它的屬性
data[key] = observe(data[key]);
}
})
return defineProxy(data);
}
// 生成Proxy代理
function defineProxy(obj) {
return new Proxy(obj, {
set(obj, key, val) {
console.log(`屬性${key}變化為${val}`);
compile(obj, key, val);
return Reflect.set(...arguments);
}
})
}
// 解析器,響應頁面變化
function compile(obj, id, val) {
if (Array.isArray(obj)) { // 陣列變化
document.querySelector('#list').value = model.list;
} else {
document.querySelector(`#${id}`).value = val;
}
}
model= observe(model);
</script>複製程式碼
利用Proxy和Reflect實現之後,不用在考慮陣列的操作是否觸發setter,只要操作經過proxy代理層,各種操作都會被被捕獲到,達到頁面響應式的要求。
總結
在Vue2.x中陣列變化監聽的問題,其實不是Object.definePropertype方法監聽不到,而是為了效能和收益比例綜合考慮之下,改變了監聽方式,從原本的直接監聽結果變化這種思路變換到監聽會導致結果變化的方法上,也就上面所提到的對陣列的重寫。
而Vue3.0中利用Proxy的方式則完美解決了2.0中出現的問題,所以以後面試中如果遇到Vue中對於陣列監聽的處理的時候,一定要分清楚是哪一個版本,本文完。