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

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

繼上一節內容,我們將Vue複雜的掛載流程通過圖解流程,程式碼分析的方式簡單梳理了一遍,最後也講到了模板編譯的大致流程。然而在掛載的核心處,我們並沒有分析模板編譯後渲染函式是如何轉換為視覺化DOM節點的。因此這一章節,我們將重新回到Vue例項掛載的最後一個環節:渲染DOM節點。在渲染真實DOM的過程中,Vue引進了虛擬DOM的概念,這是Vue架構設計中另一個重要的理念。虛擬DOM作為JS物件和真實DOM中間的一個緩衝層,對JS頻繁操作DOM的引起的效能問題有很好的緩解作用。

4.1 Virtual DOM

4.1.1 瀏覽器的渲染流程

當瀏覽器接收到一個Html檔案時,JS引擎和瀏覽器的渲染引擎便開始工作了。從渲染引擎的角度,它首先會將html檔案解析成一個DOM樹,與此同時,瀏覽器將識別並載入CSS樣式,並和DOM樹一起合併為一個渲染樹。有了渲染樹後,渲染引擎將計算所有元素的位置資訊,最後通過繪製,在螢幕上列印最終的內容。JS引擎和渲染引擎雖然是兩個獨立的執行緒,但是JS引擎卻可以觸發渲染引擎工作,當我們通過指令碼去修改元素位置或外觀時,JS引擎會利用DOM相關的API方法去操作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幾個,顯然用Vnode物件要比真實DOM物件描述的內容要簡單得多,它只用來單純描述節點的關鍵屬性,例如標籤名,資料,子節點等。並沒有保留跟瀏覽器相關的DOM方法。除此之外,Vnode也會有其他的屬性用來擴充套件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

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
    );
    cloned.ns = vnode.ns;
    cloned.isStatic = vnode.isStatic;
    cloned.key = vnode.key;
    cloned.isComment = vnode.isComment;
    cloned.fnContext = vnode.fnContext;
    cloned.fnOptions = vnode.fnOptions;
    cloned.fnScopeId = vnode.fnScopeId;
    cloned.asyncMeta = vnode.asyncMeta;
    cloned.isCloned = true;
    return cloned
  }
複製程式碼

注意:cloneVnodeVnode的克隆只是一層淺拷貝,它不會對子節點進行深度克隆。

4.3 Virtual DOM的建立

先簡單回顧一下掛載的流程,掛載的過程是呼叫Vue例項上$mount方法,而$mount的核心是mountComponent函式。如果我們傳遞的是template模板,模板會先經過編譯器的解析,並最終根據不同平臺生成對應程式碼,此時對應的就是將with語句封裝好的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過程,我們之前只提到了它會定義跟渲染有關的函式,實際上它只定義了兩個重要的方法,_render函式就是其中一個。

// 引入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._renderProxy在資料代理分析過,本質上是為了做資料過濾檢測,它也繫結了render函式執行時的this指向。vm.$createElement方法會作為render函式的引數傳入。回憶一下,在手寫render函式時,我們會利用render函式的第一個引數createElement進行渲染函式的編寫,這裡的createElement引數就是定義好的$createElement方法。

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

初始化_init時,有一個initRender函式,它就是用來定義渲染函式方法的,其中就有vm.$createElement方法的定義,除了$createElement_c方法的定義也類似。其中 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前,它會先對傳入的引數進行處理,畢竟手寫的render函式引數規格不統一。舉一個簡單的例子。

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

這裡如果第二個引數是變數或者陣列,則預設是沒有傳遞data,因為data一般是物件形式存在。

function createElement (
    context, // vm 例項
    tag, // 標籤
    data, // 節點相關資料,屬性
    children, // 子節點
    normalizationType,
    alwaysNormalize // 區分內部編譯生成的render還是手寫render
  ) {
    // 對傳入引數做處理,如果沒有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函式去手寫渲染模板,就需要考慮使用者操作帶來的不確定性,因此_createElement在建立Vnode前會先資料的規範性進行檢測,將不合法的資料型別錯誤提前暴露給使用者。接下來將列舉幾個在實際場景中容易犯的錯誤,也方便我們理解原始碼中對這類錯誤的處理。

  1. 用響應式物件做data屬性
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) {
    // 1. 資料物件不能是定義在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() // 返回註釋節點
    }
    if (isDef(data) && isDef(data.is)) {
      tag = data.is;
    }
    if (!tag) {
      // 防止動態元件 :is 屬性設定為false時,需要做特殊處理
      return createEmptyVNode()
    }
    // 2. 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
        );
      }
    }
    ···
  }
複製程式碼

