虛擬DOM技術(shù)使得我們的頁面渲染的效率更高,減輕了節(jié)點的操作從而提高性能。本篇文章帶大家深入解析一下vue中 Virtual DOM
的技術(shù)原理和 Vue
框架的具體實現(xiàn)。(學習視頻分享:vue視頻教程)
一、真實DOM
和其解析流程
本節(jié)我們主要介紹真實 DOM
的解析過程,通過介紹其解析過程以及存在的問題,從而引出為什么需要虛擬DOM
。一圖勝千言,如下圖為 webkit
渲染引擎工作流程圖
所有的瀏覽器渲染引擎工作流程大致分為5步:創(chuàng)建 DOM
樹 —> 創(chuàng)建 Style Rules
-> 構(gòu)建 Render
樹 —> 布局 Layout
-—> 繪制 Painting
。
- 第一步,構(gòu)建 DOM 樹:用 HTML 分析器,分析 HTML 元素,構(gòu)建一棵 DOM 樹;
- 第二步,生成樣式表:用 CSS 分析器,分析 CSS 文件和元素上的 inline 樣式,生成頁面的樣式表;
- 第三步,構(gòu)建 Render 樹:將 DOM 樹和樣式表關(guān)聯(lián)起來,構(gòu)建一棵 Render 樹(Attachment)。每個 DOM 節(jié)點都有 attach 方法,接受樣式信息,返回一個 render 對象(又名 renderer),這些 render 對象最終會被構(gòu)建成一棵 Render 樹;
- 第四步,確定節(jié)點坐標:根據(jù) Render 樹結(jié)構(gòu),為每個 Render 樹上的節(jié)點確定一個在顯示屏上出現(xiàn)的精確坐標;
- 第五步,繪制頁面:根據(jù) Render 樹和節(jié)點顯示坐標,然后調(diào)用每個節(jié)點的 paint 方法,將它們繪制出來。
注意點:
1、DOM
樹的構(gòu)建是文檔加載完成開始的? 構(gòu)建 DOM
樹是一個漸進過程,為達到更好的用戶體驗,渲染引擎會盡快將內(nèi)容顯示在屏幕上,它不必等到整個 HTML
文檔解析完成之后才開始構(gòu)建 render
樹和布局。
2、Render
樹是 DOM
樹和 CSS
樣式表構(gòu)建完畢后才開始構(gòu)建的? 這三個過程在實際進行的時候并不是完全獨立的,而是會有交叉,會一邊加載,一邊解析,以及一邊渲染。
3、CSS
的解析注意點? CSS
的解析是從右往左逆向解析的,嵌套標簽越多,解析越慢。
4、JS
操作真實 DOM
的代價? 用我們傳統(tǒng)的開發(fā)模式,原生 JS
或 JQ
操作 DOM
時,瀏覽器會從構(gòu)建 DOM 樹開始從頭到尾執(zhí)行一遍流程。在一次操作中,我需要更新 10 個 DOM
節(jié)點,瀏覽器收到第一個 DOM
請求后并不知道還有 9 次更新操作,因此會馬上執(zhí)行流程,最終執(zhí)行10 次。例如,第一次計算完,緊接著下一個 DOM
更新請求,這個節(jié)點的坐標值就變了,前一次計算為無用功。計算 DOM
節(jié)點坐標值等都是白白浪費的性能。即使計算機硬件一直在迭代更新,操作 DOM
的代價仍舊是昂貴的,頻繁操作還是會出現(xiàn)頁面卡頓,影響用戶體驗
二、Virtual-DOM
基礎(chǔ)
2.1、虛擬 DOM
的好處
虛擬 DOM
就是為了解決瀏覽器性能問題而被設(shè)計出來的。如前,若一次操作中有 10 次更新 DOM
的動作,虛擬 DOM
不會立即操作 DOM
,而是將這 10 次更新的 diff
內(nèi)容保存到本地一個 JS
對象中,最終將這個 JS
對象一次性 attch
到 DOM
樹上,再進行后續(xù)操作,避免大量無謂的計算量。所以,用 JS
對象模擬 DOM
節(jié)點的好處是,頁面的更新可以先全部反映在 JS
對象(虛擬 DOM
)上,操作內(nèi)存中的 JS
對象的速度顯然要更快,等更新完成后,再將最終的 JS
對象映射成真實的 DOM
,交由瀏覽器去繪制。
2.2、算法實現(xiàn)
2.2.1、用 JS
對象模擬 DOM
樹
(1)如何用 JS
對象模擬 DOM
樹
例如一個真實的 DOM
節(jié)點如下:
<div id="virtual-dom"> <p>Virtual DOM</p> <ul id="list"> <li class="item">Item 1</li> <li class="item">Item 2</li> <li class="item">Item 3</li> </ul> <div>Hello World</div> </div>
我們用 JavaScript
對象來表示 DOM
節(jié)點,使用對象的屬性記錄節(jié)點的類型、屬性、子節(jié)點等。
element.js
中表示節(jié)點對象代碼如下:
/** * Element virdual-dom 對象定義 * @param {String} tagName - dom 元素名稱 * @param {Object} props - dom 屬性 * @param {Array<Element|String>} - 子節(jié)點 */ function Element(tagName, props, children) { this.tagName = tagName this.props = props this.children = children // dom 元素的 key 值,用作唯一標識符 if(props.key){ this.key = props.key } var count = 0 children.forEach(function (child, i) { if (child instanceof Element) { count += child.count } else { children[i] = '' + child } count++ }) // 子元素個數(shù) this.count = count } function createElement(tagName, props, children){ return new Element(tagName, props, children); } module.exports = createElement;
根據(jù) element
對象的設(shè)定,則上面的 DOM
結(jié)構(gòu)就可以簡單表示為:
var el = require("./element.js"); var ul = el('div',{id:'virtual-dom'},[ el('p',{},['Virtual DOM']), el('ul', { id: 'list' }, [ el('li', { class: 'item' }, ['Item 1']), el('li', { class: 'item' }, ['Item 2']), el('li', { class: 'item' }, ['Item 3']) ]), el('div',{},['Hello World']) ])
現(xiàn)在 ul
就是我們用 JavaScript
對象表示的 DOM
結(jié)構(gòu),我們輸出查看 ul
對應的數(shù)據(jù)結(jié)構(gòu)如下:
(2)渲染用 JS
表示的 DOM
對象
但是頁面上并沒有這個結(jié)構(gòu),下一步我們介紹如何將 ul
渲染成頁面上真實的 DOM
結(jié)構(gòu),相關(guān)渲染函數(shù)如下:
/** * render 將virdual-dom 對象渲染為實際 DOM 元素 */ Element.prototype.render = function () { var el = document.createElement(this.tagName) var props = this.props // 設(shè)置節(jié)點的DOM屬性 for (var propName in props) { var propValue = props[propName] el.setAttribute(propName, propValue) } var children = this.children || [] children.forEach(function (child) { var childEl = (child instanceof Element) ? child.render() // 如果子節(jié)點也是虛擬DOM,遞歸構(gòu)建DOM節(jié)點 : document.createTextNode(child) // 如果字符串,只構(gòu)建文本節(jié)點 el.appendChild(childEl) }) return el }
我們通過查看以上 render
方法,會根據(jù) tagName
構(gòu)建一個真正的 DOM
節(jié)點,然后設(shè)置這個節(jié)點的屬性,最后遞歸地把自己的子節(jié)點也構(gòu)建起來。
我們將構(gòu)建好的 DOM
結(jié)構(gòu)添加到頁面 body
上面,如下:
ulRoot = ul.render(); document.body.appendChild(ulRoot);
這樣,頁面 body
里面就有真正的 DOM
結(jié)構(gòu),效果如下圖所示:
2.2.2、比較兩棵虛擬 DOM
樹的差異 — diff
算法
diff
算法用來比較兩棵 Virtual DOM
樹的差異,如果需要兩棵樹的完全比較,那么 diff
算法的時間復雜度為O(n^3)
。但是在前端當中,你很少會跨越層級地移動 DOM
元素,所以 Virtual DOM
只會對同一個層級的元素進行對比,如下圖所示, div
只會和同一層級的 div
對比,第二層級的只會跟第二層級對比,這樣算法復雜度就可以達到 O(n)
。
(1)深度優(yōu)先遍歷,記錄差異
在實際的代碼中,會對新舊兩棵樹進行一個深度優(yōu)先的遍歷,這樣每個節(jié)點都會有一個唯一的標記:
在深度優(yōu)先遍歷的時候,每遍歷到一個節(jié)點就把該節(jié)點和新的的樹進行對比。如果有差異的話就記錄到一個對象里面。
// diff 函數(shù),對比兩棵樹 function diff(oldTree, newTree) { var index = 0 // 當前節(jié)點的標志 var patches = {} // 用來記錄每個節(jié)點差異的對象 dfsWalk(oldTree, newTree, index, patches) return patches } // 對兩棵樹進行深度優(yōu)先遍歷 function dfsWalk(oldNode, newNode, index, patches) { var currentPatch = [] if (typeof (oldNode) === "string" && typeof (newNode) === "string") { // 文本內(nèi)容改變 if (newNode !== oldNode) { currentPatch.push({ type: patch.TEXT, content: newNode }) } } else if (newNode!=null && oldNode.tagName === newNode.tagName && oldNode.key === newNode.key) { // 節(jié)點相同,比較屬性 var propsPatches = diffProps(oldNode, newNode) if (propsPatches) { currentPatch.push({ type: patch.PROPS, props: propsPatches }) } // 比較子節(jié)點,如果子節(jié)點有'ignore'屬性,則不需要比較 if (!isIgnoreChildren(newNode)) { diffChildren( oldNode.children, newNode.children, index, patches, currentPatch ) } } else if(newNode !== null){ // 新節(jié)點和舊節(jié)點不同,用 replace 替換 currentPatch.push({ type: patch.REPLACE, node: newNode }) } if (currentPatch.length) { patches[index] = currentPatch } }
從以上可以得出,patches[1]
表示 p
,patches[3]
表示 ul
,以此類推。
(2)差異類型
DOM
操作導致的差異類型包括以下幾種:
- 節(jié)點替換:節(jié)點改變了,例如將上面的
div
換成h1
; - 順序互換:移動、刪除、新增子節(jié)點,例如上面
div
的子節(jié)點,把p
和ul
順序互換; - 屬性更改:修改了節(jié)點的屬性,例如把上面
li
的class
樣式類刪除; - 文本改變:改變文本節(jié)點的文本內(nèi)容,例如將上面
p
節(jié)點的文本內(nèi)容更改為 “Real Dom
”;
以上描述的幾種差異類型在代碼中定義如下所示:
var REPLACE = 0 // 替換原先的節(jié)點 var REORDER = 1 // 重新排序 var PROPS = 2 // 修改了節(jié)點的屬性 var TEXT = 3 // 文本內(nèi)容改變
(3)列表對比算法
子節(jié)點的對比算法,例如 p, ul, div
的順序換成了 div, p, ul
。這個該怎么對比?如果按照同層級進行順序?qū)Ρ鹊脑?,它們都會被替換掉。如 p
和 div
的 tagName
不同,p
會被 div
所替代。最終,三個節(jié)點都會被替換,這樣 DOM
開銷就非常大。而實際上是不需要替換節(jié)點,而只需要經(jīng)過節(jié)點移動就可以達到,我們只需知道怎么進行移動。
將這個問題抽象出來其實就是字符串的最小編輯距離問題(Edition Distance
),最常見的解決方法是 Levenshtein Distance
, Levenshtein Distance
是一個度量兩個字符序列之間差異的字符串度量標準,兩個單詞之間的 Levenshtein Distance
是將一個單詞轉(zhuǎn)換為另一個單詞所需的單字符編輯(插入、刪除或替換)的最小數(shù)量。Levenshtein Distance
是1965年由蘇聯(lián)數(shù)學家 Vladimir Levenshtein 發(fā)明的。Levenshtein Distance
也被稱為編輯距離(Edit Distance
),通過動態(tài)規(guī)劃求解,時間復雜度為 O(M*N)
。
定義:對于兩個字符串 a、b
,則他們的 Levenshtein Distance
為:
示例:字符串 a
和 b
,a=“abcde” ,b=“cabef”
,根據(jù)上面給出的計算公式,則他們的 Levenshtein Distance
的計算過程如下:
本文的 demo
使用插件 list-diff2
算法進行比較,該算法的時間復雜度偉 O(n*m)
,雖然該算法并非最優(yōu)的算法,但是用于對于 dom
元素的常規(guī)操作是足夠的。
該算法具體的實現(xiàn)過程這里不再詳細介紹,該算法的具體介紹可以參照:https://github.com/livoras/list-diff
(4)實例輸出
兩個虛擬 DOM
對象如下圖所示,其中 ul1
表示原有的虛擬 DOM
樹,ul2
表示改變后的虛擬 DOM
樹
var ul1 = el('div',{id:'virtual-dom'},[ el('p',{},['Virtual DOM']), el('ul', { id: 'list' }, [ el('li', { class: 'item' }, ['Item 1']), el('li', { class: 'item' }, ['Item 2']), el('li', { class: 'item' }, ['Item 3']) ]), el('div',{},['Hello World']) ]) var ul2 = el('div',{id:'virtual-dom'},[ el('p',{},['Virtual DOM']), el('ul', { id: 'list' }, [ el('li', { class: 'item' }, ['Item 21']), el('li', { class: 'item' }, ['Item 23']) ]), el('p',{},['Hello World']) ]) var patches = diff(ul1,ul2); console.log('patches:',patches);
我們查看輸出的兩個虛擬 DOM
對象之間的差異對象如下圖所示,我們能通過差異對象得到,兩個虛擬 DOM
對象之間進行了哪些變化,從而根據(jù)這個差異對象(patches
)更改原先的真實 DOM
結(jié)構(gòu),從而將頁面的 DOM
結(jié)構(gòu)進行更改。
2.2.3、將兩個虛擬 DOM
對象的差異應用到真正的 DOM
樹
(1)深度優(yōu)先遍歷 DOM
樹
因為步驟一所構(gòu)建的 JavaScript
對象樹和 render
出來真正的 DOM
樹的信息、結(jié)構(gòu)是一樣的。所以我們可以對那棵 DOM
樹也進行深度優(yōu)先的遍歷,遍歷的時候從步驟二生成的 patches
對象中找出當前遍歷的節(jié)點差異,如下相關(guān)代碼所示:
function patch (node, patches) { var walker = {index: 0} dfsWalk(node, walker, patches) } function dfsWalk (node, walker, patches) { // 從patches拿出當前節(jié)點的差異 var currentPatches = patches[walker.index] var len = node.childNodes ? node.childNodes.length : 0 // 深度遍歷子節(jié)點 for (var i = 0; i < len; i++) { var child = node.childNodes[i] walker.index++ dfsWalk(child, walker, patches) } // 對當前節(jié)點進行DOM操作 if (currentPatches) { applyPatches(node, currentPatches) } }
(2)對原有 DOM
樹進行 DOM
操作
我們根據(jù)不同類型的差異對當前節(jié)點進行不同的 DOM
操作 ,例如如果進行了節(jié)點替換,就進行節(jié)點替換 DOM
操作;如果節(jié)點文本發(fā)生了改變,則進行文本替換的 DOM
操作;以及子節(jié)點重排、屬性改變等 DOM
操作,相關(guān)代碼如 applyPatches
所示 :
function applyPatches (node, currentPatches) { currentPatches.forEach(currentPatch => { switch (currentPatch.type) { case REPLACE: var newNode = (typeof currentPatch.node === 'string') ? document.createTextNode(currentPatch.node) : currentPatch.node.render() node.parentNode.replaceChild(newNode, node) break case REORDER: reorderChildren(node, currentPatch.moves) break case PROPS: setProps(node, currentPatch.props) break case TEXT: node.textContent = currentPatch.content break default: throw new Error('Unknown patch type ' + currentPatch.type) } }) }
(3)DOM結(jié)構(gòu)改變
通過將第 2.2.2 得到的兩個 DOM
對象之間的差異,應用到第一個(原先)DOM
結(jié)構(gòu)中,我們可以看到 DOM
結(jié)構(gòu)進行了預期的變化,如下圖所示:
2.3、結(jié)語
相關(guān)代碼實現(xiàn)已經(jīng)放到 github 上面,有興趣的同學可以clone運行實驗,github地址為:https://github.com/fengshi123/virtual-dom-example%E3%80%82
Virtual DOM
算法主要實現(xiàn)上面三個步驟來實現(xiàn):
-
用
JS
對象模擬DOM
樹 —element.js
<div id="virtual-dom"> <p>Virtual DOM</p> <ul id="list"> <li class="item">Item 1</li> <li class="item">Item 2</li> <li class="item">Item 3</li> </ul> <div>Hello World</div> </div>
-
比較兩棵虛擬
DOM
樹的差異 —diff.js
-
將兩個虛擬
DOM
對象的差異應用到真正的DOM
樹 —patch.js
function applyPatches (node, currentPatches) { currentPatches.forEach(currentPatch => { switch (currentPatch.type) { case REPLACE: var newNode = (typeof currentPatch.node === 'string') ? document.createTextNode(currentPatch.node) : currentPatch.node.render() node.parentNode.replaceChild(newNode, node) break case REORDER: reorderChildren(node, currentPatch.moves) break case PROPS: setProps(node, currentPatch.props) break case TEXT: node.textContent = currentPatch.content break default: throw new Error('Unknown patch type ' + currentPatch.type) } }) }
三、Vue
源碼 Virtual-DOM
簡析
我們從第二章節(jié)(Virtual-DOM
基礎(chǔ))中已經(jīng)掌握 Virtual DOM
渲染成真實的 DOM
實際上要經(jīng)歷 VNode
的定義、diff
、patch
等過程,所以本章節(jié) Vue
源碼的解析也按這幾個過程來簡析。
3.1、VNode
模擬 DOM
樹
3.1.1、VNode
類簡析
在 Vue.js
中,Virtual DOM
是用 VNode
這個 Class
去描述,它定義在 src/core/vdom/vnode.js
中 ,從以下代碼塊中可以看到 Vue.js
中的 Virtual DOM
的定義較為復雜一些,因為它這里包含了很多 Vue.js
的特性。實際上 Vue.js
中 Virtual DOM
是借鑒了一個開源庫 snabbdom 的實現(xiàn),然后加入了一些 Vue.js
的一些特性。
export default class VNode { tag: string | void; data: VNodeData | void; children: ?Array<VNode>; text: string | void; elm: Node | void; ns: string | void; context: Component | void; // rendered in this component's scope key: string | number | void; componentOptions: VNodeComponentOptions | void; componentInstance: Component | void; // component instance parent: VNode | void; // component placeholder node // strictly internal raw: boolean; // contains raw HTML? (server only) isStatic: boolean; // hoisted static node isRootInsert: boolean; // necessary for enter transition check isComment: boolean; // empty comment placeholder? isCloned: boolean; // is a cloned node? isOnce: boolean; // is a v-once node? asyncFactory: Function | void; // async component factory function asyncMeta: Object | void; isAsyncPlaceholder: boolean; ssrContext: Object | void; fnContext: Component | void; // real context vm for functional nodes fnOptions: ?ComponentOptions; // for SSR caching devtoolsMeta: ?Object; // used to store functional render context for devtools fnScopeId: ?string; // functional scope id support constructor ( tag?: string, data?: VNodeData, children?: ?Array<VNode>, text?: string, elm?: Node, context?: Component, componentOptions?: VNodeComponentOptions, asyncFactory?: Function ) { this.tag = tag this.data = data this.children = children this.text = text this.elm = elm this.ns = undefined this.context = context this.fnContext = undefined this.fnOptions = undefined this.fnScopeId = undefined this.key = data && data.key this.componentOptions = componentOptions this.componentInstance = undefined this.parent = undefined this.raw = false this.isStatic = false this.isRootInsert = true this.isComment = false this.isCloned = false this.isOnce = false this.asyncFactory = asyncFactory this.asyncMeta = undefined this.isAsyncPlaceholder = false } }
這里千萬不要因為 VNode
的這么屬性而被嚇到,或者咬緊牙去摸清楚每個屬性的意義,其實,我們主要了解其幾個核心的關(guān)鍵屬性就差不多了,例如:
tag
屬性即這個vnode
的標簽屬性data
屬性包含了最后渲染成真實dom
節(jié)點后,節(jié)點上的class
,attribute
,style
以及綁定的事件children
屬性是vnode
的子節(jié)點text
屬性是文本屬性elm
屬性為這個vnode
對應的真實dom
節(jié)點key
屬性是vnode
的標記,在diff
過程中可以提高diff
的效率
3.1.2、源碼創(chuàng)建 VNode
過程
(1)初始化vue
我們在實例化一個 vue
實例,也即 new Vue( )
時,實際上是執(zhí)行 src/core/instance/index.js
中定義的 Function
函數(shù)。
function Vue (options) { if (process.env.NODE_ENV !== 'production' && !(this instanceof Vue) ) { warn('Vue is a constructor and should be called with the `new` keyword') } this._init(options) }
通過查看 Vue
的 function
,我們知道 Vue
只能通過 new
關(guān)鍵字初始化,然后調(diào)用 this._init
方法,該方法在 src/core/instance/init.js
中定義。
Vue.prototype._init = function (options?: Object) { const vm: Component = this // 省略一系列其它初始化的代碼 if (vm.$options.el) { console.log('vm.$options.el:',vm.$options.el); vm.$mount(vm.$options.el) } }
(2)Vue
實例掛載
Vue
中是通過 $mount
實例方法去掛載 dom
的,下面我們通過分析 compiler
版本的 mount
實現(xiàn),相關(guān)源碼在目錄 src/platforms/web/entry-runtime-with-compiler.js
文件中定義:。
const mount = Vue.prototype.$mount Vue.prototype.$mount = function ( el?: string | Element, hydrating?: boolean ): Component { el = el && query(el) // 省略一系列初始化以及邏輯判斷代碼 return mount.call(this, el, hydrating) }
我們發(fā)現(xiàn)最終還是調(diào)用用原先原型上的 $mount
方法掛載 ,原先原型上的 $mount
方法在 src/platforms/web/runtime/index.js
中定義 。
Vue.prototype.$mount = function ( el?: string | Element, hydrating?: boolean ): Component { el = el && inBrowser ? query(el) : undefined return mountComponent(this, el, hydrating) }
我們發(fā)現(xiàn)$mount
方法實際上會去調(diào)用 mountComponent
方法,這個方法定義在 src/core/instance/lifecycle.js
文件中
export function mountComponent ( vm: Component, el: ?Element, hydrating?: boolean ): Component { vm.$el = el // 省略一系列其它代碼 let updateComponent /* istanbul ignore if */ if (process.env.NODE_ENV !== 'production' && config.performance && mark) { updateComponent = () => { // 生成虛擬 vnode const vnode = vm._render() // 更新 DOM vm._update(vnode, hydrating) } } else { updateComponent = () => { vm._update(vm._render(), hydrating) } } // 實例化一個渲染W(wǎng)atcher,在它的回調(diào)函數(shù)中會調(diào)用 updateComponent 方法 new Watcher(vm, updateComponent, noop, { before () { if (vm._isMounted && !vm._isDestroyed) { callHook(vm, 'beforeUpdate') } } }, true /* isRenderWatcher */) hydrating = false return vm }
從上面的代碼可以看到,mountComponent
核心就是先實例化一個渲染Watcher
,在它的回調(diào)函數(shù)中會調(diào)用 updateComponent
方法,在此方法中調(diào)用 vm._render
方法先生成虛擬 Node,最終調(diào)用 vm._update
更新 DOM
。
(3)創(chuàng)建虛擬 Node
Vue
的 _render
方法是實例的一個私有方法,它用來把實例渲染成一個虛擬 Node
。它的定義在 src/core/instance/render.js
文件中:
Vue.prototype._render = function (): VNode { const vm: Component = this const { render, _parentVnode } = vm.$options let vnode try { // 省略一系列代碼 currentRenderingInstance = vm // 調(diào)用 createElement 方法來返回 vnode vnode = render.call(vm._renderProxy, vm.$createElement) } catch (e) { handleError(e, vm, `render`){} } // set parent vnode.parent = _parentVnode console.log("vnode...:",vnode); return vnode }
Vue.js
利用 _createElement
方法創(chuàng)建 VNode
,它定義在 src/core/vdom/create-elemenet.js
中:
export function _createElement ( context: Component, tag?: string | Class<Component> | Function | Object, data?: VNodeData, children?: any, normalizationType?: number ): VNode | Array<VNode> { // 省略一系列非主線代碼 if (normalizationType === ALWAYS_NORMALIZE) { // 場景是 render 函數(shù)不是編譯生成的 children = normalizeChildren(children) } else if (normalizationType === SIMPLE_NORMALIZE) { // 場景是 render 函數(shù)是編譯生成的 children = simpleNormalizeChildren(children) } let vnode, ns if (typeof tag === 'string') { let Ctor ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag) if (config.isReservedTag(tag)) { // 創(chuàng)建虛擬 vnode vnode = new VNode( config.parsePlatformTagName(tag), data, children, undefined, undefined, context ) } else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) { // component vnode = createComponent(Ctor, data, context, children, tag) } else { vnode = new VNode( tag, data, children, undefined, undefined, context ) } } else { vnode = createComponent(tag, data, context, children) } if (Array.isArray(vnode)) { return vnode } else if (isDef(vnode)) { if (isDef(ns)) applyNS(vnode, ns) if (isDef(data)) registerDeepBindings(data) return vnode } else { return createEmptyVNode() } }
_createElement
方法有 5 個參數(shù),context
表示 VNode 的上下文環(huán)境,它是 Component
類型;tag
表示標簽,它可以是一個字符串,也可以是一個 Component
;data
表示 VNode 的數(shù)據(jù),它是一個 VNodeData
類型,可以在 flow/vnode.js
中找到它的定義;children
表示當前 VNode 的子節(jié)點,它是任意類型的,需要被規(guī)范為標準的 VNode
數(shù)組;
3.1.3、實例查看
為了更直觀查看我們平時寫的 Vue
代碼如何用 VNode
類來表示,我們通過一個實例的轉(zhuǎn)換進行更深刻了解。
例如,實例化一個 Vue
實例:
var app = new Vue({ el: '#app', render: function (createElement) { return createElement('div', { attrs: { id: 'app', class: "class_box" }, }, this.message) }, data: { message: 'Hello Vue!' } })
我們打印出其對應的 VNode
表示:
3.2、diff
過程
3.2.1、Vue.js
源碼的 diff
調(diào)用邏輯
Vue.js
源碼實例化了一個 watcher
,這個 ~ 被添加到了在模板當中所綁定變量的依賴當中,一旦 model
中的響應式的數(shù)據(jù)發(fā)生了變化,這些響應式的數(shù)據(jù)所維護的 dep
數(shù)組便會調(diào)用 dep.notify()
方法完成所有依賴遍歷執(zhí)行的工作,這包括視圖的更新,即 updateComponent
方法的調(diào)用。watcher
和 updateComponent
方法定義在 src/core/instance/lifecycle.js
文件中 。
export function mountComponent ( vm: Component, el: ?Element, hydrating?: boolean ): Component { vm.$el = el // 省略一系列其它代碼 let updateComponent /* istanbul ignore if */ if (process.env.NODE_ENV !== 'production' && config.performance && mark) { updateComponent = () => { // 生成虛擬 vnode const vnode = vm._render() // 更新 DOM vm._update(vnode, hydrating) } } else { updateComponent = () => { vm._update(vm._render(), hydrating) } } // 實例化一個渲染W(wǎng)atcher,在它的回調(diào)函數(shù)中會調(diào)用 updateComponent 方法 new Watcher(vm, updateComponent, noop, { before () { if (vm._isMounted && !vm._isDestroyed) { callHook(vm, 'beforeUpdate') } } }, true /* isRenderWatcher */) hydrating = false return vm }
完成視圖的更新工作事實上就是調(diào)用了vm._update
方法,這個方法接收的第一個參數(shù)是剛生成的Vnode
,調(diào)用的vm._update
方法定義在 src/core/instance/lifecycle.js
中。
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) { const vm: Component = this const prevEl = vm.$el const prevVnode = vm._vnode const restoreActiveInstance = setActiveInstance(vm) vm._vnode = vnode if (!prevVnode) { // 第一個參數(shù)為真實的node節(jié)點,則為初始化 vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */) } else { // 如果需要diff的prevVnode存在,那么對prevVnode和vnode進行diff vm.$el = vm.__patch__(prevVnode, vnode) } restoreActiveInstance() // update __vue__ reference if (prevEl) { prevEl.__vue__ = null } if (vm.$el) { vm.$el.__vue__ = vm } // if parent is an HOC, update its $el as well if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) { vm.$parent.$el = vm.$el } }
在這個方法當中最為關(guān)鍵的就是 vm.__patch__
方法,這也是整個 virtual-dom
當中最為核心的方法,主要完成了prevVnode
和 vnode
的 diff
過程并根據(jù)需要操作的 vdom
節(jié)點打 patch
,最后生成新的真實 dom
節(jié)點并完成視圖的更新工作。
接下來,讓我們看下 vm.__patch__
的邏輯過程, vm.__patch__
方法定義在 src/core/vdom/patch.js
中。
function patch (oldVnode, vnode, hydrating, removeOnly) { ...... if (isUndef(oldVnode)) { // 當oldVnode不存在時,創(chuàng)建新的節(jié)點 isInitialPatch = true createElm(vnode, insertedVnodeQueue) } else { // 對oldVnode和vnode進行diff,并對oldVnode打patch const isRealElement = isDef(oldVnode.nodeType) if (!isRealElement && sameVnode(oldVnode, vnode)) { // patch existing root node patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly) } ...... } }
在 patch
方法中,我們看到會分為兩種情況,一種是當 oldVnode
不存在時,會創(chuàng)建新的節(jié)點;另一種則是已經(jīng)存在 oldVnode
,那么會對 oldVnode
和 vnode
進行 diff
及 patch
的過程。其中 patch
過程中會調(diào)用 sameVnode
方法來對對傳入的2個 vnode
進行基本屬性的比較,只有當基本屬性相同的情況下才認為這個2個vnode
只是局部發(fā)生了更新,然后才會對這2個 vnode
進行 diff
,如果2個 vnode
的基本屬性存在不一致的情況,那么就會直接跳過 diff
的過程,進而依據(jù) vnode
新建一個真實的 dom
,同時刪除老的 dom
節(jié)點。
function sameVnode (a, b) { return ( a.key === b.key && a.tag === b.tag && a.isComment === b.isComment && isDef(a.data) === isDef(b.data) && sameInputType(a, b) ) }
diff
過程中主要是通過調(diào)用 patchVnode
方法進行的:
function patchVnode (oldVnode, vnode, insertedVnodeQueue, ownerArray, index, removeOnly) { ...... const elm = vnode.elm = oldVnode.elm const oldCh = oldVnode.children const ch = vnode.children // 如果vnode沒有文本節(jié)點 if (isUndef(vnode.text)) { // 如果oldVnode的children屬性存在且vnode的children屬性也存在 if (isDef(oldCh) && isDef(ch)) { // updateChildren,對子節(jié)點進行diff if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly) } else if (isDef(ch)) { if (process.env.NODE_ENV !== 'production') { checkDuplicateKeys(ch) } // 如果oldVnode的text存在,那么首先清空text的內(nèi)容,然后將vnode的children添加進去 if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '') addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue) } else if (isDef(oldCh)) { // 刪除elm下的oldchildren removeVnodes(elm, oldCh, 0, oldCh.length - 1) } else if (isDef(oldVnode.text)) { // oldVnode有子節(jié)點,而vnode沒有,那么就清空這個節(jié)點 nodeOps.setTextContent(elm, '') } } else if (oldVnode.text !== vnode.text) { // 如果oldVnode和vnode文本屬性不同,那么直接更新真是dom節(jié)點的文本元素 nodeOps.setTextContent(elm, vnode.text) } ...... }
從以上代碼得知,
diff
過程中又分了好幾種情況,oldCh
為 oldVnode
的子節(jié)點,ch
為 Vnode
的子節(jié)點:
- 首先進行文本節(jié)點的判斷,若
oldVnode.text !== vnode.text
,那么就會直接進行文本節(jié)點的替換; - 在
vnode
沒有文本節(jié)點的情況下,進入子節(jié)點的diff
; - 當
oldCh
和ch
都存在且不相同的情況下,調(diào)用updateChildren
對子節(jié)點進行diff
; - 若
oldCh
不存在,ch
存在,首先清空oldVnode
的文本節(jié)點,同時調(diào)用addVnodes
方法將ch
添加到elm
真實dom
節(jié)點當中; - 若
oldCh
存在,ch
不存在,則刪除elm
真實節(jié)點下的oldCh
子節(jié)點; - 若
oldVnode
有文本節(jié)點,而vnode
沒有,那么就清空這個文本節(jié)點。
3.2.2、子節(jié)點 diff
流程分析
(1)Vue.js
源碼
這里著重分析下updateChildren
方法,它也是整個 diff
過程中最重要的環(huán)節(jié),以下為 Vue.js
的源碼過程,為了更形象理解 diff
過程,我們給出相關(guān)的示意圖來講解。
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) { // 為oldCh和newCh分別建立索引,為之后遍歷的依據(jù) let oldStartIdx = 0 let newStartIdx = 0 let oldEndIdx = oldCh.length - 1 let oldStartVnode = oldCh[0] let oldEndVnode = oldCh[oldEndIdx] let newEndIdx = newCh.length - 1 let newStartVnode = newCh[0] let newEndVnode = newCh[newEndIdx] let oldKeyToIdx, idxInOld, vnodeToMove, refElm // 直到oldCh或者newCh被遍歷完后跳出循環(huán) while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { if (isUndef(oldStartVnode)) { oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left } else if (isUndef(oldEndVnode)) { oldEndVnode = oldCh[--oldEndIdx] } else if (sameVnode(oldStartVnode, newStartVnode)) { patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx) oldStartVnode = oldCh[++oldStartIdx] newStartVnode = newCh[++newStartIdx] } else if (sameVnode(oldEndVnode, newEndVnode)) { patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx) oldEndVnode = oldCh[--oldEndIdx] newEndVnode = newCh[--newEndIdx] } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx) canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm)) oldStartVnode = oldCh[++oldStartIdx] newEndVnode = newCh[--newEndIdx] } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx) canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm) oldEndVnode = oldCh[--oldEndIdx] newStartVnode = newCh[++newStartIdx] } else { if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx) if (isUndef(idxInOld)) { // New element createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx) } else { vnodeToMove = oldCh[idxInOld] if (sameVnode(vnodeToMove, newStartVnode)) { patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx) oldCh[idxInOld] = undefined canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm) } else { // same key but different element. treat as new element createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx) } } newStartVnode = newCh[++newStartIdx] } } if (oldStartIdx > oldEndIdx) { refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue) } else if (newStartIdx > newEndIdx) { removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx) } }
在開始遍歷 diff
前,首先給 oldCh
和 newCh
分別分配一個 startIndex
和 endIndex
來作為遍歷的索引,當oldCh
或者 newCh
遍歷完后(遍歷完的條件就是 oldCh
或者 newCh
的 startIndex >= endIndex
),就停止oldCh
和 newCh
的 diff
過程。接下來通過實例來看下整個 diff
的過程(節(jié)點屬性中不帶 key
的情況)。
(2)無 key
的 diff
過程
我們通過以下示意圖對以上代碼過程進行講解:
(2.1)首先從第一個節(jié)點開始比較,不管是 oldCh
還是 newCh
的起始或者終止節(jié)點都不存在 sameVnode
,同時節(jié)點屬性中是不帶 key
標記的,因此第一輪的 diff
完后,newCh
的 startVnode
被添加到 oldStartVnode
的前面,同時 newStartIndex
前移一位;
(2.2)第二輪的 diff
中,滿足 sameVnode(oldStartVnode, newStartVnode)
,因此對這2個 vnode
進行diff
,最后將 patch
打到 oldStartVnode
上,同時 oldStartVnode
和 newStartIndex
都向前移動一位 ;
(2.3)第三輪的 diff
中,滿足 sameVnode(oldEndVnode, newStartVnode)
,那么首先對 oldEndVnode
和newStartVnode
進行 diff
,并對 oldEndVnode
進行 patch
,并完成 oldEndVnode
移位的操作,最后newStartIndex
前移一位,oldStartVnode
后移一位;
(2.4)第四輪的 diff
中,過程同步驟3;
(2.5)第五輪的 diff
中,同過程1;
(2.6)遍歷的過程結(jié)束后,newStartIdx > newEndIdx
,說明此時 oldCh
存在多余的節(jié)點,那么最后就需要將這些多余的節(jié)點刪除。
(3)有 key
的 diff
流程
在 vnode
不帶 key
的情況下,每一輪的 diff
過程當中都是起始
和結(jié)束
節(jié)點進行比較,直到 oldCh
或者newCh
被遍歷完。而當為 vnode
引入 key
屬性后,在每一輪的 diff
過程中,當起始
和結(jié)束
節(jié)點都沒有找到sameVnode
時,然后再判斷在 newStartVnode
的屬性中是否有 key
,且是否在 oldKeyToIndx
中找到對應的節(jié)點 :
- 如果不存在這個
key
,那么就將這個newStartVnode
作為新的節(jié)點創(chuàng)建且插入到原有的root
的子節(jié)點中; - 如果存在這個
key
,那么就取出oldCh
中的存在這個key
的vnode
,然后再進行diff
的過;
通過以上分析,給vdom
上添加 key
屬性后,遍歷 diff
的過程中,當起始點,結(jié)束點的搜尋及 diff
出現(xiàn)還是無法匹配的情況下時,就會用 key
來作為唯一標識,來進行 diff
,這樣就可以提高 diff
效率。
帶有 Key
屬性的 vnode
的 diff
過程可見下圖:
(3.1)首先從第一個節(jié)點開始比較,不管是 oldCh
還是 newCh
的起始或者終止節(jié)點都不存在 sameVnode
,但節(jié)點屬性中是帶 key
標記的, 然后在 oldKeyToIndx
中找到對應的節(jié)點,這樣第一輪 diff
過后 oldCh
上的B節(jié)點
被刪除了,但是 newCh
上的B節(jié)點
上 elm
屬性保持對 oldCh
上 B節(jié)點
的elm
引用。
(3.2)第二輪的 diff
中,滿足 sameVnode(oldStartVnode, newStartVnode)
,因此對這2個 vnode
進行diff
,最后將 patch
打到 oldStartVnode
上,同時 oldStartVnode
和 newStartIndex
都向前移動一位 ;
(3.3)第三輪的 diff
中,滿足 sameVnode(oldEndVnode, newStartVnode)
,那么首先對 oldEndVnode
和newStartVnode
進行 diff
,并對 oldEndVnode
進行 patch
,并完成 oldEndVnode
移位的操作,最后newStartIndex
前移一位,oldStartVnode
后移一位;
(3.4)第四輪的diff
中,過程同步驟2;
(3.5)第五輪的diff
中,因為此時 oldStartIndex
已經(jīng)大于 oldEndIndex
,所以將剩余的 Vnode
隊列插入隊列最后。
3.3、patch
過程
通過3.2章節(jié)介紹的 diff
過程中,我們會看到 nodeOps
相關(guān)的方法對真實 DOM
結(jié)構(gòu)進行操作,nodeOps
定義在 src/platforms/web/runtime/node-ops.js
中,其為基本 DOM
操作,這里就不在詳細介紹。
export function createElementNS (namespace: string, tagName: string): Element { return document.createElementNS(namespaceMap[namespace], tagName) } export function createTextNode (text: string): Text { return document.createTextNode(text) } export function createComment (text: string): Comment { return document.createComment(text) } export function insertBefore (parentNode: Node, newNode: Node, referenceNode: Node) { parentNode.insertBefore(newNode, referenceNode) } export function removeChild (node: Node, child: Node) { node.removeChild(child) }
3.4、總結(jié)
通過前三小節(jié)簡析,我們從主線上把模板和數(shù)據(jù)如何渲染成最終的 DOM
的過程分析完畢了,我們可以通過下圖更直觀地看到從初始化 Vue
到最終渲染的整個過程。
四、總結(jié)
本文從通過介紹真實 DOM
結(jié)構(gòu)其解析過程以及存在的問題,從而引出為什么需要虛擬 DOM
;然后分析虛擬DOM
的好處,以及其一些理論基礎(chǔ)和基礎(chǔ)算法的實現(xiàn);最后根據(jù)我們已經(jīng)掌握的基礎(chǔ)知識,再一步步去查看Vue.js
的源碼如何實現(xiàn)的。從存在問題 —> 理論基礎(chǔ) —> 具體實踐,一步步深入,幫助大家更好的了解什么是Virtual DOM
、為什么需要 Virtual DOM
、以及 Virtual DOM
的具體實現(xiàn),希望本文對您有幫助。
(學習視頻分享:web前端開發(fā)、編程基礎(chǔ)視頻)