Vue 原始碼學習(一)

eltonchan發表於2019-03-03

vue 無疑是一個非常棒的前端MVVM庫,懷著好奇的心情開始看VUE原始碼,當然遇到了很多的疑問,也查了很多的資料看了一些文章。但是這些資料很多都忽略了很重要的部分或者是一些重要的細節,亦或是一些很重要的部分沒有指出,特別是在computed的實現上。所以才打算寫這篇文章,記錄一下自己的學習過程,當然也希望能給其他想了解VUE原始碼的童鞋一點參考。如果筆者在某些地方理解有誤,也歡迎批評指正出來,一起學習。

為了加深理解,我按著原始碼的思路造了一個簡易的輪子,基本核心的實現是與VUE原始碼一致。測試 demo。倉庫的地址:eltonchan/rollup-ts

VUE的原始碼採用rollup flow至於為什麼不採用typescript,主要考慮工程上成本和收益的考量, 這一點尤大在知乎也有說過。(3.0+版本確定改用typesript)

Vue 2.0 為什麼選用 Flow 進行靜態程式碼檢查而不是直接使用TypeScript

不懂rollup 與typescript 也沒關係,本專案已經配置好了, 只需要先執行npm i (或者cnpm i)安裝相應依賴,然後 npm start 啟動就可以。 npm run build 構建,預設是輸出umd格式,如果需要cmd或者amd 可以在rollup.config.js配置檔案修改。

    output: {
        file: 'dist/bundle.js',
        format: 'umd',
        name: 'myBundle',
        sourcemap: true
    }
複製程式碼

questions ? 帶著問題去了解一個事物往往能帶來更好的收益,那我們就從下面幾個問題開始

  • 如何對http://this.xxx的訪問代理到http://this._data.xxx 上 ?
  • 如何實現資料劫持,監聽資料的讀寫操作 ?
  • 如何實現依賴快取 ?
  • template 改變的時候 如何清理依賴項集合? eg: v-if 、元件銷燬
  • 如何實現資料修改 dom更新 ?

vue實現雙向繫結原理,主要是利用Object.defineProperty getter/setter(事實上,大多數響應式程式設計的庫都是利用這個實現的,比如非常棒的mobx.js)和釋出訂閱模式(定義了物件間的一對多的依賴關係,當一個物件的狀態發生改變時,所有依賴於它的物件都將獲得通知),而在vue中,watcher 就是訂閱者,而一對多的依賴關係 就是指data上的屬性與watcher,而data上的屬性如何與watcher 關聯起來, dep 就是橋樑, 所以搞懂 dep, watcher, observe三者的關係,自然就搞懂了vue實現雙向繫結的原理了。

整體的流程圖


一、 Proxy 回到第一個問題, 答案其實是:對於每一個 data 上的key,都在 vm 上做一個代理,實際操作的是 this._data、 實現的程式碼如下:

export function proxy (target: IVue, sourceKey: string, key: string) {
    Object.defineProperty(target, key, {
        enumerable: true,
        configurable: true,
        get() {
            return this[sourceKey][key];
        },

        set(val: any) {
            this[sourceKey][key] = val;
        }
    });
}
複製程式碼

可以看出獲取和修改this.xx 都是在獲取或者修改this.data.xx;


二、Observer 用於把data上的屬性封裝成可觀察的屬性 用Object.defineProperty來攔截物件的讀寫gettet/setter操作, 在獲取的時候收集依賴, 在修改的時候通知相關的依賴。

    walk(data): void {
        if (!data || typeof data !== 'object') return;
        Object.keys(data).forEach(key => {
            this.defineReactive({
                data,
                key, 
                value: data[key]
            });
        });
    }

    defineReactive({ data, key, value }: IReactive): void {
        const dep = new Dep();
        this.walk(value);
        const self = this;
        Object.defineProperty(data, key, {
            enumerable: true,
            configurable: true,
            get() {
                if (Dep.target) {
                    Dep.target.addDep(dep);
                }
                return value;
            },
    
            set(newVal: any): void {
                if (value === newVal) return;
                self.walk(value);
                value = newVal;
                dep.notify();
            }
    
        });
    }
複製程式碼

可以看出, 在get的時候收集依賴,而Dep.target 其實就是watcher, 等下講到watcher的時候再回過來, 這裡要關注dep 其實dep在這裡是一個閉包環境,在執行get 或者set的時候 還可以訪問到建立的dep. 比如 this.name當在獲取this.name的值的時候 會建立一個Dep的例項, 把watcher 新增到這個dep中。

Vue 原始碼學習(一)

為什麼物件上新增屬性不會監聽,而修改整個物件為什麼能檢測到子屬性的變化 ?

