徹底搞懂虛擬Dom到真實Dom的生成過程


再有一棵樹形結構的JavaScript對象后,我們現在需要做的就是將這棵樹跟真實的Dom樹形成映射關系,首先簡單回顧之前遇到的mountComponent方法:

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 vue到此時就是首次渲染了,會將傳入的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.$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(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方法開始生成真實的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, 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內,完成創建的過程,元素節點的創建還是比較簡單的,我們接下來看下組件是怎么創建的。

廣州VI設計公司https://www.houdianzi.com

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方法傳入的是undefined,oldVnode也是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可能會被問到的面試題作為本章的結束吧~

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

父子兩個組件同時定義了beforeCreate、created、beforeMounte、mounted四個鈎子,它們的執行順序是怎么樣的?

懟回去:

如果大家看完前面的章節,相信這個問題已經了然於胸了。首先會執行父組件的初始化過程,所以會依次執行beforeCreate、created、在執行掛載前又會執行beforeMount鈎子,不過在生成真實dom的__patch__過程中遇到嵌套子組件后又會轉為去執行子組件的初始化鈎子beforeCreate、created,子組件在掛載前會執行beforeMounte,再完成子組件的Dom創建后執行mounted。這個父組件的__patch__過程才算完成,最后執行父組件的mounted鈎子,這就是它們的執行順序。執行順序如下:
parent beforeCreate parent created parent beforeMounte child beforeCreate child created child beforeMounte child mounted parent mounted


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM