博客 / 詳情

返回

Vue中的模板渲染、響應式系統、虛擬DOM

前言

在看vue源碼的時候,覺得這幾個vue的核心理念需要總結一下,遂寫篇文章,自己忘記的時候再回來看看。

模板渲染

Vue採用的是聲明式渲染,與命令式渲染不同,聲明式渲染只需要告訴程序,我們想要的什麼效果,其他的事情讓程序自己去做。而命令式渲染,需要命令程序一步一步根據命令執行渲染。例如:

let arr = [1, 2, 3, 4, 5];

// 命令式渲染,關心每一步、關心流程。用命令去實現
let newArr = [];
for (let i = 0; i < arr.length; i++) {
    newArr.push(arr[i] * 2);
}

// 聲明式渲染,不用關心中間流程,只需要關心結果和實現的條件
let newArr1 = arr.map(function (item) {
    return item * 2;
});
Vue 實現了iffor事件數據綁定等指令,允許採用簡潔的模板語法來聲明式地將數據渲染出視圖。
為什麼要進行模板編譯?實際組件中的 template 語法是無法被瀏覽器解析的,因為它不是正確的 HTML 語法,而模板編譯,就是將組件的 template 編譯成可執行的 JavaScript 代碼,即將 template 轉化為真正的渲染函數。

模板編譯分三個階段,parseoptimizegenerate,最終生成render函數。

  1. parse階段:使用正在表達式將template進行字符串解析,得到指令classstyle等數據,生成抽象語法樹 AST
  2. optimize階段:尋找 AST 中的靜態節點進行標記,為後面 VNodepatch 過程中對比做優化。被標記為 static 的節點在後面的 diff 算法中會被直接忽略,不做詳細的比較。
  3. generate階段:根據 AST 結構拼接生成 render 函數的字符串。

預編譯
對於 Vue 組件來説,模板編譯只會在組件實例化的時候編譯一次,生成 渲染函數 之後在也不會進行編譯。因此,編譯對組件的 runtime 是一種性能損耗。而模板編譯的目的僅僅是將template轉化為render function,而這個過程,正好可以在項目構建的過程中完成。
比如webpackvue-loader依賴了vue-template-compiler模塊,在 webpack 構建過程中,將template預編譯成 render 函數,在 runtime 可直接跳過模板編譯過程。

/*回過頭看,runtime 需要是僅僅是 render 函數,而我們有了預編譯之後,我們只需要保證構建過程中
生成 render 函數。與 React 類似,在添加JSX的語法糖編譯器babel-plugin-transform-vue-jsx
之後,我們可以在 Vue 組件中使用JSX語法直接書寫 render 函數。*/
<script>
export default {
    data() {
        return {
            msg: 'Hello JSX.'
        }
    },
    render() {
        const msg = this.msg;
        return <div>
            {msg}
        </div>;
    }
}
</script>

當然,假如同時聲明瞭 template 標籤和 render 函數,構建過程中,template 編譯的結果將覆蓋原有的 render 函數,即 template 的優先級高於直接書寫的 render 函數。

響應式系統

Vue 是一款 MVVMJS框架,當對數據模型data進行修改時,視圖會自動得到更新,即框架幫我們完成了更新DOM的操作,而不需要我們手動的操作DOM。可以這麼理解,當我們對數據進行賦值的時候,Vue 告訴了所有依賴該數據模型的組件,你依賴的數據有更新,你需要進行重渲染了,這個時候,組件就會重渲染,完成了視圖的更新。

整個流程梳理

  1. 首先實例化Vue類;
  2. 在實例化時,先觸發observe,遞歸地對所有data中的變量進行訂閲;
  3. 每次訂閲之前,都會生成一個dep實例,dep是指依賴;
  4. 每一個只要是Object類型的變量都有一個dep實例;
  5. 這個dep是閉包產生的,因此所有與dep有關的操作,都要放到defineReactive函數內部執行;
window.myapp = new Vue({
    el: "#app",
    data: {
        number: {
            big: 999
        },
    },
});

export default class Vue {
    constructor (options: any = {}) {
        this.$options = options;
        this.$el = options.el;
        this.$data = options.data;
        this.$methods = options.methods;
        this.observe(this.$data);
        new Compiler(this.$el, this);
    }

    observe (data) {
        if (!data || typeof data !== "object") {
            return;
        }
        Object.keys(data).forEach(key => {
            this.defineReactive(data, key, data[key]);
        })
    }

    defineReactive(data, key, value) {
        this.observe(value);
        let dep = new Dep();
        this.$dps.push(dep);
        Object.defineProperty(data, key, {
            enumerable: true,
            configurable: false,
            get () {
                 // 由於需要在閉包內添加watcher,所以通過Dep定義一個全局target屬性,
                 // 暫存watcher, 添加完移除
                if (Dep.target)
                    // dep.addSub(Dep.target);
                    dep.depend();
                    /**
                     * dep.depend();
                     * 兩種寫法一致
                     */
                return value;
            },
        })
    }
}

Dep

  1. 先定義一個全局的uid,便於分別每一個dep實例,在創建dep的時候綁定並自加1,每一個dep,都會有一個subs隊列,裏面存放的是watcher
  2. 每一個data以及其中凡是對象的變量,都唯一對應一個dep
  3. 如果想要實現從model -> View的綁定,只需要這樣做,把所有的發佈者watcher都放到一個dep中。
  4. 當改變一個變量時,只需要拿到這個變量對應的dep即可,因為dep有一個subs隊列,存放的全是相關的發佈者watcher,只需要遍歷subs並且調用其中發佈者的update方法即可更新頁面,這就是設計dep類的思想。

    let guid = 0;
    export default class Dep {
        static target: any = null;
        subs: Array<any>;
        uid: number;
        constructor() {
            this.subs = [];
            this.uid = guid ++;
        }
        addSub(sub) {
            this.subs.push(sub);
        }
        depend () {
            Dep.target.addDep(this);
        }
        notify() {
            this.subs.forEach(sub => {
                sub.update();
            })
        }
    }
    Dep.target = null;

Dep.target是一個靜態變量,所有的dep實例的target都指向同一個東西,也就是説這個target是全局唯一的,理解為全局變量即可,其實就是一個watcher。在definePropertyget事件被觸發時會進行依賴收集。

編譯模板compiler

compiler的主要作用是把html節點和watcher關聯起來,至於html的內容如何更新,都由watchercallback/updater函數決定。這裏暫時不做深入,這裏只需要知道它是watcher來更新DOM的。

watcher和dep綁定
到了這一步,model已經閉包地擁有了自己的dephtml節點也和watcher關聯了起來,就差把watcher推到對應的dep裏了。然後先看看Watcher

export default class Watcher {
    private vm;
    private exp;
    private cb;
    private value;
    private depIds = {};
    constructor (vm, exp, cb) {
        this.vm = vm;
        this.exp = exp;
        this.cb = cb;
        // 創建時必須觸發一次依賴收集
        this.triggerDepCollection();
    }
    update () {
        this.triggerDepCollection();
        this.cb(this.value);
    }
    addDep (dep) {
        if (!this.depIds.hasOwnProperty(dep.uid)) {
            dep.addSub(this);
            this.depIds[dep.uid] = dep;
        }
    }
    // 收集依賴,因為觸發了definePropreity的get()
    // or re-collection
    triggerDepCollection () {
        Dep.target = this;
        this.value = this.getDeepValue();
        Dep.target = null;
    }
    getDeepValue () {
        let data = this.vm.$data;
        this.exp.split(".").forEach(key => {
            data = data[key];
        })
        return data;
    }
}
當編譯html代碼時,我們碰到了一個需要收集的變量,現在為其創建一個watcher,並在watcher內部與dep建立聯繫。我們稱這步為依賴收集,我們可以看到,在構造函數的最後一行,triggerDepCollection()意味這個watcher自己觸發依賴收集,這個函數先把我們先前提到的Dep.target設為watcher自身,然後getDeepValue()這裏你只需要知道去訪問了一次exp變量,這就觸發了exp變量的get事件,就是提醒expdep,“你可以收集我了”,get事件的主要內容就是收集這個依賴,然後再結合最開始提到的代碼,觸發dep.depend()