由於 JavaScript 的限制,Vue 不能檢測物件屬性的新增或刪除(當然mobx也不例外的)。所以可觀察的物件屬性的新增或者刪除無法觸發set 方法,而直接修改物件則可以,而在set 方法中則會判斷新值是否是物件陣列型別,如果是 則子屬性封裝成可觀察的屬性,這也是set中self.walk(value);的作用。


三、Watcher 剛才提到了watcher,從上圖中也可以看到了watcher的作用 事實上,每一個computed屬性和watch 都會new 一個 Watcher。接下來會講到。先來看watcher的實現。

    constructor(
        vm: IVue,
        expression: Function | string,
        cb: Function,
    ) {
        this.vm = vm;
        vm._watchers.push(this);

        this.cb = cb || noop;

        this.id = ++uid;

        // 處理watch 的情況
        if (typeof expression === 'function') {
            this.getter = expression;
        } else {
            this.getter = () => vm[expression];
        }

        this.expression = expression.toString();

        this.depIds = new Set();
        this.newDepIds = new Set();
        this.deps = [];
        this.newDeps = [];

        this.value = this.get();
    }
複製程式碼

這裡的expression,對於初始化用來渲染檢視的watcher來說,就是render方法,對於computed來說就是表示式,對於watch才是key,所以這邊需要判斷是字串還是函式,而getter方法是用來取value的。這邊有個depIds,deps,但是又有個newDepIds,newDeps,為什麼這樣設計,接下去再講,先看this.value = this.get();可以看出在這裡給watcher的value賦值,再來看get方法。

    get() :void {
        Dep.target = this;
        const value = this.getter.call(this.vm); // 執行一次get 收集依賴
        Dep.target = null;
        this.cleanupDeps(); // 清除依賴
        return value;
    }
複製程式碼

可以看到getter是用來取值的,當執行這一行程式碼的時候,以render的那個watcher為例,會執行VNode render 當遇到{{ msg }}的表示式的時候會取值,這個時候會觸發msg的get方法,而此時的Dep.target 就是這個watcher, 所以我們會把這個render的watcher和msg這個屬性關聯起,也就是msg的dep已經有render的這個watcher了。這個就是Watcher,Dep,Observer的關係。我們再來看Dep:

export default class Dep implements IDep {
    static target: any = null;
    subs:any = [];
    id;

    constructor () {
        this.id = uid++;
        this.subs = [];
    }

    addSub(sub: IWatcher): void {
        if (this.subs.find(o => o.id === sub.id)) return;
        this.subs.push(sub);
    }

    removeSub (sub: IWatcher) {
        const idx = this.subs.findIndex(o => o.id === sub.id);
        if (idx >= 0) this.subs.splice(idx, 1);
    }

    notify():void {
        this.subs.forEach((sub: Isub) => {
            sub.update();
        })
    }

    depend () {
        if (Dep.target) {
            Dep.target.addDep(this);
        }
    }
}
複製程式碼

Dep的實現很簡單,這邊看notify的方法,我們知道在修改data上的屬性的時候回觸發set,然後觸發notify方法,然後我們知道sub就是watcher,所以watcher.update方法就是修改屬性所執行的方法,回到watcher看這個update的實現。

    update() {
        // 推送到觀察者佇列中,下一個tick時呼叫。*/
        queueWatcher(this);
    }

    run(cb) {
        const value = this.get();
        if (value !== this.value) {
            const oldValue = this.value;
            this.value = value;
            cb.call(this.vm, value, oldValue);
        }
    }
複製程式碼

update方法並沒有直接render vNode。而是把watcher推到一個佇列中,事實上vue是的更新dom是非同步的,為什麼要非同步更新佇列,這邊摘抄了一下官網的描述:Vue 非同步執行 DOM 更新。只要觀察到資料變化,Vue 將開啟一個佇列,並緩衝在同一事件迴圈中發生的所有資料改變。如果同一個 watcher 被多次觸發,只會被推入到佇列中一次。這種在緩衝時去除重複資料對於避免不必要的計算和 DOM 操作上非常重要。然後,在下一個的事件迴圈“tick”中,Vue 重新整理佇列並執行實際 (已去重的) 工作。Vue 在內部嘗試對非同步佇列使用原生的 Promise.then 和 MessageChannel,如果執行環境不支援,會採用 setTimeout(fn, 0) 代替。其實這是一種非常好的效能優化方案,我們設想一下如果在mounted中迴圈賦值,如果不採用非同步更新策略,每一個賦值都更新,完全是一種浪費。

Vue 原始碼學習(一)

四、nextTick 關於nextTick其實很多文章寫的都不錯,這邊就不詳細介紹了。涉及到的概念可以點選下面連結檢視:

JavaScript 執行機制詳解:再談Event Loop

關於 macrotask 和 microtask

Vue 原始碼學習(一)


