Vue原理解析(五):完全搞懂虛擬Dom到真實Dom的生成過程

上一篇:Vue原理解析(四):你知道被你們聊爛了的虛擬Dom是怎麼生成的嗎?html

再有一棵樹形結構的JavaScript對象後,咱們如今須要作的就是將這棵樹跟真實的Dom樹造成映射關係,首先簡單回顧以前遇到的mountComponent方法:vue

export function mountComponent(vm, el) {
  vm.$el = el
  ...
  callHook(vm, 'beforeMount')
  ...
  const updateComponent = function () {
    vm._update(vm._render())
  }
  ...
}
複製代碼

咱們已經執行完了vm._render方法拿到了VNode,如今將它做爲參數傳給vm._update方法並執行。vm._update這個方法的做用就是就是將VNode轉爲真實的Dom,不過它有兩個執行的時機:node

首次渲染

  • 當執行new Vue到此時就是首次渲染了,會將傳入的VNode對象映射爲真實的Dom

更新頁面

  • 數據變化會驅動頁面發生變化,這也是vue最獨特的特性之一,數據改變以前和以後會生成兩份VNode進行比較,而怎麼樣在舊的VNode上作最小的改動去渲染頁面,這樣一個diff算法仍是挺複雜的。如再沒有先說清楚數據響應式是怎麼回事以前,而直接講diff對理解vue的總體流程並不太好。因此咱們這章分析完首次渲染後,下一章就是數據響應式,以後纔是diff比對,如此排序,萬望理解。

咱們如今先來看下vm._update方法的定義:web

Vue.prototype._update = function(vnode) {
  ... 首次渲染
  vm.$el = vm.__patch__(vm.$el, vnode)  // 覆蓋原來的vm.$el
  ...
}
複製代碼

這裏的vm.$el是以前在mountComponent方法內就掛載的,一個真實Dom元素。首次渲染會傳入vm.$el以及獲得的VNode,因此看下vm.__patch__定義:面試

Vue.prototype.__patch__ = createPatchFunction({ nodeOps, modules }) 
複製代碼

__patch__createPatchFunction方法內部返回的一個方法,它接受一個對象:算法

nodeOps屬性:封裝了操做原生Dom的一些方法的集合,如建立、插入、移除這些,再使用到的地方再詳解。數組

modules屬性:建立真實Dom也須要生成它的如class/attrs/style等屬性。modules是一個數組集合,數組的每一項都是這些屬性對應的鉤子方法,這些屬性的建立、更新、銷燬等都有對應鉤子方法,當某一時刻須要作某件事,執行對應的鉤子便可。好比它們都有create這個鉤子方法,如將這些create鉤子收集到一個數組內,須要在真實Dom上建立這些屬性時,依次執行數組的每一項,也就是依次建立了它們。bash

Ps: 這裏modules屬性內的鉤子方法是區分平臺的,webweex以及SSR它們調用VNode方法方式並不相同,因此vue在這裏又使用了函數柯里化這個騷操做,在createPatchFunction內將平臺的差別化抹平,從而__patch__方法只用接收新舊node便可。weex

生成Dom

這裏你們記住一句話便可,不管VNode是什麼類型的節點,只有三種類型的節點會被建立並插入到的Dom中:元素節點、註釋節點、和文本節點。app

咱們接着來看下createPatchFunction它究竟返回一個什麼樣的方法:

export function createPatchFunction(backend) {
  ...
  const { modules, nodeOps } = backend  // 解構出傳入的集合
  
  return function (oldVnode, vnode) {  // 接收新舊vnode
    ...
    
    const isRealElement = isDef(oldVnode.nodeType) // 是不是真實Dom
    if(isRealElement) {  // $el是真實Dom
      oldVnode = emptyNodeAt(oldVnode)  // 轉爲VNode格式覆蓋本身
    }
    ...
  }
}
複製代碼

首次渲染時沒有oldVnodeoldVnode就是$el,一個真實的dom,通過emptyNodeAt(oldVnode)方法包裝:

function emptyNodeAt(elm) {
  return new VNode(
    nodeOps.tagName(elm).toLowerCase(), // 對應tag屬性
    {},  // 對應data
    [],   // 對應children
    undefined,  //對應text
    elm  // 真實dom賦值給了elm屬性
  )
}

包裝後的:
{
  tag: 'div',
  elm: '<div id="app"></div>' // 真實dom
}

-------------------------------------------------------

nodeOps:
export function tagName (node) {  // 返回節點的標籤名
  return node.tagName  
}
複製代碼

再將傳入的$el屬性轉爲了VNode格式以後,咱們繼續:

