深入剖析Vue原始碼 - 完整渲染過程

不做祖國的韭菜發表於2019-05-16

繼上一節內容,我們將Vue複雜的掛載流程通過圖解流程,程式碼分析的方式簡單梳理了一遍,其中也講到了模板編譯的大致流程,然而在掛載的核心處,我們並沒有分析模板編譯後函式如何渲染為視覺化的DOM節點。這一節,我們將重新回到Vue例項掛載的最後一個環節:渲染DOM節點。在渲染真實DOM的過程中,Vue引進了虛擬DOM的概念,虛擬DOM作為JS物件和真實DOM中間的一個緩衝存,極大的優化了JS頻繁操作DOM的效能問題,接下來我們將慢慢展開分析。

4.1 Virtual DOM

4.1.1 瀏覽器的渲染流程

當瀏覽器接收到一個Html檔案時,JS引擎和瀏覽器的渲染引擎便開始工作了。從渲染引擎的角度,它首先會將html檔案解析成一個DOM樹,與此同時,瀏覽器將識別並載入CSS樣式,並和DOM樹一起合併為一個渲染樹。有了渲染樹後,渲染引擎將計算所有元素的位置資訊,最後通過繪製,在螢幕上列印最終的內容。而JS引擎的作用是通過DOM相關的API去操作DOM物件,而當我們操作DOM時,很容易觸發到渲染引擎的迴流或者重繪。

  • 迴流: 當我們對DOM的修改引發了元素尺寸的變化時,瀏覽器需要重新計算元素的大小和位置,最後將重新計算的結果繪製出來,這個過程稱為迴流。
  • 重繪: 當我們對DOM的修改只單純改變元素的顏色時,瀏覽器此時並不需要重新計算元素的大小和位置,而只要重新繪製新樣式。這個過程稱為重繪。

很顯然迴流比重繪更加耗費效能

通過了解瀏覽器基本的渲染機制,我們很容易聯想到當不斷的通過JS修改DOM時,不經意間會觸發到渲染引擎的迴流或者重繪,而這個效能開銷是非常巨大的。因此為了降低開銷,我們可以做的是儘可能減少DOM操作。

4.1.2 緩衝層-虛擬DOM

虛擬DOM是優化頻繁操作DOM引發效能問題的產物。虛擬DOM(下面稱為Virtual DOM)是將頁面的狀態抽象為JS物件的形式,本質上是JS和真實DOM的中間層,當我們想用JS指令碼大批量進行DOM操作時,會優先作用於Virtual DOM這個JS物件,最後通過對比將要改動的部分通知並更新到真實的DOM。儘管最終還是操作真實的DOM,但Virtual DOM可以將多個改動合併成一個批量的操作,從而減少 dom 重排的次數,進而縮短了生成渲染樹和繪製所花的時間。

我們看一個真實的DOM包含了什麼:

深入剖析Vue原始碼 - 完整渲染過程
瀏覽器將一個真實DOM設計得很複雜,不僅包含了自身的屬性描述,大小位置等定義,也囊括了DOM擁有的瀏覽器事件等。正因為如此複雜的結構,我們頻繁去操作DOM或多或少會帶來瀏覽器效能問題。而作為資料和真實DOM之間的一層緩衝,Virtual DOM 只是用來對映到真實DOM的渲染,因此不需要包含操作 DOM 的方法,只要在物件中重點關注幾個屬性即可。

// 真實DOM
<div id="real"><span>dom</span></div>

// 真實DOM對應的JS物件
{
    tag: 'div',
    data: {
        id: 'real'
    },
    children: [{
        tag: 'span',
        children: 'dom'
    }]
}
複製程式碼

4.2 Vnode

Vue原始碼在渲染機制的優化上,同樣引進了virtual dom的概念,它是用Vnode這個建構函式去描述一個DOM節點。

4.2.1 Vnode建構函式
var VNode = function VNode (tag,data,children,text,elm,context,componentOptions,asyncFactory) {
    this.tag = tag; // 標籤
    this.data = data;  // 資料
    this.children = children; // 子節點
    this.text = text;
    ···
    ···
  };
複製程式碼

Vnode定義的屬性也差不多有20幾個,這裡列舉大部分屬性,只重點關注幾個關鍵屬性:標籤名,資料,子節點。其他的屬性都是用來擴充套件Vue的靈活性。

除此之外,原始碼中還定義了Vnode的其他方法

4.2.2 建立Vnode註釋節點
// 建立註釋vnode節點
var createEmptyVNode = function (text) {
    if ( text === void 0 ) text = '';

    var node = new VNode();
    node.text = text;
    node.isComment = true; // 標記註釋節點
    return node
};
複製程式碼
4.2.3 建立Vnode文字節點
// 建立文字vnode節點
function createTextVNode (val) {
    return new VNode(undefined, undefined, undefined, String(val))
}
複製程式碼
4.2.4 克隆vnode

vnode的克隆只是一層淺拷貝,不會對子節點進行深度克隆。

