Vue2非同步批量更新與computed、watcher原理實現

JS_Even_JS發表於2020-08-07

Vue2響應式原理與實現
Vue2元件掛載與物件陣列依賴收集

一、實現Vue2生命週期

Vue2中生命週期可以在建立Vue例項傳入的配置物件中進行配置,也可以通過全域性的Vue.mixin()方法來混入生命週期鉤子,如:

Vue.mixin({
    a: {
        b: 1
    },
    c: 3,
    beforeCreate () { // 混入beforeCreate鉤子
        console.log("beforeCreate1");
    },
    created () { // 混入created鉤子
        console.log("created1");
    }
});

Vue.mixin({
    a: {
        b: 2
    },
    d: 4,
    beforeCreate () { // 混入beforeCreate鉤子
        console.log("beforeCreate2");
    },
    created () { // 混入created鉤子
        console.log("created2");
    }
});

所以在實現生命週期前,我們需要實現Vue.mixin()這個全域性的方法,將混入的所有生命週期鉤子進行合併之後再到合適的時機去執行生命週期的各個鉤子。我們可以將全域性的api放到一個單獨的模組中,如:

// src/index.js
import {initGlobalApi} from "./globalApi/index";
function Vue(options) {
    this._init(options);
}
initGlobalApi(Vue); // 混入全域性的API
// src/globalApi/index.js
import {mergeOptions} from "../utils/index"; // mergeOptions可能會被多次使用,單獨放到工具類中
export function initGlobalApi(Vue) {
    Vue.options = {}; // 初始化一個options物件並掛載到Vue上
    Vue.mixin = function(options) {
        this.options = mergeOptions(this.options, options); // 將傳入的options物件進行合併,這裡的this就是指Vue
    }
}

接下來就開始實現mergeOptions這個工具方法,該方法可以合併生命週期的鉤子也可以合併普通物件,合併的思路很簡單,首先遍歷父物件中的所有屬性對父子物件中的各個屬性合併一次,然後再遍歷子物件找出父物件中不存在的屬性再合併一次,經過兩次合併即可完成父子物件中所有屬性的合併。

export function mergeOptions(parent, child) {
    const options = {}; // 用於儲存合併結果
    for (let key in parent) { // 遍歷父物件上的所有屬性合併一次
        mergeField(key);
    }
    
    for (let key in child) { // 遍歷子物件上的所有屬性
        if (!Object.hasOwnProperty(parent, key)) { // 找出父物件中不存在的屬性,即未合併過的屬性,合併一次
            mergeField(key);
        }
    }
    return options; // 經過兩次合併即可完成父子物件各個屬性的合併
}

接下來就是要實現mergeField()方法,對於普通物件的合併而言非常簡單,為了方便,我們可以將mergeField()方法放到mergeOptions內部,如:

export function mergeOptions(parent, child) {
    function mergeField(key) {
        if (isObject(parent[key]) && isObject(child[key])) { // 如果父子物件中的同一個key對應的值都是物件,那麼直接解構父子物件,如果屬性相同,用子物件覆蓋即可
            options[key] = {
                ...parent[key],
                ...child[key]
            }
            
        } else { // 對於不全是物件的情況,子有就用子的值,子沒有就用父的值
            options[key] = child[key] || parent[key];
        }
    }
}

而對於生命週期的合併,我們需要將相同的生命週期放到一個陣列中,等合適的時機依次執行,我們可以通過策略模式實現,如:

const stras = {};
const hooks = [
    "beforeCreate",
    "created",
    "beforeMount",
    "mounted"
];
function mergeHook(parentVal, childVal) {
    if (childVal) { // 子存在
        if(parentVal) { // 子存在,父也存在,直接合並即可
            return parentVal.concat(childVal);
        } else { // 子存在,父不存在,一開始父中肯定不存在
            return [childVal];
        }
    } else { // 子不存在,直接使用父的即可
        return parentVal;
    }
}
hooks.forEach((hook) => {
    stras[hook] = mergeHook; // 每一種鉤子對應一種策略
});

