深刻剖析Vue源碼 - 完整渲染過程

繼上一節內容,咱們將Vue複雜的掛載流程經過圖解流程,代碼分析的方式簡單梳理了一遍,最後也講到了模板編譯的大體流程。然而在掛載的核心處,咱們並無分析模板編譯後渲染函數是如何轉換爲可視化DOM節點的。所以這一章節,咱們將從新回到Vue實例掛載的最後一個環節:渲染DOM節點。在渲染真實DOM的過程當中,Vue引進了虛擬DOM的概念,這是Vue架構設計中另外一個重要的理念。虛擬DOM做爲JS對象和真實DOM中間的一個緩衝層,對JS頻繁操做DOM的引發的性能問題有很好的緩解做用。html

4.1 Virtual DOM

4.1.1 瀏覽器的渲染流程

當瀏覽器接收到一個Html文件時,JS引擎和瀏覽器的渲染引擎便開始工做了。從渲染引擎的角度,它首先會將html文件解析成一個DOM樹,與此同時,瀏覽器將識別並加載CSS樣式,並和DOM樹一塊兒合併爲一個渲染樹。有了渲染樹後,渲染引擎將計算全部元素的位置信息,最後經過繪製,在屏幕上打印最終的內容。JS引擎和渲染引擎雖然是兩個獨立的線程,可是JS引擎卻能夠觸發渲染引擎工做,當咱們經過腳本去修改元素位置或外觀時,JS引擎會利用DOM相關的API方法去操做DOM對象,此時渲染引擎變開始工做,渲染引擎會觸發迴流或者重繪。下面是迴流重繪的兩個概念:node

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

很顯然迴流比重繪更加耗費性能算法

經過了解瀏覽器基本的渲染機制,咱們很容易聯想到當不斷的經過JS修改DOM時,不經意間會觸發到渲染引擎的迴流或者重繪,這個性能開銷是很是巨大的。所以爲了下降開銷,咱們須要作的是儘量減小DOM操做。有什麼方法能夠作到呢?api

4.1.2 緩衝層-虛擬DOM

虛擬DOM是爲了解決頻繁操做DOM引起性能問題的產物。虛擬DOM(下面稱爲Virtual DOM)是將頁面的狀態抽象爲JS對象的形式,本質上是JS和真實DOM的中間層,當咱們想用JS腳本大批量進行DOM操做時,會優先做用於Virtual DOM這個JS對象,最後經過對比將要改動的部分通知並更新到真實的DOM。儘管最終仍是操做真實的DOM,但Virtual DOM能夠將多個改動合併成一個批量的操做,從而減小 DOM 重排的次數,進而縮短了生成渲染樹和繪製所花的時間。數組

咱們看一個真實的DOM包含了什麼:瀏覽器

瀏覽器將一個真實 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節點。bash

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的相關方法。app

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的克隆只是一層淺拷貝,它不會對子節點進行深度克隆。dom

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函數的實現思路。其實這兩個過程都牽扯到組件,因此這一節對不少環節都沒法深刻分析,下一節開始會進入組件的專題。我相信分析完組件後,讀者會對整個渲染過程會有更深入的理解和思考。


相關文章
相關標籤/搜索