五、Computed 計算屬性是基於它們的依賴進行快取的。只在相關依賴發生改變時它們才會重新求值 回到開始的那個問題,如何實現依賴快取? name的更新如何讓info也更新,如果name不變,info如何取值?

剛才在講watcher的時候,提到過每個computed會例項化一個Watcher,從下面程式碼中也可以看出來,每一個computed屬性都有一個訂閱者watcher。

    initComputed(computed) {
        if (!computed || typeof computed !== 'object') return;
        const keys = Object.keys(computed);
        const watchers = this._computedWatchers;
        let i = keys.length;
        while(i--) {
            const key = keys[i];
            const func = computed[key];
            watchers[key] = new Watcher(
                this,
                func || noop,
                noop,
            );

            defineComputed(this, key);
        }
    }
複製程式碼

看這個例子:

    computed: {
        info() {
            console.info('computed update');
            return this.name + 'hello';
        }
    },
複製程式碼

watcher 的getter方法就是computed屬性的表示式,而在執行this.value = this.get();這個value就會是表示式的執行結果,所以其實Vue是把info的值儲存在它的watcher的value裡面的,然後又知道在取name的值的時候,會觸發name的get方法,此時的Dep.target 就是這個info的watcher,而dep是一個閉包,還是之前收集name的那個dep, 所以name的dep就會有兩個watcher,[renderWatcher, computedWatcher], 當name更新的時候,這兩個訂閱者watcher都會收到通知,這也就是name的更新讓info也更新。

Vue 原始碼學習(一)

那info的值是watcher的value, 所以這邊要做一個代理,把computed屬性的取值代理到對應watcher的value,實現起來也很簡單。

export default function defineComputed(vm: IVue, key: string) {
    Object.defineProperty(vm, key, {
        enumerable: true,
        configurable: true,
        get() {
            const watcher = vm._computedWatchers && vm._computedWatchers[key];
            return watcher.value;
        },
    });
}
複製程式碼

六、依賴更新

<p v-if="switch">{{ name }}</p>
複製程式碼

假設switch由true切換成false時候,是需要把name上面的renderWatcher刪除掉的,所以需要用depIds和deps的屬性來記錄dep。

    addDep(dep: Dep) {
        const id = dep.id;
        if (!this.newDepIds.has(id)) {
            this.newDepIds.add(id);
            this.newDeps.push(dep);
            if (!this.depIds.has(id)) {
                dep.addSub(this);
            }
        }
    }

    cleanupDeps() {
        let i = this.deps.length;
        while (i--) {
            const dep = this.deps[i];
            if (!this.depIds.has(dep.id)) {
                dep.removeSub(this);
            }
        }

        const tmp = this.depIds;
        this.depIds = this.newDepIds;
        this.newDepIds = tmp;
        this.newDepIds.clear();

        const deps = this.deps;
        this.deps = this.newDeps;
        this.newDeps = deps;
        this.newDeps.length = 0;
    }
複製程式碼

這裡把newDepIds賦值給了depIds, 然後newDepIds再清空,deps也是這樣的操作,這是一種效率很高的操作,避免使用了深拷貝。新增依賴的時候都是用newDepIds,newDeps來記錄,刪除的時候會去deps裡面遍歷查詢,等刪除了再把newDepIds賦值給depIds,這樣能保證在更新依賴的時候,沒有使用的依賴會從這個watcher中移除。

Vue 原始碼學習(一)

七、watch 為什麼watch 一個物件的時候 oldValue == value ?

watch的屬性也是一個例項化的Watcher,只是這個時候的expression是key,value 是vm[key],而cb就是回撥函式,所以這個時候對應屬性的dep中自然就有這個watcher。

    initWatch(watch) {
        if (!watch || typeof watch !== 'object') return;
        const keys = Object.keys(watch);
        let i = keys.length;

        while(i--) {
            const key = keys[i];
            const cb = watch[key];
            new Watcher(this, key, cb);
        }
    }
複製程式碼

當屬性更新的時候,會執行到這個run方法, 當watch一個物件的時候,watcher的value其實是一個引用,修改這個屬性的時候,this.value也同步修改了,所以也就是為什麼oldValue == value了, 至於作者為什麼這麼設計,我想肯定是有他原因的。

    run(cb) {
        const value = this.get();
        if (value !== this.value) {
            const oldValue = this.value;
            this.value = value;
            cb.call(this.vm, value, oldValue);
        }
    }
複製程式碼

八、Compile vue 2+ 已經使用VNode了,這部分還沒有細緻研究過,所以我這邊自己寫了個簡易的Compile,這部分已經和原始碼沒有關係了。主要用到了DocumentFragment和閉包而已,有興趣的童鞋可以到這個倉庫檢視。

components vnode 待補充...

相關文章