Vue響應式原理 - 關於Array的特別處理

蒼耳mtjj發表於2019-08-17

之前寫過一篇響應式原理-如何監聽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;
複製程式碼

大家可以自己在控制檯中嘗試一下,答案非常明顯了。

`Object.defineProperty`檢測陣列索引的變化

Vue只是沒有使用這個方式去監聽陣列索引的變化,因為尤大認為效能消耗太大,於是在效能和使用者體驗之間做了取捨。 詳細可見這邊文章Vue為什麼不能檢測陣列變動

Vue響應式原理 - 關於Array的特別處理

好了,終於揭開了謎底,為什麼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__?

  1. 判斷某個陣列是否已Observer過,避免重複執行。

  2. Vue.setVue.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是如何監聽的?這種情況其實監聽的是物件vmitems屬性,和陣列其實是沒關係的。因為之前發現有人誤解,這裡簡單的提示一下~

四、總結

本文主要還是講原理及思路,並不會涉及到很多程式碼,畢竟原始碼總會變。同時還要保證自己的js基礎紮實,閱讀原始碼才不會吃力哦~ 我就是很吃力的那種?

如果你覺得對你有幫助,就點個贊吧~

已完成:

Vue原始碼解讀系列篇

Github部落格 歡迎交流~

五、參考文獻

相關文章