合併生命週期的時候parent一開始是{},所以肯定是父中不存在子中存在,此時返回一個陣列並將子物件中的生命週期放到陣列中即可,之後的合併父子都有可能存在,父子都存在,那麼直接將子物件中的生命週期鉤子追加進去即可,如果父存在子不存在,直接使用父的即可。

// 往mergeField新增生命週期的策略合併
function mergeField(key) {
    if (stras[key]) { // 如果存在對應的策略,即生命週期鉤子合併
        options[key] = stras[key](parent[key], child[key]); // 傳入鉤子進行合併即可
    } else if (isObject(parent[key]) && isObject(child[key])) {
    
    } else {
        
    }
}

完成Vue.mixin()全域性api中的options合併之後,我們還需要與使用者建立Vue例項時候傳入的options再進行合併,生成最終的options並儲存到vm.$options中,如:

// src/init.js
import {mountComponent, callHook} from "./lifecyle";
export function initMixin(Vue) {
    Vue.prototype._init = function(options) {
        const vm = this;
        // vm.$options = options;
        vm.$options = mergeOptions(vm.constructor.options, options); // vm.constructor就是指Vue,即將全域性的Vue.options與使用者傳入的options進行合併
        callHook(vm, "beforeCreate"); // 資料初始化前執行beforeCreate
        initState(vm);
        callHook(vm, "created"); // 資料初始化後執行created
    }
}
// src/lifecyle.js
export function mountComponent(vm, el) {
    callHook(vm, "beforeMount"); // 渲染前執行beforeMount
    new Watcher(vm, updateComponent, () => {}, {}, true);
    callHook(vm, "mounted"); // 渲染後執行mounted
}

我們已經在合適時機呼叫了callHook()方法去執行生命週期鉤子,接下來就是實現callHook()方法,即拿到對應鉤子的陣列遍歷執行,如:

// src/lifecyle.js
export function callHook(vm, hook) {
    const handlers = vm.$options[hook]; // 取出對應的鉤子陣列
    handlers && handlers.forEach((handler) => { // 遍歷鉤子
        handler.call(vm); // 依次執行即可
    });
}

二、非同步批量更新

目前我們是每次資料發生變化後,就會觸發set()方法,進而觸發對應的dep物件呼叫notify()給渲染watcher派發通知,從而讓頁面更新。如果我們執行vm.name = "react"; vm.name="node",那麼可以看到頁面會渲染兩次,因為資料被修改了兩次,所以每次都會通知渲染watcher進行頁面更新操作,這樣會影響效能,而對於上面的操作,我們可以將其合併成一次更新即可。
其實現方式為,將需要執行更新操作的watcher先快取到佇列中,然後開啟一個定時器等同步修改資料的操作完成後,開始執行這個定時器,非同步重新整理watcher佇列,執行更新操作。
新建一個scheduler.js用於完成非同步更新操作,如:

// src/observer/scheduler.js
let queue = []; // 存放watcher
let has = {}; // 判斷當前watcher是否在佇列中
let pending = false; // 用於標識是否處於pending狀態
export function queueWatcher(watcher) {
    const id = watcher.id; // 取出watcher的id
    if (!has[id]) { // 如果佇列中還沒有快取該watcher
        has[id] = true; // 標記該watcher已經快取過
        queue.push(watcher); // 將watcher放到佇列中
        if (!pending) { // 如果當前佇列沒有處於pending狀態
            setTimeout(flushSchedulerQueue, 0); // 開啟一個定時器,非同步重新整理佇列
            pending = true; // 進入pending狀態,防止新增多個watcher的時候開啟多個定時器
        }
    }    
}
// 重新整理佇列,遍歷儲存的watcher並呼叫其run()方法執行
function flushSchedulerQueue() {
    for (let i = 0; i < queue.length; i++) {
        const watcher = queue[i];
        watcher.run();
    }
    queue = []; // 清空佇列
    has = {};
}

修改watcher.js,需要修改update()方法,update()將不再立即執行更新操作,而是將watcher放入佇列中快取起來,因為update()方法已經被另做他用,所以同時需要新增一個run()方法讓wather可以執行更新操作

