虛擬DOM技術使得我們的頁面渲染的效率更高,並減輕了節點的操作從而提高效能。這篇文章帶大家深入解析一下vue中 Virtual DOM
的技術原理和 Vue
框架的具體實作。 (學習影片分享:vue影片教學)
一、真實DOM
和其解析流程
本節我們主要介紹真實 DOM
的解析過程,透過介紹其解析過程以及存在的問題,從而引出為什麼需要虛擬DOM
。一圖勝千言,如下圖為webkit
渲染引擎工作流程圖
所有的瀏覽器渲染引擎工作流程大致分為5步驟:創建DOM
樹—> 建立Style Rules
-> 建立Render
樹—> 佈局Layout
# -—> 繪製Painting
。
- 第一步,建立DOM 樹:用HTML 分析器,分析HTML 元素,建構一棵DOM 樹;
- 第二步,產生樣式表:用CSS 分析器,分析CSS 檔案和元素上的inline 樣式,產生頁面的樣式表;
- 第三步,建立Render 樹:將DOM 樹和樣式表關聯起來,建立一棵Render 樹(Attachment)。每個DOM 節點都有attach 方法,接受樣式訊息,傳回一個render 物件(又稱renderer),這些render 物件最終會被建構成一棵Render 樹;
- 第四步,確定節點座標:根據Render 樹狀結構,為每個Render 樹上的節點確定一個在顯示器上出現的精確座標;
- #第五步,繪製頁面:根據Render 樹和節點顯示座標,然後呼叫每個節點的paint 方法,將它們繪製出來。
注意點:
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
基礎
2.1、虛擬DOM
的好處
虛擬DOM
是為了解決瀏覽器效能問題而被設計出來的。如前,若一次操作中有10 次更新DOM
的動作,虛擬DOM
不會立即操作DOM
,而是將這10 次更新的diff
內容儲存到本機一個JS
物件中,最後將這個JS
物件一次attch
到DOM
樹上,再進行後續操作,避免大量無謂的計算量。所以,用JS
物件模擬DOM
節點的好處是,頁面的更新可以先全部反映在JS
物件(虛擬DOM
)上,操作記憶體中的JS
物件的速度顯然要更快,等更新完成後,再將最終的JS
物件對應成真實的DOM
,交由瀏覽器去繪製。
2.2、演算法實作
2.2.1、用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
結構,效果如下圖:
#2.2.2、比較兩棵虛擬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
结构进行更改。
2.2.3、将两个虚拟 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><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
结构进行了预期的变化,如下图所示:
2.3、结语
相关代码实现已经放到 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
源码的解析也按这几个过程来简析。
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 的实现,然后加入了一些 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
的效率
3.1.2、源码创建 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
数组;
3.1.3、实例查看
为了更直观查看我们平时写的 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
表示:
3.2、diff
过程
3.2.1、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
没有,那么就清空这个文本节点。
3.2.2、子节点 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
队列插入队列最后。
3.3、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) }
3.4、总结
通过前三小节简析,我们从主线上把模板和数据如何渲染成最终的 DOM
的过程分析完毕了,我们可以通过下图更直观地看到从初始化 Vue
到最终渲染的整个过程。
四、总结
本文从通过介绍真实 DOM
结构其解析过程以及存在的问题,从而引出为什么需要虚拟 DOM
;然后分析虚拟DOM
的好处,以及其一些理论基础和基础算法的实现;最后根据我们已经掌握的基础知识,再一步步去查看Vue.js
的源码如何实现的。从存在问题 —> 理论基础 —> 具体实践,一步步深入,帮助大家更好的了解什么是Virtual DOM
、为什么需要 Virtual DOM
、以及 Virtual DOM
的具体实现,希望本文对您有帮助。
以上是深入解析Vue中的虛擬DOM的詳細內容。更多資訊請關注PHP中文網其他相關文章!

Vue.js通過多種功能提升用戶體驗:1.響應式系統實現數據即時反饋;2.組件化開發提高代碼復用性;3.VueRouter提供平滑導航;4.動態數據綁定和過渡動畫增強交互效果;5.錯誤處理機制確保用戶反饋;6.性能優化和最佳實踐提升應用性能。

Vue.js在Web開發中的角色是作為一個漸進式JavaScript框架,簡化開發過程並提高效率。 1)它通過響應式數據綁定和組件化開發,使開發者能專注於業務邏輯。 2)Vue.js的工作原理依賴於響應式系統和虛擬DOM,優化性能。 3)實際項目中,使用Vuex管理全局狀態和優化數據響應性是常見實踐。

Vue.js是由尤雨溪在2014年發布的漸進式JavaScript框架,用於構建用戶界面。它的核心優勢包括:1.響應式數據綁定,數據變化自動更新視圖;2.組件化開發,UI可拆分為獨立、可複用的組件。

Netflix使用React作為其前端框架。 1)React的組件化開發模式和強大生態系統是Netflix選擇它的主要原因。 2)通過組件化,Netflix將復雜界面拆分成可管理的小塊,如視頻播放器、推薦列表和用戶評論。 3)React的虛擬DOM和組件生命週期優化了渲染效率和用戶交互管理。

Netflix在前端技術上的選擇主要集中在性能優化、可擴展性和用戶體驗三個方面。 1.性能優化:Netflix選擇React作為主要框架,並開發了SpeedCurve和Boomerang等工具來監控和優化用戶體驗。 2.可擴展性:他們採用微前端架構,將應用拆分為獨立模塊,提高開發效率和系統擴展性。 3.用戶體驗:Netflix使用Material-UI組件庫,通過A/B測試和用戶反饋不斷優化界面,確保一致性和美觀性。

NetflixusesAcustomFrameworkcalled“ Gibbon” BuiltonReact,notReactorVuedIrectly.1)TeamSperience:selectBasedonFamiliarity.2)ProjectComplexity:vueforsimplerprojects:reactforforforproproject,reactforforforcompleplexones.3)cocatizationneedneeds:reactoffipicatizationneedneedneedneedneedneeds:reactoffersizationneedneedneedneedneeds:reactoffersizatization needefersmoreflexibleise.4)

Netflix在框架選擇上主要考慮性能、可擴展性、開發效率、生態系統、技術債務和維護成本。 1.性能與可擴展性:選擇Java和SpringBoot以高效處理海量數據和高並發請求。 2.開發效率與生態系統:使用React提升前端開發效率,利用其豐富的生態系統。 3.技術債務與維護成本:選擇Node.js構建微服務,降低維護成本和技術債務。

Netflix主要使用React作為前端框架,輔以Vue用於特定功能。 1)React的組件化和虛擬DOM提升了Netflix應用的性能和開發效率。 2)Vue在Netflix的內部工具和小型項目中應用,其靈活性和易用性是關鍵。


熱AI工具

Undresser.AI Undress
人工智慧驅動的應用程序,用於創建逼真的裸體照片

AI Clothes Remover
用於從照片中去除衣服的線上人工智慧工具。

Undress AI Tool
免費脫衣圖片

Clothoff.io
AI脫衣器

AI Hentai Generator
免費產生 AI 無盡。

熱門文章

熱工具

MantisBT
Mantis是一個易於部署的基於Web的缺陷追蹤工具,用於幫助產品缺陷追蹤。它需要PHP、MySQL和一個Web伺服器。請查看我們的演示和託管服務。

SublimeText3 Linux新版
SublimeText3 Linux最新版

SublimeText3漢化版
中文版,非常好用

Atom編輯器mac版下載
最受歡迎的的開源編輯器

SublimeText3 Mac版
神級程式碼編輯軟體(SublimeText3)