Vue2元件掛載與物件陣列依賴收集

JS_Even_JS發表於2020-08-06

Vue2響應式原理與實現

一、Vue模板渲染

上一篇文章中已經實現了Vue的響應式系統,接下來就是要將Vue的模板進行掛載並渲染出對應的介面。

渲染的入口就是呼叫Vue例項的$mount()方法,其會接收一個選擇器名作為引數,Vue進行模板渲染的時候,所使用的模板是有一定優先順序的:
① 如果使用者傳遞的options物件中包含render屬性,那麼就會優先使用使用者配置的render()函式中包含的模板進行渲染;
② 如果使用者傳遞的options物件中不包含render屬性,但是包含template屬性,那麼會使用使用者配置的template屬性中對應的模板進行渲染;
③ 如果使用者傳遞的options物件中不包含render屬性,也不包含template屬性,那麼會使用掛載點el對應的DOM作為模板進行渲染。

實現上一步中遺留的的$mount()方法:

// src/init.js
import {compileToFunction} from "./compile/index";
import {mountComponent} from "./lifecyle";
export function initMixin(Vue) {
    Vue.prototype.$mount = function(el) { // 傳入el選擇器
        const vm = this;
        const options = vm.$options;
        el = document.querySelector(el); // 根據傳入的el選擇器拿到Vue例項的掛載點
        if (!options.render) { // 如果沒有配置render渲染函式
            let template = options.template; // 看看使用者有沒有配置template模板屬性
            if (!template) { // 如果也沒有配置模板
                template = el.outerHTML; // 那麼就用掛載點知道DOM作為模板
            }
            options.render = compileToFunction(template); // 拿到模板之後將其編譯為渲染函式
        }
        mountComponent(vm, el); // 傳入vm例項和el開始掛載元件
    }
}

所以$mount()函式主要就是要初始化對應的渲染函式,有了渲染函式就可以開始渲染了。渲染屬於生命週期的一部分,我們將mountComponent()放到lifecycle.js中。

mountComponent方法中主要做的事情就是建立一個渲染Watcher。在建立渲染Watcher的時候會傳入一個函式,這個函式就是用於更新元件的。而元件的更新需要做的就是執行render渲染函式拿到對應的虛擬DOM,然後與舊的虛擬DOM進行比較找到變化的部分並應用到真實DOM
新增一個watcher.js,watcher屬於資料觀測的一部分,所以需要放到src/observer下,如:

// src/observer/watcher.js
let id = 0;
export default class Watcher {
    constructor(vm, exprOrFn, cb, options, isRenderWatcher) {
        this.vm = vm;
        this.id = id++;
        if (typeof exprOrFn === "function") { // 建立Watcher的時候傳遞的是一個函式,這個函式會立即執行
            this.getter = exprOrFn;
        }
        this.cb = cb;
        this.options = options;
        this.isRenderWatcher = isRenderWatcher;
        this.get(); // 讓傳入的Watcher的函式或表示式立即執行
    }
    get() {
        this.getter.call(this.vm, this.vm);
    }
}
// src/lifecycle.js
export function mountComponent(vm, el) {
    vm.$el = el; // 將掛載點儲存到Vue例項的$el屬性上
    // beforeMount
    let updateComponent = () => {
        vm._update(vm._render());
    }
    // 建立一個渲染Watcher並傳入updateComponent()函式,在建立渲染Watcher的時候會立即執行
    new Watcher(vm, updateComponent, () => {}, {}, true);
    // mounted
}

隨著渲染Watcher的建立,updateComponent()函式也跟著執行,即執行vm._render(),拿到虛擬DOM,需要給Vue的原型上新增一個_render()方法,和之前一樣通過renderMixin()將Vue混入,如:

// src/render.js
export function renderMixin(Vue) {
    Vue.prototype._render = function() {
        const vm = this;
        const render = vm.$options.render; // 取出render渲染函式
        return render.call(vm); // 讓render渲染函式執行返回虛擬DOM節點
    }
}
// src/index.js 在其中引入renderMixin並傳入Vue,以便在Vue原型上混入_render()方法
+ import {renderMixin} from "./render";
function Vue() {

}
+ renderMixin(Vue);

假設我們建立Vue例項的時候配置了一個template屬性,值為:

<div id='app' style='background:red;height:300px;' key='haha'>
    hello {{name}}
    <span style='color:blue;'>{{name}} {{arr}}</span>
</div>

那麼這個模板經過compileToFunction()函式編譯後就會變成一個render渲染函式,如下所示:

(function anonymous(
) {
with(this) {return _c("div", {id: "app",style: {"background":"red","height":"300px"},key: "haha"},_v("hello"+_s(name)),_c("span", {style: {"color":"blue"}},_v(_s(name)+_s(arr)))
    )
    }
})

渲染函式內部使用了with(this){},在執行渲染函式的時候會傳入Vue例項,所以這個this就是指Vue例項,對於其中的_c()、_v()、_s()其實就是vm._c()vm._v()vm._s()
所以我們還需要在renderMixin()內給Vue原型混入_c()、_v()、_s()這幾個方法

_s()方法主要是解析template模板中用到的資料,即Vue中的data資料,使用者可能會在模板中使用Vue中不包含的資料,此時變數的值就是null,使用者也可能使用到Vue中的物件資料,對於這些資料我們需要進行stringify()一下轉換為字串形式顯示在模板中。

v()方法主要就是解析傳入的文字字串,並將其解析為一個虛擬文字節點物件。

c()方法主要就是接收多個引數(標籤名屬性物件子節點陣列),並解析為一個虛擬元素節點物件

// src/render.js
import {createTextNode, createElementNode} from "./vdom/index";
export function renderMixin(Vue) {
    Vue.prototype._c = function(...args) { // 建立虛擬元素節點物件
        return createElementNode(...args);
    }
    Vue.prototype._v = function(text) { // 建立虛擬文字節點物件
        return createTextNode(text);
    }
    Vue.prototype._s = function(val) { // 解析變數的值
        return val === null ? "" : typeof val === "object" ? JSON.stringify(val) : val;
    }
}

二、虛擬DOM

接下來開始建立虛擬DOM,虛擬DOM包括建立、比較等各種操作,也是一個獨立複雜的過程,需要將對虛擬DOM的操作獨立成一個單獨的模組,主要就是對外暴露createElementNode()createTextNode()兩個方法,如:

// src/vdom/index.js
function vnode(tag, key, attrs, children, text) { // 建立虛擬DOM
    return {
        tag, // 標籤名,元素節點專屬
        key, // 標籤對應的key屬性,元素節點專屬
        attrs, // 標籤上的非key屬性,元素節點專屬
        children, // 標籤內的子節點,元素節點專屬
        text // 文字節點專屬,非文字節點為undefined
    }
}
// 建立虛擬文字節點,文字節點其他都為undefined,僅text有值
export function createTextNode(text) {
    return vnode(undefined, undefined, undefined, undefined, text);
}
// 建立虛擬元素節點
export function createElementNode(tag, attrs, ...children) {
    const key = attrs.key;
    if (key) {
        delete attrs.key;
    }
    return vnode(tag, key, attrs, children);
}

拿到虛擬DOM之後,就開始執行vm._update(vnode)方法,所以需要給Vue原型上混入一個_update()方法,_update屬於lifecycle的一部分,如下:

// src/lifecycle.js
import {patch} from "./vdom/patch";
export function lifecycleMixin(Vue) {
    // 更新的時候接受一個虛擬DOM節點,然後與掛載點或舊DOM節點進行比較
    Vue.prototype._update = function(vnode) {
        const vm = this;
        const prevVnode = vm._vnode; // 拿到之前的虛擬節點
        vm._vnode = vnode; // 將當前最新的虛擬節點儲存起來,以便下次比較的時候可以取出來作為舊的虛擬節點
        if (!prevVnode) { // 第一次沒有舊節點,所以為undefined,需要傳入真實節點
            vm.$el = patch(vm.$el, vnode);
        } else {
            vm.$el = patch(prevVnode, vnode);
        }
    }
}

第一次渲染的時候,舊節點為undefined,所以我們直接傳入真實的DOM掛載節點即可。接下來我們實現patch()方法。

patch()方法要做的事情就是,將傳入的新的虛擬DOM節點渲染成真實的DOM節點,然後用新建立的真實DOM節點替換掉掛載點對應的DOM

// src/vdom/patch.js
export function patch(oldVnode, vnode) { // 接收新舊虛擬DOM節點進行比較
    const isRealElement = oldVnode.nodeType; // 看看舊節點是否有nodeType屬性,如果有則是真實DOM節點
    if (isRealElement) { // 如果舊的節點是一個真實的DOM節點,直接渲染出最新的DOM節點並替換掉舊的節點即可
        const parentElm = oldVnode.parentNode; // 拿到舊節點即掛載點的父節點,這裡為<body>元素
        const oldElm = oldVnode;
        const el = createElm(vnode); // 根據新的虛擬DOM建立出對應的真實DOM
        parentElm.insertBefore(el, oldElm.nextSibling);// 將建立出來的新的真實DOM插入
        parentElm.removeChild(oldElm); // 移除掛載點對應的真實DOM
        return el; // 返回最新的真實DOM,以便儲存到Vue例項的$el屬性上
    } else {
        // 舊節點也是虛擬DOM,這裡進行新舊虛擬DOMDIFF比較
    }
}

實現將虛擬DOM轉換成真實的DOM,主要就是根據虛擬DOM節點上儲存的真實DOM節點資訊,通過DOM API建立出真實的DOM節點即可。

// src/vdom/patch.js
function createElm(vnode) {
    if (vnode.tag) { // 如果虛擬DOM上存在tag屬性,說明是元素節點
        vnode.el = document.createElement(vnode.tag); // 根據tag標籤名建立出對應的真實DOM節點
        updateProperties(vnode); // 更新DOM節點上的屬性
        vnode.children.forEach((child) => { // 遍歷虛擬子節點,將其子節點也轉換成真實DOM並加入到當前節點下
            vnode.el.appendChild(createElm(child));
        });
    } else { // 如果不存在tag屬性,說明是文字節點
        vnode.el = document.createTextNode(vnode.text); // 建立對應的真實文字節點 
    }
    return vnode.el;
}

實現DOM節點上屬性和樣式的更新,如:

// src/vdom/patch.js
function updateProperties(vnode, oldAttrs = {}) { // 傳入新的虛擬DOM和舊DOM的屬性物件
    const el = vnode.el; // 更新屬性前已經根據元素標籤名建立出了對應的真實元素節點,並儲存到vnode的el屬性上
    const newAttrs = vnode.attrs || {}; // 取出新虛擬DOM的屬性物件
    const oldStyles = oldAttrs.style || {}; // 取出舊虛擬DOM上的樣式
    const newStyles = newAttrs.style || {}; // 取出新虛擬DOM上的樣式
    // 移除新節點中不再使用的樣式
    for (let key in oldStyles) { // 遍歷舊的樣式
        if (!newStyles[key]) { // 如果新的節點已經沒有這個樣式了,則直接移除該樣式
            el.style[key] = "";
        }
    }
    // 移除新節點中不再使用的屬性
    for (let key in oldAttrs) {
        if (!newAttrs[key]) { // 如果新的節點已經沒有這個屬性了,則已經移除該屬性
            el.removeAttribute(key);
        }
    }
    // 遍歷新的屬性物件,開始更新樣式和屬性
    for (let key in newAttrs) { 
        if (key === "style") {
            for (let styleName in newAttrs.style) {
                el.style[styleName] = newAttrs.style[styleName];
            }
        } else if (key === "class") {
            el.className = newAttrs[key];
        } else {
            el.setAttribute(key, newAttrs[key]);
        }
    }
}

因為新舊虛擬DOM節點上的樣式style屬性也是一個物件,所以必須將樣式style物件單獨拿出來進行遍歷才能知道新的樣式中有沒有之前舊的樣式了。移除老的樣式和屬性之後,再遍歷一下新的屬性物件,更新一下最新的樣式和屬性。

三、實現響應式更新

所謂響應式更新,就是當我們修改Vue中的data資料的時候模板能夠自動重新渲染出最新的介面。目前我們只是渲染出了介面,當我們去修改Vue例項中的資料的時候,發現模板並沒有進行重新渲染,因為我們雖然對Vue中的資料進行了劫持,但是模板的更新(重新渲染)是由渲染Watcher來執行的,或者確切的說是在建立渲染Watcher的時候傳入的updateComponent()函式決定的,updateComponent()函式重新執行就會導致模板重新渲染,所以我們需要在資料發生變化的時候通知渲染Watcher更新(呼叫updateComponent()函式)。所以這裡的關鍵就是要通知渲染Watcher資料發生了變化。而通知機制,我們可以通過釋出訂閱模式來實現。實現方式如下:

