Vue響應式原理-如何監聽Array的變化?

蒼耳mtjj發表於2019-06-04

回憶

在上一篇Vue響應式原理-理解Observer、Dep、Watcher簡單講解了ObserverDepWatcher三者的關係。

Observer的虛擬碼中我們模擬瞭如下程式碼:

class Observer {
    constructor() {
        // 響應式繫結資料通過方法
    	observe(this.data);
    }
}

export function observe (data) {
    const keys = Object.keys(data);
    for (let i = 0; i < keys.length; i++) {
       // 將data中我們定義的每個屬性進行響應式繫結
       defineReactive(obj, keys[i]);
    }
}

export function defineReactive () {
    // ...省略 Object.defineProperty get-set
}
複製程式碼

今天我們就進一步瞭解Observer裡還做了什麼事。

Array的變化如何監聽?

data 中的資料如果是一個陣列怎麼辦?我們發現Object.defineProperty對陣列進行響應式化是有缺陷的。

雖然我們可以監聽到索引的改變。

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("資料被改變了,我要渲染到頁面上去!");
        }
    })
}

let data = [1];

// 對陣列key進行監聽
defineReactive(data, 0, 1);
console.log(data[0]); // 我被讀了,我要不要做點什麼好?
data[0] = 2; // 資料被改變了,我要渲染到頁面上去!
複製程式碼

但是defineProperty不能檢測到陣列長度的變化,準確的說是通過改變length而增加的長度不能監測到。這種情況無法觸發任何改變。

data.length = 0; // 控制檯沒有任何輸出
複製程式碼

而且監聽陣列所有索引的的代價也比較高,綜合一些其他因素,Vue用了另一個方案來處理。

首先我們的observe需要改造一下,單獨加一個陣列的處理。

// 將data中我們定義的每個屬性進行響應式繫結
export function observe (data) {
    const keys = Object.keys(data);
    for (let i = 0; i < keys.length; i++) {
        // 如果是陣列
        if (Array.isArray(keys[i])) {
            observeArray(keys[i]);
        } else {
            // 如果是物件
            defineReactive(obj, keys[i]);
        }
    }
}

// 陣列的處理
export function observeArray () {
    // ...省略
}
複製程式碼

那接下來我們就應該考慮下Array變化如何監聽?

Vue 中對這個陣列問題的解決方案非常的簡單粗暴,就是對能夠改變陣列的方法做了一些手腳。

我們知道,改變陣列的方法有很多,舉個例子比如說push方法吧。push存在Array.prototype上的,如果我們能

能攔截到原型上的push方法,是不是就可以做一些事情呢?

Object.defineProperty

物件裡目前存在的屬性描述符有兩種主要形式:資料描述符存取描述符存取描述符是由getter-setter函式對描述的屬性,也就是我們用來給物件做響應式繫結的。Object.defineProperty-MDN

雖然我們無法使用Object.defineProperty將陣列進行響應式的處理,也就是getter-setter,但是還有其他的功能可以供我們使用。就是資料描述符資料描述符是一個具有值的屬性,該值可能是可寫的,也可能不是可寫的。

value

該屬性對應的值。可以是任何有效的 JavaScript 值(數值,物件,函式等)。預設為 undefined

writable

當且僅當該屬性的writabletrue時,value才能被賦值運算子改變。預設為 false

因此我們只要把原型上的方法,進行value的重新賦值。

如下程式碼,在重新賦值的過程中,我們可以獲取到方法名和所有引數。

function def (obj, key) {
    Object.defineProperty(obj, key, {
        writable: true,
        enumerable: true,
        configurable: true,
        value: function(...args) {
            console.log('key', key);
            console.log('args', args); 
        }
    });
}

// 重寫的陣列方法
let obj = {
    push() {}
}

// 陣列方法的繫結
def(obj, 'push');

obj.push([1, 2], 7, 'hello!');
// 控制檯輸出 key push
// 控制檯輸出 args [Array(2), 7, "hello!"]
複製程式碼

通過如上程式碼我們就可以知道,使用者使用了陣列上原型的方法以及引數我們都可以攔截到,這個攔截的過程就可以做一些變化的通知。

Vue監聽Array三步曲

接下來,就看看Vue是如何實現的吧~

