1. 程式人生 > 程式設計 >Vue虛擬Dom到真實Dom的轉換

Vue虛擬Dom到真實Dom的轉換

再有一顆樹形結構的物件後, 我們需要做的就是講這棵樹跟真實Dom樹形成對映關係。我們先回顧之前的mountComponnet 方法:

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,不過它有兩個執行時機:

首次渲染

當執行new 到此時就是首次渲染了, 會將傳入的Vnode物件對映為真實的Dom。

更新頁面

資料變化會驅動頁面發生變化, 這也是vue最獨特的特性之一, 資料改變之前和之後生成兩份VNode進行比較, 而怎麼樣在舊的VNode上做最小的改動去渲染頁面,這樣一個diff演算法還是挺複雜的。 如果再沒有先說清楚資料響應式是怎麼回事之前,直接將diff對理解vue 的整體流程不太好。 所以這章分析首次渲染後, 下一章就是資料響應式, 之後才是diff比較。

先來看看vm._update方法的定義:

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

這裡的 vm. e l 是 之 前 在 = = m o u n t C o m p o n e n t = = 方 法 內 就 掛 載 的 , 一 個 真 實 的 = = D o m = = 元 素 。 首 次 渲 染 會 傳 入 v m . el 是之前在 ==mountComponent== 方法內就掛載的, 一個真實的==Dom==元素。 首次渲染會傳入 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上建立這些屬性時,依次執行陣列的每一項,也就是依次建立了它們。

PS: 這裡modules 屬性內的鉤子方法是區分平臺的, web,weex 以及 SSR 它們呼叫VNode 方法方式並不相同, 所以vue在這裡又使用了函式柯里化這個騷操作, 在createPatchFunction 內將平臺的差異化磨平, 從而 patch 方法只用接收新舊node即可。

生成Dom

這裡大家記住一句話即可, 無論VNode 是什麼型別的節點, 只有三種類型的節點會被建立並插入到Dom中: 元素節點,註釋節點, 和文字節點。

我們接著看下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格式覆蓋自己
    }
    ...
  }
}

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

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 方法開始生成真實的Dom,VNode 生成真實的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,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,refElm); // 插入到父節點
    
  } else {  // 文字節點
    vnode.elm = nodeOps.createTextNode(vnode.text)  // 建立文字節點
    insert(parentElm,refElm)  // 插入到父節點
  }
  
  ...
}

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

nodeOps:
export function createElement(tagName) {  // 建立節點
  return docwww.cppcns.comument.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,ref)  // 在父節點內的參考節點之前插入elm
      }
    } else {
      nodeOps.appendChild(parent,elm)  //  新增elm到parent內
    }
  }  // 沒有父節點什麼都不做
}
這算一個比較重要的方法,因為很多地方會用到。

依次判斷是否是元素節點, 註釋節點,文字節點, 分別建立它們然後插入到父節點裡面, 這裡主要介紹建立元素節點, 另外兩個並沒有複雜的邏輯。 我們接下來看下:createChild 方法定義:

function createChild(vnode,insertedVnodeQueue) {
  if(Array.isArray(children)) {  // 是陣列
    for(let i = 0; i < children.length; ++i) {  // 遍歷vnode每一項
      createElm(  // 遞迴呼叫
        children[i],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內, 完成建立的過程, 元素節點的建立還是比較簡單的, 接下來看下元件式怎麼建立的。

元件VNode生成Dom

{  // 元件VNode
  tag: 'vue-component-1-app',context: {...},componentOptions: {
    Ctor: function(){...},// 子元件建構函式
    propsData: undefined,children: undefined,tag: 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,refElm) { 
  ...
  if (createComponent(vnode,refElm)) { // 元件分支
    return  
  }
  ...

執行createComponent 方法, 如果是元素節點不會返回任何東西,所以是undefined , 會繼續走接下來的建立元節點的邏輯。 現在是元件, 我們看下createComponent 的實現:

function createComponent(vnode,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時構建元件關係。 將createComponentInsanceForVnode 執行的結果賦值給了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 方法內首先執行craeeteComponentInstanceForVnode方法, 這個方法的內部就會將子元件的建構函式例項化, 因為子元件的建構函式繼承了基類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)
  }
}

--------------------------------------------http://www.cppcns.com--------------------------------------------

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 方法傳入的是 undefined,oldVnode 也是undefinned,會執行__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,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,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) 

最後我們以一個問題來結束本章的內容:

父子兩個元件同時定義了 b程式設計客棧eforeCreate,created,beforeMounte,mounted 四個鉤子, 它們的執行順序是怎樣的?

解答:

首先會執行父元件的初始化過程, 所以會依次執行beforeCreate, created,在執行掛載前又會執行beforeMount鉤子, 不過在生成真實dom 的 __patch__過程中遇到巢狀子元件後又會轉為去執行子元件的初始化鉤子beforeCreate,子元件在掛載前會執行beforeMounte,再完成子元件的Dom建立後執行 mounted。 這個父元件的 patch 過程才算完成, 最後執行父元件的mounted 鉤子, 這就是它們的執行順序。 如下:

parent beforeCreate
parent created
parent beforeMounte
    child beforeCreate
    child created
    child beforeMounte
    child mounted
parent mounted

到此這篇關於Vue虛擬Dom到真實Dom的轉換的文章就介紹到這了,更多相關Vue虛擬Dom到真實Dom內容請搜尋我們以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援我們!