① 將Vue中data資料的每一個key對映成一個釋出者物件;
當Watcher去取資料的時候,用到了哪個key對應的值,那麼就將當前Watcher物件加入到該key對應的釋出者物件的訂閱者列表中;
③ 當哪個key對應的值被修改的時候,就拿到該key對應的釋出者物件,呼叫其釋出通知的方法,通知訂閱者列表中的watcher物件執行更新操作

// src/observer/dep.js
let id = 0;
export default class Dep {
    constructor() {
        this.id = id++;
        this.subs = []; // 訂閱者列表,存放當前dep物件中需要通知的watcher物件
    }
    addSub(watcher) { // 新增訂閱者
        this.subs.push(watcher);
    }
    notify() { // 釋出通知,通知訂閱者更新
        this.subs.forEach((watcher) => {
            watcher.update();
        });
    }
    depend() { // 收集依賴,主要讓watcher物件中記錄一下依賴哪些dep物件
        if (Dep.target) { // 如果存在當前Watcher物件
            Dep.target.addDep(this); // 通知watcher物件將當前dep物件加入到watcher中
        }
    }
}

let stack = []; // 存放取值過程中使用到的watcher物件
// 取值前將當前watcher放到棧頂
export function pushTarget(watcher) {
    Dep.target = watcher; // 記錄當前Watcher
    stack.push(watcher); // 將當前watcher放到棧中
}
// 取完值後將當前watcher物件從棧頂移除
export function popTarget() {
    stack.pop(); // 移除棧頂的watcher
    Dep.target = stack[stack.length - 1]; // 更新當前watcher
}

這裡每次watcher物件取值之前,都會呼叫pushTarget()方法將當前watcher物件儲存到全域性的Dep.target上,同時將當前watcher放到一個陣列中,之所以要放到陣列中,是因為計算屬性也是一種wacther物件,當我們執行渲染watcher物件的時候,此時Dep.target的值為渲染watcher物件,如果模板中使用到了計算屬性,那麼就要執行計算watcher去取值,此時就會將計算watcher儲存到Dep.target中,當計算屬性取值完成後,渲染Watcher可能還需要繼續取值,所以還需要將Dep.target還原成渲染Watcher,為了能夠還原回來,需要將watcher放到棧中儲存起來。
修改watcher.js的get()方法,在取值前將當前Watcher物件儲存到全域性的Dep.target上,如:

// src/observer/watcher.js
import {pushTarget, popTarget} from "./dep";
export default class Watcher {
    constructor(vm, exprOrFn, cb, options, isRenderWatcher) {
        this.deps = []; // 當前Watcher依賴了哪些key
        this.depIds = new Set(); // 避免重複
    }
    get() {
        pushTarget(this); // 取值前將當前watcher放到全域性的Dep.target中
        this.getter.call(this.vm, this.vm);
        popTarget(); // 取值完成後將當前watcher從棧頂移除
    }
    // 讓watcher記錄下其依賴的dep物件
    addDep(dep) {
        const id = dep.id;
        if (!this.depIds.has(id)) { // 如果不存在該dep的id
            this.deps.push(dep);
            this.depIds.add(id);
            dep.addSub(this);
        }
    }
    update() { // watcher進行更新操作,以便頁面能夠更新
        this.get();
    }
}

這裡之所以先呼叫dep.depend()方法讓當前watcher物件將其依賴的dep加入到其deps陣列中主要是為計算watcher設計的,假如渲染watcher中僅僅使用到了一個計算屬性,由於渲染watcher並沒有直接依賴Vue中data物件中的資料,所以data物件中各個key對應的dep物件並不會將渲染watcher加入到訂閱者列表中,而是僅僅會將計算watcher放到訂閱者列表中,此時使用者去修改Vue中的資料,渲染watcher就不會收到通知,導致無法更新。後面實現計算watcher的時候會進一步解釋。

四、實現物件的依賴收集

此時取值前已經將Wacher放到了全域性的Dep.target中,而取值的時候會被響應式資料系統的get()攔截,我們可以在get中收集依賴,在修改值的時候會被響應式資料系統的set()攔截,我們可以在set中進行釋出通知,如:

// src/observer/index.js
function defineReactive(data, key, value) {
    let ob = observe(value);
    let dep = new Dep(); // 給每個key建立一個對應的Dep物件
    dep.name = key;
    Object.defineProperty(data, key, {
        get() {
            if (Dep.target) { // 如果已經將當前Watcher物件儲存到Dep.target上
                dep.depend(); // 執行當前key對應的dep物件的depend()方法收集依賴
            }
            return value;
        },
        set(newVal) {
            if (newVal === value) {
                return;
            }
            observe(newVal);
            value = newVal;
            dep.notify(); // 資料會被修改後,通過對應key的dep物件給訂閱者釋出通知
        }
    });
}

五、實現陣列的依賴收集

經過上面的操作,我們已經實現了對物件的依賴收集,修改物件的某個key的值,可以通知到渲染watcher進行更新。
如果Vue中data資料中有某個key的值為陣列,比如,data: {arr: [1, 2, 3]},那麼當我們通過vm.arr.push(4)去修改陣列的時候,會發現模板並沒有更新,因為我們目前僅僅對物件進行了依賴收集,也就是說,arr對應的dep物件中有渲染Watcher的依賴,但是arr的值[1, 2, 3]這物件並沒有對應的dep物件,所以沒辦法通知渲染watcher物件執行更新操作。
在前面響應式資料系統中,我們進行了資料的遞迴觀測,如果物件的key對應的值也是一個物件或者陣列,那麼會對這個值也進行觀測,而一旦觀測就會建立一個對應的Observer物件,所以我們可以在Observer物件中新增一個dep物件用於收集陣列收集依賴

// src/observer/index.js
class Observer {
    constructor(data) {
        this.dep = new Dep(); // 為了觀察陣列收集依賴用,直接觀察陣列本身,而不是陣列對應的key,如{arr: [1, 2, 3]}, 直接觀察[1, 2, 3]而不是觀察arr
    }
}

function defineReactive(data, key, value) {
    let ob = observe(value); // 對值進行觀測
    get() {
        if (Dep.target) {
            if (ob) { // 如果被觀測的值也是一個物件或者陣列,則會返回一個Observer物件,否則為null
                ob.dep.depend(); // 對陣列收集依賴
            }
        }
    }
}

對陣列收集依賴後,我們還需要在陣列發生變化的時候進行通知,之前響應式系統中已經對能夠改變陣列的幾個方法進行了重寫,所以我們可以在這些方法被呼叫的時候發起通知,如:

// src/observer/array.js
methods.forEach((method) => {
    arrayMethods[method] = function(...args) {
        ...
        if (inserted) {
            ob.observeArray(inserted);
        }
        ob.dep.notify(); // 在能夠改變陣列的方法中發起通知
    }
})

此時還存在一個問題,還是以data: {arr: [1, 2, 3]}為例,雖然我們現在通過vm.arr.push(4)可以看到頁面會更新,但是如果我們push的是一個陣列呢?比如,執行vm.arr.push([4, 5]),那麼當我們執行vm.arr[3].push(6)的時候發現頁面並沒有更新,因為我們沒有對arr中的[4,5]這個陣列進行依賴收集,所以我們需要對陣列進行遞迴依賴收集

// src/observer/index.js
function defineReactive(data, key, value) {
    let ob = observe(value); // 對值進行觀測
    get() {
        if (Dep.target) {
            if (ob) { // 如果被觀測的值也是一個物件或者陣列,則會返回一個Observer物件,否則為null
                ob.dep.depend(); // 對陣列收集依賴
                if (Array.isArray(value)) { // 如果這個值是一個陣列
                    dependArray(value);
                }
            }
        }
    }
}

// 遍歷陣列中的每一項進行遞迴依賴收集
function dependArray(value) {
    for (let i = 0; i < value.length; i++) { // 遍歷陣列中的每個元素
        let current = value[i];
        // 如果陣列中的值是陣列或者物件,那麼該值也會被觀察,即就會有觀察者物件
        current.__ob__ && current.__ob__.dep.depend(); // 對於其中的物件或者陣列收集依賴,即給其加一個Watcher物件
        if (Array.isArray(current)) { // 如果值還是陣列,則遞迴收集依賴
            dependArray(current)
        }
    }
}

相關文章