虛擬DOM技術使得我們的頁面渲染的效率更高,並減輕了節點的操作從而提高效能。這篇文章帶大家深入解析一下vue中 Virtual DOM
的技術原理和 Vue
框架的具體實作。 (學習影片分享: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> <p>Virtual DOM</p> <ul> <li>Item 1</li> <li>Item 2</li> <li>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>} - 子节点 */ 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>
根據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 <p><strong>(2)对原有 <code>DOM</code> 树进行 <code>DOM</code> 操作</strong></p><p>我们根据不同类型的差异对当前节点进行不同的 <code>DOM</code> 操作 ,例如如果进行了节点替换,就进行节点替换 <code>DOM</code> 操作;如果节点文本发生了改变,则进行文本替换的 <code>DOM</code> 操作;以及子节点重排、属性改变等 <code>DOM</code> 操作,相关代码如 <code>applyPatches</code> 所示 :</p><div class="code" style="position:relative; padding:0px; margin:0px;"><pre class="brush:php;toolbar:false">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> <p>Virtual DOM</p> <ul> <li>Item 1</li> <li>Item 2</li> <li>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></vnode>
这里千万不要因为 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() } }</vnode></component>
_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) { 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的詳細內容。更多資訊請關注PHP中文網其他相關文章!