export function createPatchFunction(backend) { 
  ...
  
  return function (oldVnode, vnode) {  // 接收新舊vnode
  
    const insertedVnodeQueue = []
    ...
    const oldElm = oldVnode.elm  //包裝後的真實Dom <div id='app'></div>
    const parentElm = nodeOps.parentNode(oldElm)  // 首次父節點爲<body></body>
  	
    createElm(  // 建立真實Dom
      vnode, // 第二個參數
      insertedVnodeQueue,  // 空數組
      parentElm,  // <body></body>
      nodeOps.nextSibling(oldElm)  // 下一個節點
    )
    
    return vnode.elm // 返回真實Dom覆蓋vm.$el
  }
}
                                              
------------------------------------------------------

nodeOps:
export function parentNode (node) {  // 獲取父節點
  return node.parentNode 
}

export function nextSibling(node) {  // 獲取下一個節點
  return node.nextSibing  
}
複製代碼

createElm方法開始生成真實的DomVNode生成真實的Dom的方式仍是分爲元素節點和組件兩種方式,因此咱們使用上一章生成的VNode分別說明。

1. 元素節點生成Dom

{  // 元素節點VNode
  tag: 'div',
  children: [{
      tag: 'h1',
      children: [
        {text: 'title h1'}
      ]
    }, {
      tag: 'h2',
      children: [
        {text: 'title h2'}
      ]
    }, {
      tag: 'h3',
      children: [
        {text: 'title h3'}
      ]
    }
  ]
}
複製代碼

你們能夠先看下這個流程圖有一個印象便可,接下來再看具體實現時相信思路會清晰不少:

開始建立Dom,咱們來看下它的定義:

function createElm(vnode, insertedVnodeQueue, parentElm, refElm, nested, ownerArray, index) { 
  ...
  const children = vnode.children  // [VNode, VNode, VNode]
  const tag = vnode.tag  // div
  
  if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
    return  // 若是是組件結果返回true,不會繼續,以後詳解createComponent
  }
  
  if(isDef(tag)) {  // 元素節點
    vnode.elm = nodeOps.createElement(tag)  // 建立父節點
    createChildren(vnode, children, insertedVnodeQueue)  // 建立子節點
    insert(parentElm, vnode.elm, refElm)  // 插入
    
  } else if(isTrue(vnode.isComment)) {  // 註釋節點
    vnode.elm = nodeOps.createComment(vnode.text)  // 建立註釋節點
    insert(parentElm, vnode.elm, refElm); // 插入到父節點
    
  } else {  // 文本節點
    vnode.elm = nodeOps.createTextNode(vnode.text)  // 建立文本節點
    insert(parentElm, vnode.elm, refElm)  // 插入到父節點
  }
  
  ...
}

------------------------------------------------------------------

nodeOps:
export function createElement(tagName) {  // 建立節點
  return document.createElement(tagName)
}

export function createComment(text) {  //建立註釋節點
  return document.createComment(text)
}

export function createTextNode(text) {  // 建立文本節點
  return document.createTextNode(text)
}

function insert (parent, elm, ref) {  //插入dom操做
  if (isDef(parent)) {  // 有父節點
    if (isDef(ref)) { // 有參考節點
      if (ref.parentNode === parent) {  // 參考節點的父節點等於傳入的父節點
        nodeOps.insertBefore(parent, elm, ref)  // 在父節點內的參考節點以前插入elm
      }
    } else {
      nodeOps.appendChild(parent, elm)  //  添加elm到parent內
    }
  }  // 沒有父節點什麼都不作
}
這算一個比較重要的方法,由於不少地方會用到。
複製代碼

依次判斷是不是元素節點、註釋節點、文本節點,分別建立它們而後插入到父節點裏面,這裏主要介紹建立元素節點,另外兩個並無複雜的邏輯。咱們來看下createChild方法定義:

function createChild(vnode, children, insertedVnodeQueue) {
  if(Array.isArray(children)) {  // 是數組
    for(let i = 0; i < children.length; ++i) {  // 遍歷vnode每一項
      createElm(  // 遞歸調用
        children[i], 
        insertedVnodeQueue, 
        vnode.elm, 
        null, 
        true, // 不是根節點插入
        children, 
        i
      )
    }
  } else if(isPrimitive(vnode.text)) {  //typeof爲string/number/symbol/boolean之一
    nodeOps.appendChild(  // 建立並插入到父節點
      vnode.elm, 
      nodeOps.createTextNode(String(vnode.text))
    )
  }
}

-------------------------------------------------------------------------------

nodeOps:
export default appendChild(node, child) {  // 添加子節點
  node.appendChild(child)
}
複製代碼

開始建立子節點,遍歷VNode的每一項,每一項仍是使用以前的createElm方法建立Dom。若是某一項又是數組,繼續調用createChild建立某一項的子節點;若是某一項不是數組,建立文本節點並將它添加到父節點內。像這樣使用遞歸的形式將嵌套的VNode所有建立爲真實的Dom

再看一遍流程圖,相信你們疑惑已經減小不少:

簡單來講就是由裏向外的挨個建立出真實的 Dom,而後插入到它的父節點內,最後將建立好的 Dom插入到 body內,完成建立的過程,元素節點的建立仍是比較簡單的,咱們接下來看下組件是怎麼建立的。

2. 組件VNode生成Dom

{  // 組件VNode
  tag: 'vue-component-1-app',
  context: {...},
  componentOptions: {
    Ctor: function(){...},  // 子組件構造函數
    propsData: undefined,
    children: undefined,
    tag: undefined,
    children: undefined
  },
  data: {
    on: undefined,  // 原生事件
    hook: {  // 組件鉤子
      init: function(){...},
      insert: function(){...},
      prepatch: function(){...},
      destroy: function(){...}
    }
  }
}

-------------------------------------------

<template>  // app組件內模板
  <div>app text</div>
</template>
複製代碼

首先仍是看張簡易流程圖,留個印象便可,方便理清以後的邏輯順序:

咱們使用上一章組件生成的 VNode,看下在 createElm內建立組件 Dom分支邏輯是怎麼樣的:

function createElm(vnode, insertedVnodeQueue, parentElm, refElm) { 
  ...
  if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) { // 組件分支
    return  
  }
  ...
複製代碼

執行createComponent方法,若是是元素節點不會返回任何東西,因此是undefined,會繼續走接下來的建立元素節點的邏輯。如今是組件,咱們看下createComponent的實現:

function createComponent(vnode, insertedVnodeQueue, parentElm, refElm) {
  let i = vnode.data
  if(isDef(i)) {
    if(isDef(i = i.hook) && isDef(i = i.init)) {
      i(vnode)  // 執行init方法
    }
    
    ...
  }
}
複製代碼

首先會將組件的vnode.data賦值給i,是否有這個屬性就能判斷是不是組件vnode。以後的if(isDef(i = i.hook) && isDef(i = i.init))集判斷和賦值爲一體,if內的i(vnode)就是執行的組件init(vnode)方法。這個時候咱們來看下組件的init鉤子方法作了什麼:

import activeInstance  // 全局變量

const init = vnode => {
  const child = vnode.componentInstance = 
    createComponentInstanceForVnode(vnode, activeInstance)
  ...
}
複製代碼

activeInstance是一個全局的變量,再update方法內賦值爲當前實例,再當前實例作__patch__的過程當中做爲子組件的父實例傳入,在子組件的initLifecycle時構建組件關係。將createComponentInstanceForVnode執行的結果賦值給了vnode.componentInstance,因此看下它的返回的結果是什麼:

export  createComponentInstanceForVnode(vnode, parent) {  // parent爲全局變量activeInstance
  const options = {  // 組件的options
    _isComponent: true,  // 設置一個標記位,代表是組件
    _parentVnode: vnode, 
    parent  // 子組件的父vm實例,讓初始化initLifecycle能夠創建父子關係
  }
  
  return new vnode.componentOptions.Ctor(options)  // 子組件的構造函數定義爲Ctor
}
複製代碼

再組件的init方法內首先執行createComponentInstanceForVnode方法,這個方法的內部就會將子組件的構造函數實例化,由於子組件的構造函數繼承了基類Vue的全部能力,這個時候至關於執行new Vue({...}),接下來又會執行_init方法進行一系列的子組件的初始化邏輯,咱們回到_init方法內,由於它們之間仍是有些不一樣的地方:

Vue.prototype._init = function(options) {
  if(options && options._isComponent) {  // 組件的合併options,_isComponent爲以前定義的標記位
    initInternalComponent(this, options)  // 區分是由於組件的合併項會簡單不少
  }
  
  initLifecycle(vm)  // 創建父子關係
  ...
  callHook(vm, 'created')
  
  if (vm.$options.el) { // 組件是沒有el屬性的,因此到這裏咋然而止
    vm.$mount(vm.$options.el)
  }
}

----------------------------------------------------------------------------------------

function initInternalComponent(vm, options) {  // 合併子組件options
  const opts = vm.$options = Object.create(vm.constructor.options)
  opts.parent = options.parent  // 組件init賦值,全局變量activeInstance
  opts._parentVnode = options._parentVnode  // 組件init賦值,組件的vnode 
  ...
}
複製代碼

前面都還執行的好好的,最後卻由於沒有el屬性,因此沒有掛載,createComponentInstanceForVnode方法執行完畢。這個時候咱們回到組件的init方法,補全剩下的邏輯:

const init = vnode => {
  const child = vnode.componentInstance = // 獲得組件的實例
    createComponentInstanceForVnode(vnode, activeInstance)
    
  child.$mount(undefined)  // 那就手動掛載唄
}
複製代碼