前文的 defineReactive方法裏面的 get方法中的 if (Dep.target) dep.depend(),它又調用了depDep.target.addDep(this),也就是當前的watcheraddDep(this)watcheraddDep(this)又調用了這個depaddSub()
本來要收集依賴,只需要dep調用自己的addSub(watcher),把watcher推到自己的subs隊列就完事了,但現在,dep把自己傳給watcher,然後watcher再把自己傳給depdep再把watcher加到自己的隊列,這樣豈不是多此一舉?其實不然。就在於watcheraddDep這一步,關鍵在於判斷這個depuid是不是自己加入過的dep,也可以用defineReactive方法裏面的set實現。
每次調用update()的時候會觸發相應屬性的getDeepvaluegetDeepvalue裏面會觸發dep.depend(),繼而觸發這裏的addDep
1、假如相應屬性的dep.id已經在當前watcherdepIds裏,説明不是一個新的屬性,僅僅是改變了其值而已,則不需要將當前watcher添加到該屬性的dep裏。

2、假如相應屬性是新的屬性,則將當前watcher添加到新屬性的dep裏,,因為新屬性之前的setterdep 都已經失效,如果不把 watcher 加入到新屬性的dep中,通過 obj.xxx = xxx 賦值的時候,對應的 watcher 就收不到通知,等於失效了。因此每次更新都要重新收集依賴。

3、每個子屬性的watcher在添加到子屬性的dep的同時,也會添加到父屬性的dep,監聽子屬性的同時監聽父屬性的變更,這樣,父屬性改變時,子屬性的watcher也能收到通知進行update,這一步是在 this.get() --> this.getVMVal() 裏面完成,forEach時會從父級開始取值,間接調用了它的getter,觸發了addDep(), 在整個forEach過程,當前wacher都會加入到每個父級過程屬性的dep,例如:當前watcher的是child.child.name, 那麼childchild.childchild.child.name這三個屬性的dep都會加入當前watcher

至此,所有的內容就完成了,watcher也和dep綁定完畢。

Virtual DOM

Vue 中,template被編譯成瀏覽器可執行的render function,然後配合響應式系統,將render function掛載在render-watcher中,當有數據更改的時候,調度中心Dep通知該render-watcher執行render function,完成視圖的渲染與更新。
整個流程看似通順,但是當執行render function時,如果每次都全量刪除並重建 DOM,這對執行性能來説,無疑是一種巨大的損耗,因為我們知道,瀏覽器的DOM很“昂貴”的,當我們頻繁的更新 DOM,會產生一定的性能問題。
為了解決這個問題,Vue 使用 JS 對象將瀏覽器的 DOM 進行的抽象,這個抽象被稱為 Virtual DOMVirtual DOM 的每個節點被定義為VNode,當每次執行render function時,Vue 對更新前後的VNode進行Diff對比,找出儘可能少的需要更新的真實 DOM 節點,然後只更新需要更新的節點,從而解決頻繁更新 DOM 產生的性能問題。

VNode
VNode,全稱virtual node,即虛擬節點,對真實 DOM 節點的虛擬描述,在 Vue 的每一個組件實例中,會掛載一個$createElement函數,所有的VNode都是由這個函數創建的。

比如創建一個 div:
// 聲明 render function
render: function (createElement) {
    // 也可以使用 this.$createElement 創建 VNode
    return createElement('div', 'hellow world');
}
// 以上 render 方法返回html片段 <div>hellow world</div>

render 函數執行後,會根據VNode Tree將 VNode 映射生成真實 DOM,從而完成視圖的渲染.

Diff

Diff 將新老 VNode 節點進行比對,然後將根據兩者的比較結果進行最小單位地修改視圖,而不是將整個視圖根據新的 VNode 重繪,進而達到提升性能的目的。

patch

Vue內部的 diff 被稱為patch。其 diff 算法的是通過同層的樹節點進行比較,而非對樹進行逐層搜索遍歷的方式,所以時間複雜度只有O(n),是一種相當高效的算法。

首先定義新老節點是否相同判定函數sameVnode:滿足鍵值key和標籤名tag必須一致等條件,返回true,否則false
在進行patch之前,新老 VNode 是否滿足條件sameVnode(oldVnode, newVnode),滿足條件之後,進入流程patchVnode,否則被判定為不相同節點,此時會移除老節點,創建新節點。

patchVnode

patchVnode 的主要作用是判定如何對子節點進行更新,

如果新舊VNode都是靜態的,同時它們的key相同(代表同一節點),並且新的 VNodeclone 或者是標記了 once(標記v-once屬性,只渲染一次),那麼只需要替換 DOM 以及 VNode 即可。

