概況
在 Vue 開發的過程中,多少都會遇到數據更新後,頁面沒有更新渲染這類問題。而在上兩篇文章《Vue 響應式原理剖析 —— 從實例化、渲染到數據更新(上)》和《Vue 響應式原理剖析 —— 從實例化、渲染到數據更新(下)》中,從「實例化」、「渲染」、「數據更新」三條線完整地講述了 Vue「響應式」的工作原理,本文正是基於這些原理去解決一些常見的數據更新相關問題。
對象數據的某些修改無法被檢聽?
如下的一個場景,obj.message 賦值時能否被監聽響應呢?
var vm = new Vue({
data: {
obj: {
a: 1
},
},
template: '<div>{{ obj.message }}</div>'
});
vm.obj.message = 'modified';
答案是不能被監聽的。原因:對象屬性的添加和刪除無法被 Object.defineProperty 監聽,正如前文所述,Vue 的數據響應式基於 Object.defineProperty 實現,因此也受限。
解決辦法: Vue 提供了特定的方法 vm.$set(obj, propertyName, newValue) 來處理這種情況,至於該方法的具體邏輯,後面會詳細展開説明。
數組數據的某些修改無法被監聽?
如下的一個場景,三個賦值語句裏面,哪個能被監聽響應呢?
const vm = new Vue({
data: {
items: [1, 2, 3, 4, 5],
},
});
vm.items[1] = 8;
vm.items[5] = 6;
vm.items.length = 2;
答案是三個操作都不能被監聽到。原因:
- 第二個操作
vm.items[5] = 6應該是比較明顯的,與上面對象添加和刪除屬性類似,數組新添加的元素和刪除元素無法被Object.defineProperty監聽。 - 第三個操作
vm.items.length = 2也是由於Object.defineProperty的限制,數組的長度直接修改也無法被監聽。 - 最容易誤判的可能是第一個操作
vm.items[1] = 8,一種較為常規的説法是Object.defineProperty不支持監聽數組元素的變化,要驗證這個説法可以直接用一個例子説明真實的情況。
如下的一個例子:
function defineReactive(obj, key, val) {
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
console.log('讀取 index ' + key, '當前值是 ' + val)
return val;
},
set(newVal) {
if (val === newVal) {
return;
}
val = newVal
console.log('修改 index ' + key, '新值是 ' + val)
}
})
}
const testArray = [1, 2, 3, 4, 5]
testArray.forEach((c, index) => {
defineReactive(testArray, index, c)
});
testArray[0] = 100;
testArray[5] = 600;
也可以點擊這裏打開示例查看控制枱輸出。可以看到,超出範圍的數組元素操作 Object.defineProperty 確實無法監聽,但範圍內的元素重新賦值是可以被監聽的。那麼為什麼在 Vue 中對數組類型的 data,直接使用下標賦值無法被監聽呢?
答案是出於性能考慮,從上面的基礎例子中可以看到,對象和數組如果需要監聽每個屬性和元素,實際上是對每個屬性或者元素進行 Object.defineProperty 劫持,對象是監聽 key 而數組則是以數字下標作為 key,數組的數據量可能會很大,因此 Vue 出於性能考慮,並沒有對元素下標進行響應式處理。作為補充,Vue 對數組原型鏈上的幾個方法進行劫持,對於會導致元素新增的3個方法 push、pop、unshift 會在內部獲取新增的元素,執行響應式的處理:
const methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
methodsToPatch.forEach(function (method) {
// cache original method
const original = arrayProto[method]
def(arrayMethods, method, function mutator (...args) {
const result = original.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
})
})
與對象類似,如果需要為數字元素重新賦值,可以使用 vm.$set(arr, indexOfItem, newValue) 方法,這裏展示一下 $set 的具體實現:
// 精簡了非 production 邏輯
export function set (target: Array<any> | Object, key: any, val: any): any {
if (Array.isArray(target) && isValidArrayIndex(key)) {
target.length = Math.max(target.length, key)
target.splice(key, 1, val)
return val
}
if (key in target && !(key in Object.prototype)) {
target[key] = val
return val
}
const ob = (target: any).__ob__
if (target._isVue || (ob && ob.vmCount)) {
process.env.NODE_ENV !== 'production' && warn(
'Avoid adding reactive properties to a Vue instance or its root $data ' +
'at runtime - declare it upfront in the data option.'
)
return val
}
if (!ob) {
target[key] = val
return val
}
defineReactive(ob.value, key, val)
ob.dep.notify()
return val
}
主要的邏輯包括以下操作:
- 這個方法同時用於對象和數組,因此第一步會檢驗傳入的
target是否為數組,並且傳入的key(即數組的數字下標)是否符合數組的長度範圍(如上面所述,Object.defineProperty不支持劫持新添加的元素),符合的元素會調用splice插入到數組中,由於splice已經被劫持,新增加的元素會進行「響應式」處理。 - 判斷如果
key原先已存在,則無需再監聽。 - 判斷是根節點 Vue,即最外層的 Vue,或者已經有
__ob__屬性(表示已經進行響應式處理,詳情可以瀏覽前文),則無需再進行監聽。 - 如果不符合前面的條件,則表明該屬性需要執行「響應式」處理,會調用
defineReactive方法(響應式數據封裝的入口方法,詳情可以瀏覽前文)。