Vue的變化偵測原理

家威Geek發表於2020-11-18

什麼是變化偵測

Vue.js會自動通過狀態生成DOM,並將其輸出到頁面上顯示出來,這個過程叫渲染。Vue.js的渲染過程是宣告式的,我們通過模板來描述狀態與DOM之間的對映關係。

通常,在執行時應用內部的狀態會不斷髮生變化,此時需要不停地重新渲染。這時如何確定狀態中發生了什麼變化?

變化偵測就是用來解決這個問題的,它分為兩種型別:一種是“推”(push),另一種是“拉”(pull)。

Angular和React中的變化偵測都屬於“拉”,這就是說當狀態發生變化時,它不知道哪個狀態變了,只知道狀態有可能變了,然後會傳送一個訊號告訴框架,框架內部收到訊號後,會進行一個暴力比對來找出哪些DOM節點需要重新渲染。這在Angular中是髒檢查的流程,在React中使用的是虛擬DOM。

而Vue.js的變化偵測屬於“推”。當狀態發生變化時,Vue.js立刻就知道了,而且在一定程度上知道哪些狀態變了。因此,它知道的資訊更多,也就可以進行更細粒度的更新。

所謂更細粒度的更新,就是說:假如有一個狀態繫結著好多個依賴,每個依賴表示一個具體的DOM節點,那麼當這個狀態發生變化時,向這個狀態的所有依賴傳送通知,讓它們進行DOM更新操作。相比較而言,“拉”的粒度是最粗的。

但是它也有一定的代價,因為粒度越細,每個狀態所繫結的依賴就越多,依賴追蹤在記憶體上的開銷就會越大。因此,從Vue.js 2.0開始,它引入了虛擬DOM,將粒度調整為中等粒度,即一個狀態所繫結的依賴不再是具體的DOM節點,而是一個元件。這樣狀態變化後,會通知到元件,元件內部再使用虛擬DOM進行比對。這可以大大降低依賴數量,從而降低依賴追蹤所消耗的記憶體。

Vue.js之所以能隨意調整粒度,本質上還要歸功於變化偵測。因為“推”型別的變化偵測可以隨意調整粒度。

如何追蹤變化

Object.defineProperty和ES6中的Proxy

Observer

Observer類會附加到每一個被偵測的object上。一旦被附加上,Observer會將object的所有屬性轉換為getter/setter的形式。來收集屬性的依賴,並且當屬性發生變化時會通知這些依賴

import Dep from './Dep';

export class Observer {

    constructor(value) {
        this.value = value;
        if (!Array.isArray(value)) {
            this.walk(value);
        }
    }
    walk(obj) {
        const keys = Object.keys(obj);
        for (let i = 0; i < keys.length; i++) {
            defineReactive(obj, keys[i], obj[keys[i]])
        }
    }
}

function defineReactive(data, key, val) {
    if (typeof val === 'object') {
        new Observer(val);
    }
    let dep = new Dep();
    Object.defineProperty(data, key, {
        enumerable: true,
        configurable: true,
        get() {
            dep.depend();//收集依賴
            return val
        },
        set(newVal) {
            if (val === newVal) {
                return
            }
            val = newVal;
            dep.notify();//觸發依賴
        }
    })
}

Dep

它用來收集依賴、刪除依賴和向依賴傳送訊息等。

import { Watcher } from "./Watcher";

export  class Dep {
    target; //target: ?Watcher;
    constructor() {
        this.subs = [];
    }

    addSub(sub) {
        this.subs.push(sub);
    }

    removeSub(sub) {
        remove(this.subs, sub);
    }
  
    depend(){
        if(this.target instanceof Watcher){
            this.addSub(this.target);
        }
    }

    notify(){
        const subs=this.subs.slice();
        for (let i = 0; i < subs.length; i++) {
            subs[i].update();
        }
    }
}

function remove(arr, item) {
    if (arr.length) {
        const index = arr.findIndex(item);
        if (index > -1) {
            this.subs.splice(index, 1);
        }
    }
}

Watcher

Watcher是一箇中介的角色,資料發生變化時通知它,然後它再通知其他地方。

import { Dep } from "./Dep";

export class Watcher {
    constructor(vm, expOrFn, cb) {
        this.vm = vm;// vm指當前的Vue例項
        this.getter = parsePath(expOrFn);
        this.cb = cb;
        this.value = this.get();// 讀取vm.$data中的值,同時會觸發屬性上的getter
    }

    get() {
        // Watcher把自己設定到全域性唯一的指定位置,在這裡就是Dep.target
        Dep.target = this;
        //讀取資料,觸發這個資料的getter。因此Observer會收集依賴,將這個Watcher收集到Dep,也就是依賴收集。
        let value = this.getter.call(this.vm, this.vm);
        //收集結束,清除Dep.target的內容
        Dep.target = null;
        //返回讀取到的資料值
        return value
    }

    update() {
        //資料改變之後,Dep會依次迴圈向依賴發通知,這裡接到通知之後,先獲取之前的舊資料
        const oldValue = this.value;
        //然後獲取最新的值
        this.value = this.get();
        //將新舊值傳給回撥函式
        this.cb.call(this.vm, this.value, oldValue);
    }
}

const bailRE = /[^\w.$]/
export function parsePath(path) {
    if (bailRE.tetx(path)) {
        return
    }
    const segments = path.split('.')
    return function (obj) {
        for (let i = 0; i < segments.length; i++) {
            if (!obj) { return; }
            obj = obj[segments[i]]
        }
        return obj;
    }
}

總結綜述

變化偵測就是偵測資料的變化。當資料發生變化時,要能偵測到併發出通知。

Object可以通過Object.defineProperty將屬性轉換成getter/setter的形式來追蹤變化。讀取資料時會觸發getter,修改資料時會觸發setter。

我們需要在getter中收集有哪些依賴使用了資料。當setter被觸發時,去通知getter中收集的依賴資料發生了變化。

收集依賴需要為依賴找一個儲存依賴的地方,為此我們建立了Dep,它用來收集依賴、刪除依賴和向依賴傳送訊息等。

所謂的依賴,其實就是Watcher。只有Watcher觸發的getter才會收集依賴,哪個Watcher觸發了getter,就把哪個Watcher收集到Dep中。當資料發生變化時,會迴圈依賴列表,把所有的Watcher都通知一遍。

Watcher的原理是先把自己設定到全域性唯一的指定位置(例如window.target),然後讀取資料。因為讀取了資料,所以會觸發這個資料的getter。接著,在getter中就會從全域性唯一的那個位置讀取當前正在讀取資料的Watcher,並把這個Watcher收集到Dep中去。通過這樣的方式,Watcher可以主動去訂閱任意一個資料的變化。

此外,我們建立了Observer類,它的作用是把一個object中的所有資料(包括子資料)都轉換成響應式的,也就是它會偵測object中所有資料(包括子資料)的變化。

由於在ES6之前JavaScript並沒有提供超程式設計的能力,所以在物件上新增屬性和刪除屬性都無法被追蹤到。

Data、Observer、Dep和Watcher之間的關係

Data通過Observer轉換成了getter/setter的形式來追蹤變化。

當外界通過Watcher讀取資料時,會觸發getter從而將Watcher新增到依賴中。

當資料發生了變化時,會觸發setter,從而向Dep中的依賴(Watcher)傳送通知。

Watcher接收到通知後,會向外界傳送通知,變化通知到外界後可能會觸發檢視更新,也有可能觸發使用者的某個回撥函式等。

在這裡插入圖片描述

參考資料:

相關文章