新老節點均有子節點,則對子節點進行 diff 操作,進行updateChildren,這個 updateChildren 也是 diff 的核心。

1.如果老節點沒有子節點而新節點存在子節點,先清空老節點 DOM 的文本內容,然後為當前 DOM 節點加入子節點。
2.當新節點沒有子節點而老節點有子節點的時候,則移除該 DOM 節點的所有子節點。
3.當新老節點都無子節點的時候,只是文本的替換。

updateChildren

Diff 的核心,對比新老子節點數據,判定如何對子節點進行操作,在對比過程中,由於老的子節點存在對當前真實 DOM 的引用,新的子節點只是一個 VNode 數組,所以在進行遍歷的過程中,若發現需要更新真實 DOM 的地方,則會直接在老的子節點上進行真實 DOM 的操作,等到遍歷結束,新老子節點則已同步結束。

1、updateChildren內部定義了4個變量,分別是oldStartIdxoldEndIdxnewStartIdxnewEndIdx,分別表示正在 Diff 對比的新老子節點的左右邊界點索引,在老子節點數組中,索引在oldStartIdxoldEndIdx中間的節點,表示老子節點中為被遍歷處理的節點,所以小於oldStartIdx或大於oldEndIdx的表示未被遍歷處理的節點。
2、同理,在新的子節點數組中,索引在newStartIdxnewEndIdx中間的節點,表示老子節點中為被遍歷處理的節點,所以小於newStartIdx或大於newEndIdx的表示未被遍歷處理的節點。
3、每一次遍歷,oldStartIdxoldEndIdxnewStartIdxnewEndIdx之間的距離會向中間靠攏。當 oldStartIdx > oldEndIdx 或者 newStartIdx > newEndIdx 時結束循環。
4、在遍歷中,取出4索引對應的 Vnode節點:
(1). oldStartIdx:oldStartVnode
(2). oldEndIdx:oldEndVnode
(3). newStartIdx:newStartVnode
(4). newEndIdx:newEndVnode
5、diff 過程中,如果存在key,並且滿足sameVnode,會將該 DOM 節點進行復用,否則則會創建一個新的 DOM 節點。

比較過程

首先,oldStartVnodeoldEndVnodenewStartVnodenewEndVnode兩兩比較,一共有 2*2=4 種比較方法。
  1. 情況一:當oldStartVnodenewStartVnode滿足 sameVnode,則oldStartVnodenewStartVnode進行 patchVnode,並且oldStartIdxnewStartIdx右移動。
  2. 情況二:與情況一類似,當oldEndVnodenewEndVnode滿足 sameVnode,則oldEndVnodenewEndVnode進行 patchVnode,並且oldEndIdxnewEndIdx左移動。
  3. 情況三:當oldStartVnodenewEndVnode滿足 sameVnode,則説明oldStartVnode已經跑到了oldEndVnode後面去了,此時oldStartVnodenewEndVnode進行 patchVnode 的同時,還需要將oldStartVnode的真實 DOM 節點移動到oldEndVnode的後面,並且oldStartIdx右移,newEndIdx左移。
  4. 情況四:與情況三類似,當oldEndVnodenewStartVnode滿足 sameVnode,則説明oldEndVnode已經跑到了oldStartVnode前面去了,此時oldEndVnodenewStartVnode進行 patchVnode 的同時,還需要將oldEndVnode的真實 DOM 節點移動到oldStartVnode的前面,並且oldStartIdx右移,newEndIdx左移。
  5. 若不存在,説明newStartVnode為新節點,創建新節點放在oldStartVnode前面即可。
  6. oldStartIdx> oldEndIdx 或者 newStartIdx > newEndIdx,循環結束,這個時候我們需要處理那些未被遍歷到的 VNode
  7. oldStartIdx > oldEndIdx 時,説明老的節點已經遍歷完,而新的節點沒遍歷完,這個時候需要將新的節點創建之後放在oldEndVnode後面。
  8. newStartIdx > newEndIdx 時,説明新的節點已經遍歷完,而老的節點沒遍歷完,這個時候要將沒遍歷的老的節點全都刪除。
    借用網上的一個動圖

    説明
    以上部分內容來源與自己複習時的網絡查找,也主要用於個人學習,相當於記事本的存在,暫不列舉鏈接文章。如果有作者看到,可以聯繫我將原文鏈接貼出。
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.