function cloneVNode (vnode) {
    var cloned = new VNode(
      vnode.tag,
      vnode.data,
      vnode.children && vnode.children.slice(),
      vnode.text,
      vnode.elm,
      vnode.context,
      vnode.componentOptions,
      vnode.asyncFactory
    );
    ···
    return cloned
  }
複製程式碼

4.3 Vnode的建立

先簡單回顧一下掛載的流程,掛載的過程呼叫的是Vue例項上$mount方法,而$mount的核心是mountComponent方法。在這之前,如果我們傳遞的是template模板,會經過一系列的模板編譯過程,並根據不同平臺生成對應程式碼,瀏覽器對應的是render函式;如果傳遞的是render函式,則忽略模板編譯過程。有了render函式後,呼叫vm._render()方法會將render函式轉化為Virtual DOM,最終利用vm._update()Virtual DOM渲染為真實的DOM

Vue.prototype.$mount = function(el, hydrating) {
    ···
    return mountComponent(this, el)
}
function mountComponent() {
    ···
    updateComponent = function () {
        vm._update(vm._render(), hydrating);
    };
}

複製程式碼

vm._render()方法會將render函式轉化為Virtual DOM,我們看原始碼中如何定義的。

// 引入Vue時,執行renderMixin方法,該方法定義了Vue原型上的幾個方法,其中一個便是 _render函式
renderMixin();//
function renderMixin() {
    Vue.prototype._render = function() {
        var ref = vm.$options;
        var render = ref.render;
        ···
        try {
            vnode = render.call(vm._renderProxy, vm.$createElement);
        } catch (e) {
            ···
        }
        ···
        return vnode
    }
}
複製程式碼

拋開其他程式碼,_render函式的核心是render.call(vm._renderProxy, vm.$createElement)部分,vm.$createElement方法會作為render函式的引數傳入。這個引數也是在手寫render函式時使用的createElement引數的由來

new Vue({
    el: '#app',
    render: function(createElement) {
        return createElement('div', {}, this.message)
    },
    data() {
        return {
            message: 'dom'
        }
    }
})
複製程式碼

vm.$createElementVueinitRender所定義的方法,其中 vm._ctemplate內部編譯成render函式時呼叫的方法,vm.$createElement是手寫render函式時呼叫的方法。兩者的唯一區別是:內部生成的render方法可以保證子節點都是Vnode(下面有特殊的場景),而手寫的render需要一些檢驗和轉換。

function initRender(vm) {
    vm._c = function(a, b, c, d) { return createElement(vm, a, b, c, d, false); }
    vm.$createElement = function (a, b, c, d) { return createElement(vm, a, b, c, d, true); };
}
複製程式碼

createElement 方法實際上是對 _createElement 方法的封裝,在呼叫_createElement建立Vnode之前,會對傳入的引數進行處理。例如當沒有data資料時,引數會往前填充。

function createElement (
    context, // vm 例項
    tag, // 標籤
    data, // 節點相關資料,屬性
    children, // 子節點
    normalizationType,
    alwaysNormalize // 區分內部編譯生成的render還是手寫render
  ) {
    // 對傳入引數做處理,可以沒有data,如果沒有data,則將第三個引數作為第四個引數使用,往上類推。
    if (Array.isArray(data) || isPrimitive(data)) {
      normalizationType = children;
      children = data;
      data = undefined;
    }
    // 根據是alwaysNormalize 區分是內部編譯使用的,還是使用者手寫render使用的
    if (isTrue(alwaysNormalize)) {
      normalizationType = ALWAYS_NORMALIZE;
    }
    return _createElement(context, tag, data, children, normalizationType) // 真正生成Vnode的方法
  }
複製程式碼
4.3.1 資料規範檢測

Vue既然暴露給使用者用render函式去寫渲染模板,就需要考慮使用者操作帶來的不確定性,因此在生成Vnode的過程中,_createElement會先進行資料規範的檢測,將不合法的資料型別錯誤提前暴露給使用者。接下來將列舉幾個容易犯錯誤的實際場景,方便理解原始碼中如何處理這類錯誤的。

    1. 用響應式物件做節點屬性
new Vue({
    el: '#app',
    render: function (createElement, context) {
       return createElement('div', this.observeData, this.show)
    },
    data() {
        return {
            show: 'dom',
            observeData: {
                attr: {
                    id: 'test'
                }
            }
        }
    }
})
複製程式碼
    1. 特殊屬性key為非字串,數字型別
new Vue({
    el: '#app',
    render: function(createElement) {
        return createElement('div', { key: this.lists }, this.lists.map(l => {
           return createElement('span', l.name)
        }))
    },
    data() {
        return {
            lists: [{
              name: '111'
            },
            {
              name: '222'
            }
          ],
        }
    }
})
複製程式碼

這些規範都會在建立Vnode節點之前發現並報錯,原始碼如下:

function _createElement (context,tag,data,children,normalizationType) {
    // 資料物件不能是定義在Vue data屬性中的響應式資料。
    if (isDef(data) && isDef((data).__ob__)) {
      warn(
        "Avoid using observed data object as vnode data: " + (JSON.stringify(data)) + "\n" +
        'Always create fresh vnode data objects in each render!',
        context
      );
      return createEmptyVNode() // 返回註釋節點
    }
    // 針對動態元件 :is 的特殊處理,元件相關知識放到特定章節分析。
    if (isDef(data) && isDef(data.is)) {
      tag = data.is;
    }
    if (!tag) {
      // 防止動態元件 :is 屬性設定為false時,需要做特殊處理
      return createEmptyVNode()
    }
    // key值只能為string,number這些原始資料型別
    if (isDef(data) && isDef(data.key) && !isPrimitive(data.key)
    ) {
      {
        warn(
          'Avoid using non-primitive value as key, ' +
          'use string/number value instead.',
          context
        );
      }
    }
    ···
    // 省略後續操作
  }
複製程式碼
4.3.2 子節點children規範化

Virtual DOM需要保證每一個子節點都是Vnode型別,這裡分兩種場景。

  • 1.render函式編譯,理論上通過render函式編譯生成的都是Vnode型別,但是有一個例外,函式式元件返回的是一個陣列(關於元件,以及函式式元件內容,我們放到專門講元件的時候專題分析),這個時候Vue的處理是將整個children拍平。
  • 2.使用者定render函式,這個時候也分為兩種情況,一個是chidren為文字節點,這時候通過前面介紹的createTextVNode 建立一個文字節點的 VNode; 另一種相對複雜,當children中有v-for的時候會出現巢狀陣列,這時候的處理邏輯是,遍歷children,對每個節點進行判斷,如果依舊是陣列,則繼續遞迴呼叫,直到型別為基礎型別時,呼叫createTextVnode方法轉化為Vnode。這樣經過遞迴,children變成了一個型別為Vnode的陣列。
function _createElement() {
    ···
    if (normalizationType === ALWAYS_NORMALIZE) {
      // 使用者定義render函式
      children = normalizeChildren(children);
    } else if (normalizationType === SIMPLE_NORMALIZE) {
      // render 函式是編譯生成的
      children = simpleNormalizeChildren(children);
    }
}

// 處理編譯生成的render 函式
function simpleNormalizeChildren (children) {
    for (var i = 0; i < children.length; i++) {
        // 子節點為陣列時,進行開平操作,壓成一維陣列。
        if (Array.isArray(children[i])) {
        return Array.prototype.concat.apply([], children)
        }
    }
    return children
}

// 處理使用者定義的render函式
function normalizeChildren (children) {
    // 遞迴呼叫,直到子節點是基礎型別,則呼叫建立文字節點Vnode
    return isPrimitive(children)
      ? [createTextVNode(children)]
      : Array.isArray(children)
        ? normalizeArrayChildren(children)
        : undefined
  }

// 判斷是否基礎型別
function isPrimitive (value) {
    return (
      typeof value === 'string' ||
      typeof value === 'number' ||
      typeof value === 'symbol' ||
      typeof value === 'boolean'
    )
  }
複製程式碼

=== 進行資料檢測和元件規範化後,接下來通過new VNode便可以生成一棵```VNode``樹。===具體細節由於篇幅原因,不展開分析。

4.4 虛擬Vnode對映成真實DOM - update

回到 updateComponent的最後一個過程,虛擬的DOMvirtual dom生成後,呼叫Vue原型上_update方法,將虛擬DOM對映成為真實的DOM

updateComponent = function () {
    // render生成虛擬DOM,update渲染真實DOM
    vm._update(vm._render(), hydrating);
};
複製程式碼

從原始碼上可以知道,update主要有兩個呼叫時機,一個是初次資料渲染時,另一個是資料更新時觸發真實DOM更新。這一節只分析初次渲染的操作,資料更新放到響應式系統中展開。

function lifecycleMixin() {
    Vue.prototype._update = function (vnode, hydrating) {
        var vm = this;
        var prevEl = vm.$el;
        var prevVnode = vm._vnode; // prevVnode為舊vnode節點
        // 通過是否有舊節點判斷是初次渲染還是資料更新
        if (!prevVnode) {
            // 初次渲染
            vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false)
        } else {
            // 資料更新
            vm.$el = vm.__patch__(prevVnode, vnode);
        }
}
複製程式碼

_update的核心是__patch__方法,而__patch__來源於:

// 瀏覽器端才有DOM,服務端沒有dom,所以patch為一個空函式
  Vue.prototype.__patch__ = inBrowser ? patch : noop;
複製程式碼

patch方法又是createPatchFunction方法的返回值,createPatchFunction內部定義了一系列輔助的方法,但其核心是通過呼叫createEle方法,createEle會呼叫一系列封裝好的原生DOMAPI進行dom操作,建立節點,插入子節點,遞迴建立一個完整的DOM樹並插入到Body中。這部分邏輯分支較為複雜,在原始碼上打debugger並根據實際場景跑不同的分支有助於理解這部分的邏輯。內容較多就不一一展開。

總結

這一節分析了mountComponent的兩個核心方法,renderupdate,他們分別完成對render函式轉化為Virtual DOM和將Virtual DOM對映為真實DOM 的過程。整個渲染過程邏輯相對也是比較清晰的。


相關文章