Vue
的 Virtual DOM
和 Diff
算法是其核心特性之一,也是面试的热点话题之一。它们使得 Vue
能够高效地更新视图。在本文中,我们将深入探讨 Vue
源码中的 Diff
算法,了解它是如何工作的以及它对 Vue
应用的性能优化有何影响。
流程讲解
在 Vue
中,当数据发生变化的时候,会触发渲染 watcher
的回调函数(updateComponent
),进而执行组件的更新过程(在本章我们无需关注如何触发更新,只需知道此时在更新组件,以及更新所调用的方法) 在之前的实例挂载章节提到过,当 Vue
挂载的时候,会初始化渲染 watcher
,并传入更新方法 updateComponent
在之前的依赖收集章节提到过,当更新视图时,会调用更新方法,即updateComponent
=> vm._update
方法
// src\core\instance\lifecycle.js
function mountComponent (vm, el, hydrating) {
...
var updateComponent = function () { // 更新组件
vm._update(vm._render(), hydrating);
};
// 实例化渲染 Watcher (传入 updateComponent函数, 在更新时调用)
new Watcher(vm, updateComponent, noop, {
before: function before () {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate');
}
}
}, true /* isRenderWatcher */);
}
组件的更新调用了 vm._update
方法,而更新的核心方法便是 vm.__patch__(prevVnode, vnode)
这一步,而 __patch__
方法的本质就是 patch
方法 此方法在初次渲染时,将虚拟节点保存到 _vnode
上,作为是否初次渲染的标识
// src\core\instance\lifecycle.js
Vue.prototype.__patch__ = patch;
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
var vm = this;
...
const prevVnode = vm._vnode
vm._vnode = vnode;
if (!prevVnode) { // 初始渲染
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else { // 更新渲染
vm.$el = vm.__patch__(prevVnode, vnode)
}
...
}
createPatchFunction方法(patch方法的创建)
以下是创建 patch
函数的过程,通过执行 createPatchFunction
得到德返回值便是 patch
函数,其中还包括许多更新过程中需要用到的方法
// src\core\vdom\patch.js
function createPatchFunction (backend) {
function patchVnode() { ... }
return function patch (oldVnode, vnode, hydrating, removeOnly) { ... }
}
patch方法
现在单独分析 patch
方法的过程 首先,通常我们进行对比,会采用循环嵌套的方式去遍历,复杂度较高。而 Diff
算法的整体策略:深度优先算法,同层比较,不会跨层级比较,时间复杂度是 O(n)
然后,我们分析代码,在 patch
方法中会出现以下两种情况(此处不分析组件):
-
旧节点为虚拟节点且与新节点相同
当新旧节点相同时,使用
Diff
算法更新,核心便是调用patchVnode
方法需要注意的是判断新旧节点相等的方法
sameVnode
的判断依据是节点的tag
(标签名)和key
(节点标识)相等。当key
值都不存在时,则都为undefined
,即为相等 -
旧节点为虚拟节点且新旧节点不相同 或 旧节点为真实节点
- 新旧节点不同时,由于
Diff
算法只会同层级比较,所以创建新节点并销毁旧节点 - 旧节点为真实节点,则创建一个空节点(虚拟节点)替换它,再进行创建销毁操作
// src\core\vdom\patch.js function patch (oldVnode, vnode, hydrating, removeOnly) { if (isUndef(oldVnode)) { ... } else { var isRealElement = isDef(oldVnode.nodeType); // 是否为真实节点 // 1. 旧节点为虚拟节点且新旧节点相同 (使用Diff算法更新) if (!isRealElement && sameVnode(oldVnode, vnode)) { patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly); // 2. 旧节点为虚拟节点且新旧节点不相同 (创建并插入新节点, 移除旧节点) } else { var oldElm = oldVnode.elm; // 旧节点的真实DOM var parentElm = nodeOps.parentNode(oldElm); // 旧节点的父节点 createElm( // 创建新节点并插入父级节点 vnode, insertedVnodeQueue, oldElm._leaveCb ? null : parentElm, nodeOps.nextSibling(oldElm) ); ... if (isDef(parentElm)) { // 销毁旧节点 removeVnodes([oldVnode], 0, 0); } } } return vnode.elm }
- 新旧节点不同时,由于
patchVnode方法
然后我们紧接着看 patchVnode
方法,这里主要会做以下三步处理:静态节点的处理、节点属性的处理以及对子节点的处理,我们接下来一一分析
// src\core\vdom\patch.js
function patchVnode (oldVnode, vnode ...) {
...
var elm = vnode.elm = oldVnode.elm;
// 1. 静态节点的处理
// 2. 节点属性的处理
// 3. 对子节点的处理
}
静态节点的处理
新旧虚拟节点如果为静态节点,就不需要进行更新操作,直接跳过更新比对的过程
// isStatic: 是否是静态节点
// isCloned: 是否是克隆节点
// isOnce: 是否使用 v-once 指令 (仅初渲染, 不随数据变化而变化)
if (isTrue(vnode.isStatic) &&
isTrue(oldVnode.isStatic) &&
vnode.key === oldVnode.key &&
(isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
) {
vnode.componentInstance = oldVnode.componentInstance;
return
}
静态节点的判断方法 isStatic
,判断依据如下
- 文本类型节点
- 使用
v-pre
指令和pre
标签(跳过元素和其子元素的编译过程) - 其他节点:
- 非动态绑定
- 不含
v-if
,v-for
,v-else
指令 - 非
slot
,component
标签的节点 - 是否为符合当前平台的标签(例:
web
平台可使用div
,span
,select
等) - 静态节点的父节点, 不能为使用
v-for
指令的template
标签 - 节点是否包含静态属性(
type
,tag
,attrsList
等)
// vue\compiler\optimizer.js
function isStatic (node) {
if (node.type === 2) { // 表达式
return false
}
if (node.type === 3) { // 文本节点
return true
}
return !!(node.pre || (
!node.hasBindings && // 非动态绑定
!node.if && !node.for && // 不含v-if v-for v-else指令
!isBuiltInTag(node.tag) && // 非slot component标签
isPlatformReservedTag(node.tag) && // 是否为符合当前平台的标签
!isDirectChildOfTemplateFor(node) && // 祖先节点不能为使用 v-for指令的 template标签
Object.keys(node).every(isStaticKey) // 节点是否包含静态属性
))
}
节点属性的处理
通过遍历cbs.update
方法,调用对属性的处理方法对属性进行操作,分别有以下几种处理函数:
updateAttrs
:更新attr
属性updateClass
:更新class
属性updateDOMListeners
:更新绑定事件属性updateDOMProps
:更新props
属性updateStyle
:更新style
属性updateDrectives
:更新指令Drectives
属性
var oldCh = oldVnode.children;
var ch = vnode.children;
if (isDef(data) && isPatchable(vnode)) {
for (i = 0; i < cbs.update.length; ++i) {
cbs.update[i](oldVnode, vnode "i");
}
}
而变量 cbs
的定义过程如下,是包含以生命周期为键名,属性的处理函数集合为键值的对象
// src\platforms\web\runtime\modules\attrs.js
// src\platforms\web\runtime\modules\class.js
function updateAttrs (oldVnode, vnode) {}
function updateClass (oldVnode, vnode) {}
var attrs = {
create: updateAttrs,
update: updateAttrs
};
var klass = {
create: updateClass,
update: updateClass
};
...
var platformModules = [ // 处理方法
attrs,
klass,
events,
domProps,
style,
transition
];
var modules = platformModules.concat(baseModules);
var hooks = ['create', 'activate', 'update', 'remove', 'destroy']; // 生命周期
var patch = createPatchFunction({ nodeOps: nodeOps, modules: modules });
function createPatchFunction (backend) {
var i, j;
var cbs = {};
var modules = backend.modules;
for (i = 0; i < hooks.length; ++i) { // 在不同生命周期下对属性的处理函数
cbs[hooks[i]] = [];
for (j = 0; j < modules.length; ++j) {
if (isDef(modules[j][hooks[i]])) {
cbs[hooks[i]].push(modules[j][hooks[i]]);
}
}
}
...
return return function patch (oldVnode, vnode, hydrating, removeOnly) {}
}
经过处理后的cbs
如下
此处重点为
Diff
算法,属性的更新只讲解流程,具体处理地址: https://editor.csdn.net/md/?articleId=135758556
子节点的处理(重点)
此时首先会通过定义 text
属性来判断是否为文本节点
- 新节点非文本节点的情况
- 新旧节点都有子节点且不相同,则调用
updateChildren
方法比较更新子节点(重点) - 新节点有子节点,旧节点无子节点。说明更新后多了子节点,则创建新
DOM
添加到父节点 - 旧节点有子节点,新节点无子节点。说明更新后旧节点的子节点不存在,则移除子节点
- 旧节点为文本节点,则将
DOM
的文本内容设置为空
- 新节点为文本节点的情况
-
新旧节点都为文本节点,但文本内容不一样,则将
DOM
的文本内容更新为新节点的文本内容if (isUndef(vnode.text)) { // 新节点非文本节点 if (isDef(oldCh) && isDef(ch)) { // 新旧节点都有子节点 if (oldCh !== ch) { updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly); // 比较更新子节点 } } else if (isDef(ch)) { // 新节点有子节点 if (isDef(oldVnode.text)) { nodeOps.setTextContent(elm, ''); } addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue); } else if (isDef(oldCh)) { // 旧节点有子节点 removeVnodes(oldCh, 0, oldCh.length - 1); } else if (isDef(oldVnode.text)) { // 旧节点为文本节点 nodeOps.setTextContent(elm, ''); } } else if (oldVnode.text !== vnode.text) { // 新旧节点都为文本节点且文本不一样 nodeOps.setTextContent(elm, vnode.text); }
updateChildren方法
这是 Diff
算法的核心,新旧虚拟节点的子节点对比,采用了双端指针 Diff
算法。对比的是虚拟 DOM
,但是移动、删除、插入操作的是真实 DOM
// src\core\vdom\patch.js
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
var oldStartIdx = 0; // 旧节点的头指针
var newStartIdx = 0; // 新节点的头指针
var oldEndIdx = oldCh.length - 1; // 旧节点的尾指针
var oldStartVnode = oldCh[0]; // 旧节点的头节点
var oldEndVnode = oldCh[oldEndIdx]; // 旧节点的尾节点
var newEndIdx = newCh.length - 1; // 新节点的尾指针
var newStartVnode = newCh[0]; // 新节点的头节点
var newEndVnode = newCh[newEndIdx]; // 新节点的尾节点
var oldKeyToIdx, idxInOld, vnodeToMove, refElm;
var canMove = !removeOnly;
...
// 双端指针Diff
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 如果旧节点的头节点为空, 说明该节点已经在新节点列表中找到了位置, 需要将旧节点的头指针向后移动
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx];
// 如果旧节点的尾节点为空, 说明该节点已经在新节点列表中找到了位置, 需要将旧节点的尾指针向前移动
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx];
// 1. 旧节点的头节点与新节点的头节点进行比较
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);
oldStartVnode = oldCh[++oldStartIdx]; // 移动旧节点的头指针以及更新旧节点的头节点
newStartVnode = newCh[++newStartIdx]; // 移动新节点的头指针以及更新新节点的头节点
// 2. 旧节点的尾节点与新节点的尾节点进行比较
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx);
oldEndVnode = oldCh[--oldEndIdx]; // 移动旧节点的尾指针以及更新旧节点的尾节点
newEndVnode = newCh[--newEndIdx]; // 移动新节点的尾指针以及更新新节点的尾节点
// 3. 旧节点的头节点与新节点的尾节点进行比较
} else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx);
// 将旧节点的头节点移动到旧节点的尾节点之后
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm));
oldStartVnode = oldCh[++oldStartIdx]; // 移动旧节点的头指针以及更新旧节点的头节点
newEndVnode = newCh[--newEndIdx]; // 移动新节点的尾指针以及更新新节点的尾节点
// 4. 旧节点的尾节点与新节点的头节点进行比较
} else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);
// 将旧节点的尾节点移动到旧节点的头节点之前
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx]; // 移动旧节点的尾指针以及更新旧节点的尾节点
newStartVnode = newCh[++newStartIdx]; // 移动新节点的头指针以及更新新节点的头节点
// 5. 暴力对比
} else {
/* 生成旧节点映射表, 根据新节点的 key 查找对应的旧节点在旧节点列表中的索引 */
if (isUndef(oldKeyToIdx)) { oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx); }
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
// 5.1 如果在映射表中找不到对应的索引, 则说明这是一个新节点, 需要创建
if (isUndef(idxInOld)) {
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx);
// 5.2 在映射表中找到了对应的旧节点, 尝试复用或者移动
} else {
vnodeToMove = oldCh[idxInOld];
// 5.2.1 如果找到的旧节点和新节点是同一个节点, 则尝试复用
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);
oldCh[idxInOld] = undefined; // 将旧节点置为 undefined, 表示已经被复用
// 将复用的节点移动到旧节点的头节点之前
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm);
// 5.2.2 如果 key 相同但节点不同, 按照新节点处理, 创建新节点并插入父级节点
} else {
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx);
}
}
newStartVnode = newCh[++newStartIdx]; // 移动新节点的头指针以及更新新节点的头节点
}
}
if (oldStartIdx > oldEndIdx) { // 当新节点有剩余元素, 则插入
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue); // 插入DOM元素
} else if (newStartIdx > newEndIdx) { // 当旧节点有剩余元素, 则删除
removeVnodes(oldCh, oldStartIdx, oldEndIdx); // 删除DOM元素
}
}
while
循环分别处理了以下5种情况:分别是节点的新前与旧前、新后与旧后、新后与旧前、新前与旧后以及暴力对比while
循环结束的条件是当其中一个数组遍历完 startIdx < endIdx
,则结束遍历。在循环的过程中,指针不断从两边向中间收拢 当循环结束后,还可能出现剩余节点的情况,对剩余节点的2种情况分别进行了处理
- 新前:新节点中头指针指向的节点
- 旧前:旧节点中头指针指向的节点
- 新后:新节点中尾指针指向的节点
- 旧后:旧节点中尾指针指向的节点
最开始未处理的旧节点对应的就是真实
DOM
的情况
新前与旧前
- 如果新旧节点相同,则不需操作真实
DOM
。新老节点的头指针向后移动 - 如果不同,则继续进行新后与旧后的比较
新后与旧后
- 如果新旧节点相同,则不需操作真实
DOM
。新老节点的尾指针向前移动 - 如果不同,则继续进行新后与旧前的比较
新后与旧前
- 如果新旧节点相同,则操作真实
DOM
,将旧节点对应的真实DOM
插入到旧节点的结束节点对应的真实DOM
的后面。然后新节点的尾指针向前移动,旧节点的头指针向后移动 - 如果不同,则继续进行新前与旧后的比较
新前与旧后
- 如果新旧节点相同,则操作真实
DOM
,将旧节点对应的真实DOM
插入到旧节点的开始节点对应的真实DOM
的前面。然后新节点的头指针向后移动,旧节点的尾指针向前移动 - 如果不同,则继续进行新前与旧后的比较
暴力对比
当以上四种对比均为成功,则进行暴力对比
首先生成一个节点 key
值映射到节点下标 key => index
的映射表。然后用新节点的开始节点的 key
,去映射表找对应映射关系,此时会出现两种情况:
let oldCh = [{
tag:"div", key: 1
},{
tag:"span", key: 3
},{
tag:"div", key: 5
}];
// 映射表 key => index
let oldKeyToIdx = {
1: 0,
3: 1,
5: 2
};
-
如果在映射表中找到,就把新节点对应的真实
DOM
插入旧节点的开始节点对应的真实DOM
的前面,旧节点原来的位置用undefined
占位,避免破坏映射表的位置。旧节点的头指针向后移动 -
如果在映射表中没有找到,就为新节点创建真实
DOM
并插入旧节点的开始节点对应的真实DOM
的前面。旧节点的头指针向后移动疑问解答
-
对比后调用
patchVnode
的原因调用
patchVnode
是继续处理它们的子节点,或更新文本 -
节点是如何移动的
简化后的源码是这样的,通过插入到头指针指向的节点之前完成移动操作
parentElm.insertBefore( oldStartVnode.elm, oldEndVnode.elm.nextSibling )
新节点有剩余
旧节点遍历完,新节点可能还有剩余。就为剩余新节点创建真实
DOM
并插入旧节点的结束节点对应的真实DOM
的后面旧节点有剩余
新节点遍历完,旧节点可能还有剩余。则遍历剩下的旧节点,删除旧节点对应的真实
DOM
总结
-
Diff 算法是什么?
Diff
算法是Virtual DOM
在更新过程中的核心部分,它用于比较新旧Virtual DOM
树,找出变化并更新到真实DOM
上 -
Vue 中的 Diff 算法实现原理
Vue
中的Diff
算法采用了双端比较的策略,将新旧节点逐个比较,以最小化操作数。同时,它通过使用Key
来优化对列表的更新 -
Diff 算法的优化
Vue
中的Diff
算法经过了多次优化,包括同级比较、元素移动、新增和删除等方面的优化,以提高更新效率 -
性能优化和注意事项
- 合理使用 Key 可以大大提高 Diff 算法的效率,避免出现不必要的重新渲染
- 避免过度深度的
Virtual DOM
可以减少Diff
算法的复杂度 - 异步更新可以优化渲染性能,避免阻塞主线程
-
Diff 算法的重要性
Diff
算法是Vue
高效更新视图的关键,它使得Vue
应用能够在数据变化时快速响应,提升了用户体验。
总的来说,
Vue
源码中的Diff
算法是Vue.js
的核心特性之一,它为我们提供了一种高效的方式来管理和更新DOM
。了解Diff
算法的原理和优化策略,对于我们更好地使用Vue.js
构建应用和进行性能优化是非常有帮助的 -