// src/observer/watcher.js
import {queueWatcher} from "./scheduler";
export default class Watcher {
    update() {
        // this.get(); // update方法不再立即執行更新操作
        queueWatcher(this); // 先將watcher放到佇列中快取起來
    }
    run() { // 代替原來的update方法執行更新操作
        this.get();
    }
}

三、實現nextTick

目前已經實現非同步批量更新,但是如果我們執行vm.name = "react";console.log(document.getElementById("app").innerHTML),我們從輸出結果可以看到,拿到innerHTML仍然是舊的,即模板中使用的name值仍然是更新前的。之所以這樣是因為我們將渲染watcher放到了一個佇列中,等資料修改完畢之後再去非同步執行渲染wather去更新頁面,而上面程式碼是在資料修改後同步去操作DOM此時渲染watcher還沒有執行,所以拿到的是更新前的資料。
要想在資料修改之後立即拿到最新的資料,那麼必須在等渲染Watcher執行完畢之後再去操作DOM,Vue提供了一個$nextTick(fn)方法可以實現在fn函式內操作DOM拿到最新的資料。
其實現思路就是,渲染watcher進入佇列中後不立即開啟一個定時器去清空watcher佇列,而是將清空watcher佇列的方法傳遞給nextTick函式nextTick也維護一個回撥函式佇列將清空watcher佇列的方法新增到nextTick的回撥函式佇列中,然後在nextTick中開啟定時器,去清空nextTick的回撥函式佇列。所以此時我們只需要再次呼叫nextTick()方法追加一個函式,就可以保證在該函式內操作DOM能拿到最新的資料,因為清空watcher的佇列在nextTick的頭部,最先執行

// src/observer/watcher.js 
export function queueWatcher(watcher) {
    const id = watcher.id; // 取出watcher的id
    if (!has[id]) { // 如果佇列中還沒有快取該watcher
        has[id] = true; // 標記該watcher已經快取過
        queue.push(watcher); // 將watcher放到佇列中
        // if (!pending) { // 如果當前佇列沒有處於pending狀態
            // setTimeout(flushSchedulerQueue, 0); // 開啟一個定時器,非同步重新整理佇列
            // pending = true; // 進入pending狀態,防止新增多個watcher的時候開啟多個定時器
        // }
        nextTick(flushSchedulerQueue); // 不是立即建立一個定時器,而是呼叫nextTick,將清空佇列的函式放到nextTick的回撥函式佇列中,由nextTick去建立定時器    
    }    
}

let callbacks = []; // 存放nextTick回撥函式佇列
export function nextTick(fn) {
    callbacks.push(fn); // 將傳入的回撥函式fn放到佇列中
    if (!pending) { // 如果處於非pending狀態
        setTimeout(flushCallbacksQueue, 0);
        pending = true; // 進入pending狀態,防止每次呼叫nextTick都建立定時器
    }
}
function flushCallbacksQueue() {
    callbacks.forEach((fn) => {
        fn();
    });
    callbacks = []; // 清空回撥函式佇列
    pending = false; // 進入非pending狀態
}

四、實現計算屬性watcher

計算屬性本質也是建立了一個Watcher物件,只不過計算屬性watcher有些特性,比如計算屬性可以快取只有依賴的資料發生變化才會重新計算。為了能夠快取,我們需要記錄下watcher的值,需要給watcher新增一個value屬性,當依賴的資料沒有變化的時候,直接從計算watcher的value中取值即可。建立計算watcher的時候需要傳遞lazy: true,標識需要懶載入即計算屬性的watcher。

