虛擬DOM技術使得我們的頁面渲染的效率更高,減輕了節點的操作從而提高效能。本篇文章帶大家深入解析一下中 Virtual DOM
的技術原理和 Vue
框架的具體實現。(學習視訊分享:)
DOM
和其解析流程 本節我們主要介紹真實 DOM
的解析過程,通過介紹其解析過程以及存在的問題,從而引出為什麼需要虛擬DOM
。一圖勝千言,如下圖為 webkit
渲染引擎工作流程圖
所有的瀏覽器渲染引擎工作流程大致分為5步:建立 DOM
樹 —> 建立 Style Rules
-> 構建 Render
樹 —> 佈局 Layout
-—> 繪製 Painting
。
注意點:
1、DOM
樹的構建是檔案載入完成開始的? 構建 DOM
樹是一個漸進過程,為達到更好的使用者體驗,渲染引擎會盡快將內容顯示在螢幕上,它不必等到整個 HTML
檔案解析完成之後才開始構建 render
樹和佈局。
2、Render
樹是 DOM
樹和 CSS
樣式表構建完畢後才開始構建的? 這三個過程在實際進行的時候並不是完全獨立的,而是會有交叉,會一邊載入,一邊解析,以及一邊渲染。
3、CSS
的解析注意點? CSS
的解析是從右往左逆向解析的,巢狀標籤越多,解析越慢。
4、JS
操作真實 DOM
的代價? 用我們傳統的開發模式,原生 JS
或 JQ
操作 DOM
時,瀏覽器會從構建 DOM 樹開始從頭到尾執行一遍流程。在一次操作中,我需要更新 10 個 DOM
節點,瀏覽器收到第一個 DOM
請求後並不知道還有 9 次更新操作,因此會馬上執行流程,最終執行10 次。例如,第一次計算完,緊接著下一個 DOM
更新請求,這個節點的座標值就變了,前一次計算為無用功。計算 DOM
節點座標值等都是白白浪費的效能。即使計算機硬體一直在迭代更新,操作 DOM
的代價仍舊是昂貴的,頻繁操作還是會出現頁面卡頓,影響使用者體驗
Virtual-DOM
基礎DOM
的好處 虛擬 DOM
就是為了解決瀏覽器效能問題而被設計出來的。如前,若一次操作中有 10 次更新 DOM
的動作,虛擬 DOM
不會立即操作 DOM
,而是將這 10 次更新的 diff
內容儲存到本地一個 JS
物件中,最終將這個 JS
物件一次性 attch
到 DOM
樹上,再進行後續操作,避免大量無謂的計算量。所以,用 JS
物件模擬 DOM
節點的好處是,頁面的更新可以先全部反映在 JS
物件(虛擬 DOM
)上,操作記憶體中的 JS
物件的速度顯然要更快,等更新完成後,再將最終的 JS
物件對映成真實的 DOM
,交由瀏覽器去繪製。
JS
物件模擬 DOM
樹(1)如何用 JS
物件模擬 DOM
樹
例如一個真實的 DOM
節點如下:
<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
節點,使用物件的屬性記錄節點的型別、屬性、子節點等。
element.js
中表示節點物件程式碼如下:
/** * Element virdual-dom 物件定義 * @param {String} tagName - dom 元素名稱 * @param {Object} props - dom 屬性 * @param {Array<Element|String>} - 子節點 */ 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++ }) // 子元素個數 this.count = count } function createElement(tagName, props, children){ return new Element(tagName, props, children); } module.exports = createElement;
根據 element
物件的設定,則上面的 DOM
結構就可以簡單表示為:
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']) ])
現在 ul
就是我們用 JavaScript
物件表示的 DOM
結構,我們輸出檢視 ul
對應的資料結構如下:
(2)渲染用 JS
表示的 DOM
物件
但是頁面上並沒有這個結構,下一步我們介紹如何將 ul
渲染成頁面上真實的 DOM
結構,相關渲染函數如下:
/** * render 將virdual-dom 物件渲染為實際 DOM 元素 */ Element.prototype.render = function () { var el = document.createElement(this.tagName) var props = this.props // 設定節點的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() // 如果子節點也是虛擬DOM,遞迴構建DOM節點 : document.createTextNode(child) // 如果字串,只構建文位元組點 el.appendChild(childEl) }) return el }
我們通過檢視以上 render
方法,會根據 tagName
構建一個真正的 DOM
節點,然後設定這個節點的屬性,最後遞迴地把自己的子節點也構建起來。
我們將構建好的 DOM
結構新增到頁面 body
上面,如下:
ulRoot = ul.render(); document.body.appendChild(ulRoot);
這樣,頁面 body
裡面就有真正的 DOM
結構,效果如下圖所示:
DOM
樹的差異 — diff
演演算法diff
演演算法用來比較兩棵 Virtual DOM
樹的差異,如果需要兩棵樹的完全比較,那麼 diff
演演算法的時間複雜度為O(n^3)
。但是在前端當中,你很少會跨越層級地移動 DOM
元素,所以 Virtual DOM
只會對同一個層級的元素進行對比,如下圖所示, div
只會和同一層級的 div
對比,第二層級的只會跟第二層級對比,這樣演演算法複雜度就可以達到 O(n)
。
(1)深度優先遍歷,記錄差異
在實際的程式碼中,會對新舊兩棵樹進行一個深度優先的遍歷,這樣每個節點都會有一個唯一的標記:
在深度優先遍歷的時候,每遍歷到一個節點就把該節點和新的的樹進行對比。如果有差異的話就記錄到一個物件裡面。
// diff 函數,對比兩棵樹 function diff(oldTree, newTree) { var index = 0 // 當前節點的標誌 var patches = {} // 用來記錄每個節點差異的物件 dfsWalk(oldTree, newTree, index, patches) return patches } // 對兩棵樹進行深度優先遍歷 function dfsWalk(oldNode, newNode, index, patches) { var currentPatch = [] if (typeof (oldNode) === "string" && typeof (newNode) === "string") { // 文字內容改變 if (newNode !== oldNode) { currentPatch.push({ type: patch.TEXT, content: newNode }) } } else if (newNode!=null && oldNode.tagName === newNode.tagName && oldNode.key === newNode.key) { // 節點相同,比較屬性 var propsPatches = diffProps(oldNode, newNode) if (propsPatches) { currentPatch.push({ type: patch.PROPS, props: propsPatches }) } // 比較子節點,如果子節點有'ignore'屬性,則不需要比較 if (!isIgnoreChildren(newNode)) { diffChildren( oldNode.children, newNode.children, index, patches, currentPatch ) } } else if(newNode !== null){ // 新節點和舊節點不同,用 replace 替換 currentPatch.push({ type: patch.REPLACE, node: newNode }) } if (currentPatch.length) { patches[index] = currentPatch } }
從以上可以得出,patches[1]
表示 p
,patches[3]
表示 ul
,以此類推。
(2)差異型別
DOM
操作導致的差異型別包括以下幾種:
div
換成 h1
;div
的子節點,把 p
和 ul
順序互換;li
的 class
樣式類刪除;p
節點的文字內容更改為 「Real Dom
」;以上描述的幾種差異型別在程式碼中定義如下所示:
var REPLACE = 0 // 替換原先的節點 var REORDER = 1 // 重新排序 var PROPS = 2 // 修改了節點的屬性 var TEXT = 3 // 文字內容改變
(3)列表對比演演算法
子節點的對比演演算法,例如 p, ul, div
的順序換成了 div, p, ul
。這個該怎麼對比?如果按照同層級進行順序對比的話,它們都會被替換掉。如 p
和 div
的 tagName
不同,p
會被 div
所替代。最終,三個節點都會被替換,這樣 DOM
開銷就非常大。而實際上是不需要替換節點,而只需要經過節點移動就可以達到,我們只需知道怎麼進行移動。
將這個問題抽象出來其實就是字串的最小編輯距離問題(Edition Distance
),最常見的解決方法是 Levenshtein Distance
, Levenshtein Distance
是一個度量兩個字元序列之間差異的字串度量標準,兩個單詞之間的 Levenshtein Distance
是將一個單詞轉換為另一個單詞所需的單字元編輯(插入、刪除或替換)的最小數量。Levenshtein Distance
是1965年由蘇聯數學家 Vladimir Levenshtein 發明的。Levenshtein Distance
也被稱為編輯距離(Edit Distance
),通過動態規劃求解,時間複雜度為 O(M*N)
。
定義:對於兩個字串 a、b
,則他們的 Levenshtein Distance
為:
範例:字串 a
和 b
,a=「abcde」 ,b=「cabef」
,根據上面給出的計算公式,則他們的 Levenshtein Distance
的計算過程如下:
本文的 demo
使用外掛 list-diff2
演演算法進行比較,該演演算法的時間複雜度偉 O(n*m)
,雖然該演演算法並非最優的演演算法,但是用於對於 dom
元素的常規操作是足夠的。
該演演算法具體的實現過程這裡不再詳細介紹,該演演算法的具體介紹可以參照: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
物件之間進行了哪些變化,從而根據這個差異物件(patches
)更改原先的真實 DOM
結構,從而將頁面的 DOM
結構進行更改。
DOM
物件的差異應用到真正的 DOM
樹(1)深度優先遍歷 DOM
樹
因為步驟一所構建的 JavaScript
物件樹和 render
出來真正的 DOM
樹的資訊、結構是一樣的。所以我們可以對那棵 DOM
樹也進行深度優先的遍歷,遍歷的時候從步驟二生成的 patches
物件中找出當前遍歷的節點差異,如下相關程式碼所示:
function patch (node, patches) { var walker = {index: 0} dfsWalk(node, walker, patches) } function dfsWalk (node, walker, patches) { // 從patches拿出當前節點的差異 var currentPatches = patches[walker.index] var len = node.childNodes ? node.childNodes.length : 0 // 深度遍歷子節點 for (var i = 0; i < len; i++) { var child = node.childNodes[i] walker.index++ dfsWalk(child, walker, patches) } // 對當前節點進行DOM操作 if (currentPatches) { applyPatches(node, currentPatches) } }
(2)對原有 DOM
樹進行 DOM
操作
我們根據不同型別的差異對當前節點進行不同的 DOM
操作 ,例如如果進行了節點替換,就進行節點替換 DOM
操作;如果節點文字發生了改變,則進行文字替換的 DOM
操作;以及子節點重排、屬性改變等 DOM
操作,相關程式碼如 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結構改變
通過將第 2.2.2 得到的兩個 DOM
物件之間的差異,應用到第一個(原先)DOM
結構中,我們可以看到 DOM
結構進行了預期的變化,如下圖所示:
相關程式碼實現已經放到 github 上面,有興趣的同學可以clone執行實驗,github地址為:https://github.com/fengshi123/virtual-dom-example%E3%80%82
Virtual DOM
演演算法主要實現上面三個步驟來實現:
用 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
簡析我們從第二章節(Virtual-DOM
基礎)中已經掌握 Virtual DOM
渲染成真實的 DOM
實際上要經歷 VNode
的定義、diff
、patch
等過程,所以本章節 Vue
原始碼的解析也按這幾個過程來簡析。
VNode
模擬 DOM
樹VNode
類簡析在 Vue.js
中,Virtual DOM
是用 VNode
這個 Class
去描述,它定義在 src/core/vdom/vnode.js
中 ,從以下程式碼塊中可以看到 Vue.js
中的 Virtual DOM
的定義較為複雜一些,因為它這裡包含了很多 Vue.js
的特性。實際上 Vue.js
中 Virtual DOM
是借鑑了一個開源庫 snabbdom 的實現,然後加入了一些 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
的這麼屬性而被嚇到,或者咬緊牙去摸清楚每個屬性的意義,其實,我們主要了解其幾個核心的關鍵屬性就差不多了,例如:
tag
屬性即這個vnode
的標籤屬性data
屬性包含了最後渲染成真實dom
節點後,節點上的class
,attribute
,style
以及繫結的事件children
屬性是vnode
的子節點text
屬性是文字屬性elm
屬性為這個vnode
對應的真實dom
節點key
屬性是vnode
的標記,在diff
過程中可以提高diff
的效率VNode
過程(1)初始化vue
我們在範例化一個 vue
範例,也即 new Vue( )
時,實際上是執行 src/core/instance/index.js
中定義的 Function
函數。
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
關鍵字初始化,然後呼叫 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
實現,相關原始碼在目錄 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) }
我們發現最終還是呼叫用原先原型上的 $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) }
我們發現$mount
方法實際上會去呼叫 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) } } // 範例化一個渲染Watcher,在它的回撥函數中會呼叫 updateComponent 方法 new Watcher(vm, updateComponent, noop, { before () { if (vm._isMounted && !vm._isDestroyed) { callHook(vm, 'beforeUpdate') } } }, true /* isRenderWatcher */) hydrating = false return vm }
從上面的程式碼可以看到,mountComponent
核心就是先範例化一個渲染Watcher
,在它的回撥函數中會呼叫 updateComponent
方法,在此方法中呼叫 vm._render
方法先生成虛擬 Node,最終呼叫 vm._update
更新 DOM
。
(3)建立虛擬 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 // 呼叫 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
方法建立 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 函數不是編譯生成的 children = normalizeChildren(children) } else if (normalizationType === SIMPLE_NORMALIZE) { // 場景是 render 函數是編譯生成的 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)) { // 建立虛擬 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 個引數,context
表示 VNode 的上下文環境,它是 Component
型別;tag
表示標籤,它可以是一個字串,也可以是一個 Component
;data
表示 VNode 的資料,它是一個 VNodeData
型別,可以在 flow/vnode.js
中找到它的定義;children
表示當前 VNode 的子節點,它是任意型別的,需要被規範為標準的 VNode
陣列;
為了更直觀檢視我們平時寫的 Vue
程式碼如何用 VNode
類來表示,我們通過一個範例的轉換進行更深刻了解。
例如,範例化一個 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
表示:
diff
過程Vue.js
原始碼的 diff
呼叫邏輯Vue.js
原始碼範例化了一個 watcher
,這個 ~ 被新增到了在模板當中所繫結變數的依賴當中,一旦 model
中的響應式的資料發生了變化,這些響應式的資料所維護的 dep
陣列便會呼叫 dep.notify()
方法完成所有依賴遍歷執行的工作,這包括檢視的更新,即 updateComponent
方法的呼叫。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) } } // 範例化一個渲染Watcher,在它的回撥函數中會呼叫 updateComponent 方法 new Watcher(vm, updateComponent, noop, { before () { if (vm._isMounted && !vm._isDestroyed) { callHook(vm, 'beforeUpdate') } } }, true /* isRenderWatcher */) hydrating = false return vm }
完成檢視的更新工作事實上就是呼叫了vm._update
方法,這個方法接收的第一個引數是剛生成的Vnode
,呼叫的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) { // 第一個引數為真實的node節點,則為初始化 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 } }
在這個方法當中最為關鍵的就是 vm.__patch__
方法,這也是整個 virtual-dom
當中最為核心的方法,主要完成了prevVnode
和 vnode
的 diff
過程並根據需要操作的 vdom
節點打 patch
,最後生成新的真實 dom
節點並完成檢視的更新工作。
接下來,讓我們看下 vm.__patch__
的邏輯過程, vm.__patch__
方法定義在 src/core/vdom/patch.js
中。
function patch (oldVnode, vnode, hydrating, removeOnly) { ...... if (isUndef(oldVnode)) { // 當oldVnode不存在時,建立新的節點 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
不存在時,會建立新的節點;另一種則是已經存在 oldVnode
,那麼會對 oldVnode
和 vnode
進行 diff
及 patch
的過程。其中 patch
過程中會呼叫 sameVnode
方法來對對傳入的2個 vnode
進行基本屬性的比較,只有當基本屬性相同的情況下才認為這個2個vnode
只是區域性發生了更新,然後才會對這2個 vnode
進行 diff
,如果2個 vnode
的基本屬性存在不一致的情況,那麼就會直接跳過 diff
的過程,進而依據 vnode
新建一個真實的 dom
,同時刪除老的 dom
節點。
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
過程中主要是通過呼叫 patchVnode
方法進行的:
function patchVnode (oldVnode, vnode, insertedVnodeQueue, ownerArray, index, removeOnly) { ...... const elm = vnode.elm = oldVnode.elm const oldCh = oldVnode.children const ch = vnode.children // 如果vnode沒有文位元組點 if (isUndef(vnode.text)) { // 如果oldVnode的children屬性存在且vnode的children屬性也存在 if (isDef(oldCh) && isDef(ch)) { // updateChildren,對子節點進行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的內容,然後將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有子節點,而vnode沒有,那麼就清空這個節點 nodeOps.setTextContent(elm, '') } } else if (oldVnode.text !== vnode.text) { // 如果oldVnode和vnode文字屬性不同,那麼直接更新真是dom節點的文字元素 nodeOps.setTextContent(elm, vnode.text) } ...... }
從以上程式碼得知,
diff
過程中又分了好幾種情況,oldCh
為 oldVnode
的子節點,ch
為 Vnode
的子節點:
oldVnode.text !== vnode.text
,那麼就會直接進行文位元組點的替換;vnode
沒有文位元組點的情況下,進入子節點的 diff
;oldCh
和 ch
都存在且不相同的情況下,呼叫 updateChildren
對子節點進行 diff
;oldCh
不存在,ch
存在,首先清空 oldVnode
的文位元組點,同時呼叫 addVnodes
方法將 ch
新增到elm
真實 dom
節點當中;oldCh
存在,ch
不存在,則刪除 elm
真實節點下的 oldCh
子節點;oldVnode
有文位元組點,而 vnode
沒有,那麼就清空這個文位元組點。diff
流程分析(1)Vue.js
原始碼
這裡著重分析下updateChildren
方法,它也是整個 diff
過程中最重要的環節,以下為 Vue.js
的原始碼過程,為了更形象理解 diff
過程,我們給出相關的示意圖來講解。
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) { // 為oldCh和newCh分別建立索引,為之後遍歷的依據 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被遍歷完後跳出迴圈 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
的過程(節點屬性中不帶 key
的情況)。
(2)無 key
的 diff
過程
我們通過以下示意圖對以上程式碼過程進行講解:
(2.1)首先從第一個節點開始比較,不管是 oldCh
還是 newCh
的起始或者終止節點都不存在 sameVnode
,同時節點屬性中是不帶 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)遍歷的過程結束後,newStartIdx > newEndIdx
,說明此時 oldCh
存在多餘的節點,那麼最後就需要將這些多餘的節點刪除。
(3)有 key
的 diff
流程
在 vnode
不帶 key
的情況下,每一輪的 diff
過程當中都是起始
和結束
節點進行比較,直到 oldCh
或者newCh
被遍歷完。而當為 vnode
引入 key
屬性後,在每一輪的 diff
過程中,當起始
和結束
節點都沒有找到sameVnode
時,然後再判斷在 newStartVnode
的屬性中是否有 key
,且是否在 oldKeyToIndx
中找到對應的節點 :
key
,那麼就將這個 newStartVnode
作為新的節點建立且插入到原有的 root
的子節點中;key
,那麼就取出 oldCh
中的存在這個 key
的 vnode
,然後再進行 diff
的過;通過以上分析,給vdom
上新增 key
屬性後,遍歷 diff
的過程中,當起始點,結束點的搜尋及 diff
出現還是無法匹配的情況下時,就會用 key
來作為唯一標識,來進行 diff
,這樣就可以提高 diff
效率。
帶有 Key
屬性的 vnode
的 diff
過程可見下圖:
(3.1)首先從第一個節點開始比較,不管是 oldCh
還是 newCh
的起始或者終止節點都不存在 sameVnode
,但節點屬性中是帶 key
標記的, 然後在 oldKeyToIndx
中找到對應的節點,這樣第一輪 diff
過後 oldCh
上的B節點
被刪除了,但是 newCh
上的B節點
上 elm
屬性保持對 oldCh
上 B節點
的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
已經大於 oldEndIndex
,所以將剩餘的 Vnode
佇列插入佇列最後。
patch
過程通過3.2章節介紹的 diff
過程中,我們會看到 nodeOps
相關的方法對真實 DOM
結構進行操作,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) }
通過前三小節簡析,我們從主線上把模板和資料如何渲染成最終的 DOM
的過程分析完畢了,我們可以通過下圖更直觀地看到從初始化 Vue
到最終渲染的整個過程。
本文從通過介紹真實 DOM
結構其解析過程以及存在的問題,從而引出為什麼需要虛擬 DOM
;然後分析虛擬DOM
的好處,以及其一些理論基礎和基礎演演算法的實現;最後根據我們已經掌握的基礎知識,再一步步去檢視Vue.js
的原始碼如何實現的。從存在問題 —> 理論基礎 —> 具體實踐,一步步深入,幫助大家更好的瞭解什麼是Virtual DOM
、為什麼需要 Virtual DOM
、以及 Virtual DOM
的具體實現,希望本文對您有幫助。
(學習視訊分享:、)
以上就是深入解析Vue中的虛擬DOM的詳細內容,更多請關注TW511.COM其它相關文章!