第一步:先獲取原生 Array 的原型方法,因為攔截後還是需要原生的方法幫我們實現陣列的變化。

第二步:對 Array 的原型方法使用 Object.defineProperty 做一些攔截操作。

第三步:把需要被攔截的 Array 型別的資料原型指向改造後原型。

我們將程式碼進行下改造,攔截的過程中還是要將開發者的引數傳給原生的方法,保證陣列按照開發者的想法被改變,然後我們再去做檢視的更新等操作。

const arrayProto = Array.prototype // 獲取Array的原型

function def (obj, key) {
    Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        value: function(...args) {
            console.log(key); // 控制檯輸出 push
            console.log(args); // 控制檯輸出 [Array(2), 7, "hello!"]
            
            // 獲取原生的方法
            let original = arrayProto[key];
            // 將開發者的引數傳給原生的方法,保證陣列按照開發者的想法被改變
            const result = original.apply(this, args);

            // do something 比如通知Vue檢視進行更新
            console.log('我的資料被改變了,檢視該更新啦');
            this.text = 'hello Vue';
            return result;
        }
    });
}

// 新的原型
let obj = {
    push() {}
}

// 重寫賦值
def(obj, 'push');

let arr = [0];

// 原型的指向重寫
arr.__proto__ = obj;

// 執行push
arr.push([1, 2], 7, 'hello!');
console.log(arr);
複製程式碼

被改變後的arr

Vue響應式原理-如何監聽Array的變化?

Vue原始碼解析

array.js

Vuearray.js中重寫了methodsToPatch中七個方法,並將重寫後的原型暴露出去。

// Object.defineProperty的封裝
import { def } from '../util/index' 

// 獲得原型上的方法
const arrayProto = Array.prototype 

// Vue攔截的方法
const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
];

// 將上面的方法重寫
methodsToPatch.forEach(function (method) {
    def(arrayMethods, method, function mutator (...args) {
        console.log('method', method); // 獲取方法
        console.log('args', args); // 獲取引數

    	// ...功能如上述,監聽到某個方法執行後,做一些對應的操作
      	// 1、將開發者的引數傳給原生的方法,保證陣列按照開發者的想法被改變
        // 2、檢視更新等
    })
})

export const arrayMethods = Object.create(arrayProto);
複製程式碼

observer

在進行資料observer繫結的時候,我們先判斷是否hasProto,如果存在__proto__,就直接將value__proto__指向重寫過後的原型。如果不能使用 __proto__,貌似有些瀏覽器廠商沒有實現。那就直接迴圈 arrayMethods把它身上的這些方法直接裝到 value 身上好了。畢竟呼叫某個方法是先去自身查詢,當自身找不到這關方法的時候,才去原型上查詢。

// 判斷是否有__proto__,因為部分瀏覽器是沒有__proto__
const hasProto = '__proto__' in {}
// 重寫後的原型
import { arrayMethods } from './array'
// 方法名
const arrayKeys = Object.getOwnPropertyNames(arrayMethods);

// 陣列的處理
export function observeArray (value) {
    // 如果有__proto__,直接覆蓋                
    if (hasProto) {
        protoAugment(value, arrayMethods);
    } else {
        // 沒有__proto__就把方法加到屬性自身上
        copyAugment(value, arrayMethods, )
    }
}

// 原型的賦值
function protoAugment (target, src) {
    target.__proto__ = src;
}

// 複製
function copyAugment (target, src, keys) {
    for (let i = 0, l = keys.length; i < l; i++) {
        const key = keys[i]
        def(target, key, src[key]);
    }
}
複製程式碼

通過上面的程式碼我們發現,沒有直接修改 Array.prototype,而是直接把 arrayMenthods 賦值給 value__proto__ 。因為這樣不會汙染全域性的Array, arrayMenthods 只對 data中的Array 生效。

總結

因為監聽的陣列帶來的代價和一些問題,Vue使用了重寫原型的方案代替。攔截了陣列的一些方法,在這個過程中再去做通知變化等操作。

本文的一些程式碼均是Vue原始碼簡化後的,為了方便大家理解。思想理解了,原始碼就容易看懂了。

Vue原始碼解讀系列篇

Github部落格 歡迎交流~

相關文章