咱們在init方法內手動掛載這個組件,接着又會執行組件的_render()方法獲得組件內元素節點VNode,而後執行vm._update(),執行組件的__patch__方法,由於$mount方法傳入的是undefinedoldVnode也是undefined,會執行__patch__內的這段邏輯:

return function patch(oldVnode, vnode) {
  ...
  if (isUndef(oldVnode)) {
    createElm(vnode, insertedVnodeQueue)
  }
  ...
}

複製代碼

此次執行createElm時沒有傳入第三個參數父節點的,那組件建立好的Dom放哪生效了?沒有父節點也要生成Dom不是,這個時候執行的是組件的__patch__,因此參數vnode就是組件內元素節點的vnode了:

<template> // app組件內模板
  <div>app text</div>
</template>

-------------------------

{  // app內元素vnode
  tag: 'div',
  children: [
    {text: app text}
  ],
  parent: {  // 子組件_init時執行initLifecycle創建的關係
    tag: 'vue-component-1-app',
    componentOptions: {...}
  }
}
複製代碼

很明顯這個時候不是組件了,即便是組件也不要緊,大不了仍是執行一遍createComponent建立組件的邏輯,由於總會有組件是由元素節點組成的。這個時候咱們執行一遍建立元素節點的邏輯,由於沒有第三個參數父節點,因此組件的Dom雖然建立好了,並不會在這裏插入。請注意這個時候組件的init已經完成,可是組件的createComponent方法並無完成,咱們補全它的邏輯:

function createComponent(vnode, insertedVnodeQueue, parentElm, refElm) {
  let i = vnode.data;
  if (isDef(i)) {
    if (isDef(i = i.hook) && isDef(i = i.init)) {
      i(vnode)  // init已經完成
    }
    
    if (isDef(vnode.componentInstance)) {  // 執行組件init時被賦值
    
      initComponent(vnode)  // 賦值真實dom給vnode.elm
      
      insert(parentElm, vnode.elm, refElm)  // 組件Dom在這裏插入
      ...
      return true  // 因此會直接return
    }
  }
}

-----------------------------------------------------------------------

function initComponent(vnode) {
  ...
  vnode.elm = vnode.componentInstance.$el  // __patch__返回的真實dom
  ...
}
複製代碼

不管是嵌套多麼深的組件,遇到組件的後就執行init,在init__patch__過程當中又遇到嵌套組件,那就再執行嵌套組件的init,嵌套組件完成__patch__後將真實的Dom插入到它的父節點內,接着執行完外層組件的__patch__又插入到它的父節點內,最後插入到body內,完成嵌套組件的建立過程,總之仍是一個由裏及外的過程。

再回過頭來看這張圖,相信會好理解不少~

咱們再將本章最初的 mountComponent以後的邏輯補充完整:

export function mountComponent(vm, el) {
  ...
  const updateComponent = () => {
    vm._update(vm._render())
  }
  
  new Watcher(vm, updateComponent, noop, {
    before() {
      if(vm._isMounted) {
        callHook(vm, 'beforeUpdate')
      }
    }   
  }, true)
  
  ...
  callHook(vm, 'mounted')
  
  return vm
}
複製代碼

接下來會將updateComponent傳入到一個Watcher的類中,這個類是幹嗎的,咱們下一章再說明,接下來執行mounted鉤子方法。至此new Vue的整個流程就所有走完了。咱們回顧下從new Vue開始它的執行順序:

new Vue ==> vm._init() ==> vm.$mount(el) ==> vm._render()  ==> vm.update(vnode) 
複製代碼

最後咱們仍是以一道vue可能會被問到的面試題做爲本章的結束吧~

面試官微笑而又不失禮貌的問道:

  • 父子兩個組件同時定義了beforeCreatecreatedbeforeMountemounted四個鉤子,它們的執行順序是怎麼樣的?

懟回去:

  • 若是你們看完前面的章節,相信這個問題已經瞭然於胸了。首先會執行父組件的初始化過程,因此會依次執行beforeCreatecreated、在執行掛載前又會執行beforeMount鉤子,不過在生成真實dom__patch__過程當中遇到嵌套子組件後又會轉爲去執行子組件的初始化鉤子beforeCreatecreated,子組件在掛載前會執行beforeMounte,再完成子組件的Dom建立後執行mounted。這個父組件的__patch__過程纔算完成,最後執行父組件的mounted鉤子,這就是它們的執行順序。執行順序以下:
parent beforeCreate
parent created
parent beforeMounte
    child beforeCreate
    child created
    child beforeMounte
    child mounted
parent mounted
複製代碼

下一篇: Vue原理解析(六):全面深刻理解響應式原理(上)-對象篇

順手點個贊或關注唄,找起來也方便~

參考:

Vue.js源碼全方位深刻解析

Vue.js深刻淺出

Vue.js組件精講

剖析 Vue.js 內部運行機制

相關文章
相關標籤/搜索