之前寫過一篇響應式原理-如何監聽Array的變化,最近準備給團隊同事分享,發現之前看的太粗糙了,因此決定再寫一篇詳細版~
一、如何監聽陣列索引的變化?
(1)案例分析
相信初學Vue
的同學一定踩過這個坑,改變陣列的索引,沒有觸發檢視更新。
比如下面這個案例:
var vm = new Vue({
data: {
items: ['a', 'b', 'c']
}
})
vm.items[1] = 'x' // 不是響應性的
複製程式碼
以上案例摘抄Vue官方文件 - 陣列更新檢測。
(2)解決方式
Vue
官方文件也有給出,使用Vue.set
即可達到觸發檢視更新的效果。
// Vue.set
Vue.set(vm.items, indexOfItem, newValue);
複製程式碼
(3)Vue為何不能監聽索引的變化?
Vue
官方給出瞭解釋,不能檢測。
由於 JavaScript 的限制,Vue 不能檢測以下陣列的變動: 當你利用索引直接設定一個陣列項時,例如:
vm.items[indexOfItem] = newValue
。
那原因是什麼?我在學習的過程中發現很多文章都在斷章取義,Vue
官方給出瞭解釋是【Vue
不能檢測】,而很多文章寫出的是【Object.defineProperty
不能檢測】。
但實際上Object.defineProperty
是可以檢測到陣列索引的變化的。如下案例:
let data = [1, 2];
function defineReactive (obj, key, val) {
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: () => {
console.log('我被讀了,我要不要做點什麼好?');
return val;
},
set: newVal => {
if (val === newVal) {
return;
}
val = newVal;
console.log("資料被改變了,我要渲染到頁面上去!");
}
})
}
defineReactive(data, 0, 1);
console.log(data[0]);
data[0] = 5;
複製程式碼
大家可以自己在控制檯中嘗試一下,答案非常明顯了。
Vue
只是沒有使用這個方式去監聽陣列索引的變化,因為尤大認為效能消耗太大,於是在效能和使用者體驗之間做了取捨。
詳細可見這邊文章Vue為什麼不能檢測陣列變動。
好了,終於揭開了謎底,為什麼Vue
為什麼不能檢測陣列變動,因為不做哈哈。
但是我們開發者肯定是有這個需求的,解決方式就是如下,使用Vue.set
。
// Vue.set
Vue.set(vm.items, indexOfItem, newValue);
複製程式碼
原理?非常明顯,在初始的過程中沒有迴圈對所有陣列索引監聽,但是開發者需要監聽哪個索引。Vue.set
就幫你監聽哪個,核心還是Object.defineProperty
。只是儘可能的避免了無用的陣列索引監聽。
二、如何監聽陣列內容的增加或減少?
(1)技能限制
Object.defineProperty
雖然能檢測索引的變化,但的確是監聽不到陣列的增加或刪除的。可以閱讀
Vue官方文件 - 物件變更檢測注意事項 進行了解。
這個時候Vue
是怎麼做的呢?
(2)巧妙解決
Vue
的解決方案,就是重寫了陣列的原型,更準確的表達是攔截了陣列的原型。
首先選擇了7個能夠改變陣列自身的幾個方法。其次看下案例吧:
// 獲得原型上的方法
const arrayProto = Array.prototype;
// 建立一個新物件,使用現有的物件來提供新建立的物件的__proto__
const arrayMethods = Object.create(arrayProto);
// 做一些攔截的操作
Object.defineProperty(arrayMethods, 'push', {
value(...args) {
console.log('使用者傳進來的引數', args);
// 真正的push 保證資料如使用者期望
arrayProto.push.apply(this, args);
},
enumerable: true,
writable: true,
configurable: true,
});
let list = [1];
list.__proto__ = arrayMethods; // 重置原型
list.push(2, 3);
console.log('使用者得到的list:', list);
複製程式碼
為什麼叫攔截,我們在重寫案例中的push
方法時,還需要使用真正的push
,這樣才能保證陣列如使用者所期望的push
進去。
可以看到以下效果,我們既能監聽到使用者傳進來的引數,也就是監聽到這個陣列變化了,還能保證陣列如使用者所期望的push
進去。
為什麼使用arrayMethods
繼承真正的原型,因為這樣才不會汙染全域性的Array.prototype
,因為我們要監聽的陣列只有vm.data
中的。
(3)原始碼分析
export class Observer {
constructor (value: any) {
// 如果是陣列
if (Array.isArray(value)) {
// 如果原型上有__proto__屬性, 主要是瀏覽器判斷相容
if (hasProto) {
// 直接覆蓋響應式物件的原型
protoAugment(value, arrayMethods)
} else {
// 直接拷貝到物件的屬性上,因為訪問一個物件的方法時,先找他自身是否有,然後才去原型上找
copyAugment(value, arrayMethods, arrayKeys)
}
} else {
// 如果是物件
this.walk(value);
}
}
}
複製程式碼
以上可以看到Observer
對陣列的特別處理。
(4)陣列是如何收集依賴、派發更新的?
我們知道物件是在getter中收集依賴,setter中派發更新
。
那簡單回憶下:
function defineReactive (obj, key, val) {
// 生成一個Dep例項
let dep = new Dep();
Object.defineProperty(obj, key, {
get: () => {
// 依賴收集
dep.depend();
},
set: () => {
// 派發更新
dep.notify();
},
})
}
複製程式碼
為了保證data
中每個資料有著一對一的dep
,這裡應用了閉包,保證每個dep
例項不會被銷燬。那麼問題來了,dep
是一個區域性變數呀~
而監聽陣列變化,需要在陣列攔截器中進行派發更新。那就訪問不到這個dep
了,就無法知道具體要通知哪些Watcher
了!
那Vue是怎麼做的呢?既然這個訪問不到,那就再來一個dep
吧。
export class Observer {
constructor (value: any) {
this.value = value // data屬性
this.dep = new Dep() // 掛載dep例項
// 為資料定義了一個 __ob__ 屬性,這個屬性的值就是當前 Observer 例項物件
def(value, '__ob__', this) // 把當前Observer例項掛在到data的__ob__上
}
}
複製程式碼
在Vue
初始化的過程中,給data
中的每個資料都掛載了當前的Observer
例項,又在這個例項上掛載了dep
。這樣就能保證我們在陣列攔截器中訪問到dep
了。如下:
Object.defineProperty(arrayMethods, 'push', {
value(...args) {
console.log('使用者傳進來的引數', args);
// 真正的push 保證資料如使用者期望
arrayProto.push.apply(this, args);
// this指向當前這個陣列,在初始化的時候被賦值__ob__
console.log(this.__ob__.dep)
},
enumerable: true,
writable: true,
configurable: true,
});
複製程式碼
現在我們便可以在攔截器中執行dep.notify()
啦。
那如何收集依賴呢?
// 獲取當前data上的 observe例項,也就是__ob__
let childOb = !shallow && observe(val);
function defineReactive (obj, key, val) {
// 生成一個Dep例項
let dep = new Dep();
Object.defineProperty(obj, key, {
get: () => {
if (Dep.target) {
// 依賴收集
dep.depend();
// 二次收集
if (childOb.dep) {
// 再收集一次依賴
childOb.dep.depend();
}
}
return val;
},
})
}
複製程式碼
現在要存放2個dep
,那自然是要在getter
中收集2次的,childOb
其實就是observe
中返回的__ob__
。不用在意細節,自行檢視原始碼就知道啦~
(5)總結
總結一下,針對陣列在getter中收集依賴,在攔截器中觸發更新
。
三、其他思考
(1)思考:還有哪裡可以用到__ob__?
-
判斷某個陣列是否已Observer過,避免重複執行。
-
Vue.set
和Vue.del
,都是需要訪問dep
的。
(2)陣列賦值算改變長度嗎?
因為Object.defineProperty
不能檢測陣列的長度變化,例如:vm.items.length = newLength
。
var vm = new Vue({
data: {
items: ['a']
}
})
// 重新賦值,改變長度
vm.items = ['a, 'b', 'c']
複製程式碼
那vm.items = ['a, 'b', 'c']
這種情況,Vue
是如何監聽的?這種情況其實監聽的是物件vm
的items
屬性,和陣列其實是沒關係的。因為之前發現有人誤解,這裡簡單的提示一下~
四、總結
本文主要還是講原理及思路,並不會涉及到很多程式碼,畢竟原始碼總會變。同時還要保證自己的js基礎紮實,閱讀原始碼才不會吃力哦~ 我就是很吃力的那種?
如果你覺得對你有幫助,就點個贊吧~
已完成:
Vue原始碼解讀系列篇
- 1. Vue響應式原理-理解Observer、Dep、Watcher
- 2. 響應式原理-如何監聽Array的變化
- 3. 響應式原理-如何監聽Array的變化?詳細版
- 4. Vue非同步更新 - nextTick為什麼要microtask優先?
Github部落格 歡迎交流~
五、參考文獻
- 記一次思否問答的問題思考:Vue為什麼不能檢測陣列變動
- 書籍《深入淺出Vue.js》