這些規範性檢測保證了後續Virtual DOM tree的完整生成。

4.3.2 子節點children規範化

Virtual DOM tree是由每個Vnode以樹狀形式拼成的虛擬DOM樹,我們在轉換真實節點時需要的就是這樣一個完整的Virtual DOM tree,因此我們需要保證每一個子節點都是Vnode型別,這裡分兩種場景分析。

  • 模板編譯render函式,理論上template模板通過編譯生成的render函式都是Vnode型別,但是有一個例外,函式式元件返回的是一個陣列(這個特殊例子,可以看函式式元件的文章分析),這個時候Vue的處理是將整個children拍平成一維陣列。
  • 使用者定義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'
    )
  }
複製程式碼

4.3.4 實際場景

在資料檢測和元件規範化後,接下來通過new VNode()便可以生成一棵完整的VNode樹,注意在_render過程中會遇到子元件,這個時候會優先去做子元件的初始化,這部分放到元件環節專門分析。我們用一個實際的例子,結束render函式到Virtual DOM的分析。

  • template模板形式
var vm = new Vue({
  el: '#app',
  template: '<div><span>virtual dom</span></div>'
})
複製程式碼
  • 模板編譯生成render函式
(function() {
  with(this){
    return _c('div',[_c('span',[_v("virual dom")])])
  }
})
複製程式碼
  • Virtual DOM tree的結果(省略版)
{
  tag: 'div',
  children: [{
    tag: 'span',
    children: [{
      tag: undefined,
      text: 'virtual dom'
    }]
  }]
}
複製程式碼

4.4 虛擬Vnode對映成真實DOM

回到 updateComponent的最後一個過程,虛擬的DOM樹在生成virtual dom後,會呼叫Vue原型上_update方法,將虛擬DOM對映成為真實的DOM。從原始碼上可以知道,_update的呼叫時機有兩個,一個是發生在初次渲染階段,另一個發生資料更新階段。

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

vm._update方法的定義在lifecycleMixin中。

lifecycleMixin()
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__方法,如果是服務端渲染,由於沒有DOM_patch方法是一個空函式,在有DOM物件的瀏覽器環境下,__patch__patch函式的引用。

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

patch方法又是createPatchFunction方法的返回值,createPatchFunction方法傳遞一個物件作為引數,物件擁有兩個屬性,nodeOpsmodulesnodeOps封裝了一系列操作原生DOM物件的方法。而modules定義了模組的鉤子函式。

 var patch = createPatchFunction({ nodeOps: nodeOps, modules: modules });

// 將操作dom物件的方法合集做凍結操作
 var nodeOps = /*#__PURE__*/Object.freeze({
    createElement: createElement$1,
    createElementNS: createElementNS,
    createTextNode: createTextNode,
    createComment: createComment,
    insertBefore: insertBefore,
    removeChild: removeChild,
    appendChild: appendChild,
    parentNode: parentNode,
    nextSibling: nextSibling,
    tagName: tagName,
    setTextContent: setTextContent,
    setStyleScope: setStyleScope
  });

// 定義了模組的鉤子函式
  var platformModules = [
    attrs,
    klass,
    events,
    domProps,
    style,
    transition
  ];

var modules = platformModules.concat(baseModules);
複製程式碼

真正的createPatchFunction函式有一千多行程式碼,這裡就不方便列舉出來了,它的內部首先定義了一系列輔助的方法,而核心是通過呼叫createElm方法進行dom操作,建立節點,插入子節點,遞迴建立一個完整的DOM樹並插入到Body中。並且在產生真實階段階段,會有diff演算法來判斷前後Vnode的差異,以求最小化改變真實階段。後面會有一個章節的內容去講解diff演算法。createPatchFunction的過程只需要先記住一些結論,函式內部會呼叫封裝好的DOM api,根據Virtual DOM的結果去生成真實的節點。其中如果遇到元件Vnode時,會遞迴呼叫子元件的掛載過程,這個過程我們也會放到後面章節去分析。

4.5 小結

這一節分析了mountComponent的兩個核心方法,renderupdate,在分析前重點介紹了存在於JS操作和DOM渲染的橋樑:Virtual DOMJSDOM節點的批量操作會先直接反應到Virtual DOM這個描述物件上,最終的結果才會直接作用到真實節點上。可以說,Virtual DOM很大程度提高了渲染的效能。文章重點介紹了render函式轉換成Virtual DOM的過程,並大致描述了_update函式的實現思路。其實這兩個過程都牽扯到元件,所以這一節對很多環節都無法深入分析,下一節開始會進入元件的專題。我相信分析完元件後,讀者會對整個渲染過程會有更深刻的理解和思考。


相關文章