// src/state.js
import Watcher from "./observer/watcher";
function initComputed(vm) {
    const computed = vm.$options.computed; // 取出使用者配置的computed屬性
    const watchers = vm._computedWatchers = Object.create(null); // 建立一個物件用於儲存計算watcher
    for (let key in computed) { // 遍歷計算屬性的key
        const userDef = computed[key]; // 取出對應key的值,可能是一個函式也可能是一個物件
        // 如果是函式那麼就使用該函式作為getter,如果是物件則使用物件的get屬性對應的函式作為getter
        const getter = typeof userDef === "function" ? userDef : userDef.get;
        watchers[key] = new Watcher(vm, getter, () => {}, {lazy: true}); // 建立一個Watcher物件作為計算watcher,並傳入lazy: true標識為計算watcher
        if (! (key in vm)) { // 如果這個key不在vm例項上
            defineComputed(vm, key, userDef); // 將當前計算屬性代理到Vue例項物件上
        }
    }
}

計算屬性的初始化很簡單,就是取出使用者配置的計算屬性執行函式,然後建立計算watcher物件,並傳入lazy為true標識為計算watcher。為了方便操作,還需要將計算屬性代理到Vue例項上,如:

// src/state.js
function defineComputed(vm, key, userDef) {
    let getter = null;
    if (typeof userDef === "function") {
        getter = createComputedGetter(key); // 傳入key建立一個計算屬性的getter
    } else {
        getter = userDef.get;
    }

    Object.defineProperty(vm, key, { // 將當前計算屬性代理到Vue例項物件上
        configurable: true,
        enumerable: true,
        get: getter,
        set: function() {} // 未實現setter
    });
}

計算屬性最關鍵的就是計算屬性的getter,由於計算屬性存在快取,當我們去取計算屬性的值的時候,需要先看一下當前計算watcher是否處於dirty狀態處於dirty狀態才需要重新去計算求值

// src/state.js
function createComputedGetter(key) {
    return function computedGetter() {
        const watcher = this._computedWatchers[key]; // 根據key值取出對應的計算watcher
        if (watcher) {
            if (watcher.dirty) { // 如果計算屬性當前是髒的,即資料有被修改,那麼重新求值
                watcher.evaluate();
            }
            // watcher計算完畢之後就會將計算watcher從棧頂移除,所以Dep.target會變成渲染watcher
            if (Dep.target) { // 這裡拿到的是渲染Watcher,但是先建立的是計算Watcher,初始化就會建立對應的計算Watcher
                watcher.depend(); // 呼叫計算watcher的depend方法,收集渲染watcher(將渲染watcher加入到訂閱者列表中)
            }
            return watcher.value; // 如果資料沒有變化,則直接返回之前的值,不再進行計算
        }
    }
}

這裡最關鍵的就是計算屬性求值完畢之後,需要呼叫其depend()方法收集渲染watcher的依賴,即將渲染watcher加入到計算watcher所依賴key對應ddep物件的觀察者列表中。比如,模板中僅僅使用到了一個計算屬性:

<div id="app">{{fullName}}</div>
new Vue({
    data: {name: "vue"},
    computed:() {
        return "li" + this.name
    }
});

當頁面開始渲染的時候,即渲染watcher執行的時候,會首先將渲染watcher加入到棧頂,然後取計算屬性fullName的值,此時會將計算watcher加入到棧頂,然後求計算屬性的值,計算屬性依賴了name屬性,接著去取name的值,name對應的dep物件就會將計算watcher放到其觀察者列表中,計算屬性求值完畢後,計算watcher從棧頂移除,此時棧頂變成了渲染watcher,但是由於模板中只使用到了計算屬性,所以name對應的dep物件並沒有將渲染watcher放到其觀察者列表中,所以當name值發生變化的時候,無法通知渲染watcher更新頁面。所以我們需要在計算屬性求值完畢後遍歷計算watcher依賴的key並拿到對應的dep物件將渲染watcher放到其觀察者列表中

// src/observer/watcehr.js
export default class Watcher {
    constructor(vm, exprOrFn, cb, options, isRenderWatcher) {
            if (options) {
                this.lazy = !!options.lazy;// 標識是否為計算watcher
            } else {
                this.lazy = false;
            }
            this.dirty = this.lazy; // 如果是計算watcher,則預設dirty為true
            this.value = this.lazy ? undefined : this.get(); // 計算watcher需要求值,新增一個value屬性
    }
    get() {
        pushTarget(this);
        // this.getter.call(this.vm, this.vm);
        const value = this.getter.call(this.vm, this.vm); // 返回計算結果
        popTarget();
        return value;
    }
    update() {
        // queueWatcher(this); //計算wather不需要立即執行,需要進行區分
        if (this.lazy) { // 如果是計算watcher
            this.dirty = true; // 將計算屬watcher的dirtry標識為了髒了即可
        } else {
            queueWatcher(this);
        }
    }

    evaluate() {
        this.value = this.get(); // 執行計算watcher拿到計算屬性的值
        this.dirty = false; // 計算屬性求值完畢後將dirty標記為false,表示目前資料是乾淨的
    }

    depend() { // 由計算watcher執行
        let i = this.deps.length;
        while(i--) { // 遍歷計算watcher依賴了哪些key
            this.deps[i].depend(); // 拿到對應的dep物件收集依賴將渲染watcher新增到其觀察者列表中
        }
    }
}

五、實現使用者watcher

使用者watcher也是一個Watcher物件,只不過建立使用者watcher的時候傳入的是data中的key名而不是函式表示式,所以需要將傳入的key轉換為一個函式表示式使用者watcher不是在模板中使用,所以使用者watcher關鍵在於執行傳入的回撥

// src/state.js
function initWatch(vm) {
    const watch = vm.$options.watch; // 拿到使用者配置的watch
    for (let key in watch) { // 遍歷watch監聽了data中的哪些屬性
        const handler = watch[key]; // 拿到資料變化後的處理回撥函式
        new Watcher(vm, key, handler, {user: true}); // 為使用者watch建立Watcher物件,並標識user: true
    }
}

使用者watcher需要將監聽的key轉換成函式表示式

export default class Watcher {
    constructor(vm, exprOrFn, cb, options, isRenderWatcher) {
        if (typeof exprOrFn === "function") {
        } else {
            this.getter = parsePath(exprOrFn);// 將監聽的key轉換為函式表示式
        }
        if (options) {
            this.lazy = !!options.lazy; // 標識是否為計算watcher
            this.user = !!options.user; // 標識是否為使用者watcher
        } else {
            this.user = this.lazy = false; 
        }
    }
  
    run() {
        const value = this.get(); // 執行get()拿到最新的值
        const oldValue = this.value; // 儲存舊的值
        this.value = value; // 儲存新值
        if (this.user) { // 如果是使用者的watcher
            try {
                this.cb.call(this.vm, value, oldValue); // 執行使用者watcher的回撥函式,並傳入新值和舊值
            } catch(err) {
                console.error(err);
            }
        } else {
            this.cb && this.cb.call(this.vm, oldValue, value); // 渲染watcher執行回撥
        }
    }
}
function parsePath(path) {
    const segments = path.split("."); // 如果監聽的key比較深,以點號對監聽的key進行分割為陣列
    return function(vm) { // 返回一個函式
        for (let i = 0; i < segments.length; i++) {
            if (!vm) {
                return;
            }
            vm = vm[segments[i]]; // 這裡會進行取值操作
        }
        return vm;
    }
}

還需要注意的是,dep物件notify方法通知觀察者列表中的watcher執行的時候必須保證渲染watcher最後執行,如果渲染Watcher先執行,那麼當渲染watcher使用計算屬性的時候,求值的時候發現計算watcher的dirty值仍然為false,導致計算屬性拿到值仍為之前的值,即快取的值,必須讓計算watcher先執行將dirty變為true之後再執行渲染watcher,才能拿到計算屬性最新的值,所以需要對觀察者列表進行排序

由於計算watcher和使用者watcher在狀態初始化的時候就會建立,而渲染watcher是在渲染的時候才開始建立,所以我們可以按照建立順序進行排序,後面建立的id越大,即按id從小到大進行排序即可。

export default class Dep {
    notify() {
        this.subs.sort((a, b) => a.id - b.id); // 對觀察者列表中的watcher進行排序保證渲染watcher最後執行
        this.subs.forEach((watcher) => {
            watcher.update();
        });
    }
}

相關文章