前提
最近vue官方团队为vue2.x进行了最后一次版本的更新
这是一个vue2与vue3之间的一个过渡版本
这也预示着vue2的在未来很长的一段时间里都不会有大的改动
虽然vue3已经非常优秀了,但vue2的源码依然有值得我们去研究的地方
这里我们浅学一下vue2的双端diff算法
看看vue2里是如何实现Vdom的diff
目标
- 了解该算法的比较过程
- 了解我们在日常开发中应该如何提高diff的效率
文章参考以及示例代码
参考:
霍春阳《vue设计与实现》
演示代码:
vue2.7版本的源码,详细见GitHub
什么是虚拟dom
在如今最热门的几个前端框架中,不管是react还是vue2,或者是vue3 都引入虚拟dom的概念
在这里我们不深入去研究虚拟dom给前端框架带来的优势
只需要明白虚拟dom是什么东西,这是diff算法的一个前置知识
简单来说,虚拟dom就是一个js对象,通常包含tag,props,children三个属性
用来描述一个具体的dom结构
tag为一个dom节点的标签名称,如div,p
props为一个标签的属性
children为该标签节点内的标签
举个例子,如下的dom结构
<div id="app">
<p class="text">hello</p>
<span style="color:red;">vdom</span>
</div>
可以用如下的js对象来描述
{
tag: 'div',
props: {
id: 'app'
},
chidren: [
{
tag: 'p',
props: {
className: 'text'
},
chidren: [
'hello'
]
},{
tag: 'span',
props: {
style: 'color:red;'
},
chidren: [
'vdom'
]
}
]
}
可见 我们可以使用js对象去描述一整个dom树,其中每一个虚拟dom节点,我们称之为vnode
我们的前端框架中的渲染器模块就是利用这个vdom树去进行页面内容的渲染
为何需要diff算法
所谓diff算法,就是用来比较新旧两棵vdom树差异的算法
如果我们页面每一次的变动,都销毁整个dom,并根据新的vdom重新渲染页面
这会是非常大的一笔性能开销,会造成不好的用户体验
所以diff算法应运而生,根据新旧差异来更新视图,减少的dom的操作,把性能压力都放到js的比较计算中
这非常有效地提高页面更新效率
vue2的双端diff算法
在解读vue2的双端diff算法前 我们先抛出一些diff算法的前置知识:
-
两棵vdom树只做同层比较
-
tag的类型变了就不再对比子节点
-
尽可能减少dom的操作次数
我们知道,两棵vdom树,如果旧vdom树中每一个节点都与新vdom比较的话,时间复杂度是O(n2),再加上如果每个节点都进dom的操作那就是O(n3),这对于前端来说,是不可用的算法。
而vue2的双端diff算法中的双端diff算法则是利用了上面的3个关键点,把时间复杂度降低到O(n),使得算法可用。
双端diff算法图解
在看代码前我们先用图解演示一遍双端diff算法的过程
首先我们需要有同层的新旧vdom子节点两组,还需要一组来展示当前真实dom的情况
双端diff的步骤可以分成3步:
- 双端节点比较
- 非常规情况处理
- 边界情况处理
第一步:双端节点比较
双端节点比较,这种情况是比较常规的,我们通过新旧头尾4个节点的互相比较,找到可以复用的节点,判断是否需要进行移动,如下图
可以明显看到新首指针p4与旧尾指针p4是同一个节点,可进行复用,我们操作真实dom把p4移动到p1前面,即可完成这个diff算法更新操作。可以看到真实dom与新vdom已经一致了。
第二步:非常规情况处理
这种情况是在第一步双端节点比较没有符合的情况时进行的
我们以新节点第一个为标准,去旧子节点里面找是否有可以复用的节点,如有则移动,否则创建一个新的插入,如下图:
这里我们直接在旧节点中找到可复用节点p3并移动,最后真实的dom变成了p3->p2->p1->p4
然后指针移动,继续使用第一步中的比较,发现新首指针p2与旧首指针p2是同一个节点,直接原地复用
接着首指针继续移动,这里又遇到了第一步的情况,旧p4是可复用的节点,我们直接移动它到p1上面
到此,结合第二步与第一步的操作,让真实dom节点完成了更新,与新vdom保持了一致
第三步:边界情况处理
这种情况是在第一步和第二步都遍历处理完后,对剩余节点的处理
如果新节点有未创建的节点则创建并插入;如果旧节点有未被复用的节点,则清除
如下图:
经过不断重复第一步和第二步,p4是多余节点,我们移除它
经过不断重复第一步和第二步,p3是新vdom中未创建的节点,我们在最后创建并插入到p4前面
源码中diff算法的位置
我们把源码clone下来后,在以下路径
src\core\vdom\patch.ts
找到我们的patch.ts文件,这是vdom更新的核心模块
我们可以看到,文件里面有很多工具函数,而这个patch.ts文件主要export出一个patch函数,提供给实例进行新旧vdom的diff
我们找到双端diff算法的核心函数updateChildren
完整源码如下
function updateChildren(
parentElm,
oldCh,
newCh,
insertedVnodeQueue,
removeOnly
) {
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
// removeOnly is a special flag used only by <transition-group>
// to ensure removed elements stay in correct relative positions
// during leaving transitions
const canMove = !removeOnly
if (__DEV__) {
checkDuplicateKeys(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(oldCh, oldStartIdx, oldEndIdx)
}
}
接下来我们开始分析这段代码
updateChildren的入参与变量初始化
我们先来看看函数入参的含义
- parentElm 父元素
- oldCh 旧子节点
- newCh 新子节点
- insertedVnodeQueue 记录下所有新插入的节点以备调用(可以不过分关注)
- removeOnly 是仅由使用的特殊标志(可以不过分关注)
再来看看函数开头初始化变量的含义
// 旧头指针索引
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]
// oldKeyToIdx记录节点key与下标的映射关系
// idxInOld记录通过key找到的节点下标
// vnodeToMove记录需要移动的节点
// refElm记录dom节点的引用
let oldKeyToIdx, idxInOld, vnodeToMove, refElm
// 可以进行移动(可以不过分关注)
const canMove = !removeOnly
接下来,我们配合着算法图解去理解代码
双端指针进行比较
根据图解,我们知道接下来要对节点进行双端比较
在代码中,使用了一连串的if-else条件语句完成这部分的比较
if (sameVnode(oldStartVnode, newStartVnode)) {
// 旧头 与 新头 节点比较
// do something...
} else if (sameVnode(oldEndVnode, newEndVnode)) {
// 旧尾 与 新尾 节点比较
// do something...
} else if (sameVnode(oldStartVnode, newEndVnode)) {
// 旧头 与 新尾 节点比较
// do something...
} else if (sameVnode(oldEndVnode, newStartVnode)) {
// 旧尾 与 新头 节点比较
// do something...
}
这里我们看到一个工具函数sameVnode,用于判断这两个节点是否是同一个节点,也是能否复用该节点的标准
function sameVnode(a, b) {
return (
a.key === b.key &&
a.asyncFactory === b.asyncFactory &&
((a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)) ||
(isTrue(a.isAsyncPlaceholder) && isUndef(b.asyncFactory.error)))
)
}
sameVnode内部判断的机制比较复杂,还涉及到input标签的处理等等一些边界条件,我们只需要知道这个sameVnode是作用于两节点比较即可,目前可以简单理解为当 a.key === b.key值与a.tag === b.tag两值相等的时候,sameVnode返回true,注意key 未定义时为undefined
旧头新头比较,旧尾新尾比较
接下来看旧头与新头节点比较,旧尾与新尾节点比较的结果处理
如果旧头与新头节点,旧尾与新尾节点的sameVnode结果为true,证明已经找到了可以复用的节点
看源码中做了什么
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]
}
这里调用了patchVnode函数,这其实是一个递归,由于我们已经找到了可以复用的节点,所以我们得对这两个节点的子节点也进行diff处理,本质上也是调用了updateChildren
然后我们要进行对应的指针的移动
旧头新尾比较,旧尾新头比较
这两个情况与上面的比较类似,当sameVnode为true的时候,进行节点操作
if (sameVnode(oldStartVnode, newStartVnode)) {
// do something...
} else if (sameVnode(oldEndVnode, newEndVnode)) {
// do something...
} 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]
}
与之前两种情况类似,我们依然调用了patchVnode函数进行子节点的diff
与之前两种情况不同的是,旧头新尾比较,旧尾新头比较的节点更新采用的是dom节点移动的方式
nodeOps.insertBefore与nodeOps.nextSibling是vue框架开源给开发者们的一个节点方法的集合
这里抽象出来是为了便于利用vdom进行跨端平台的定制开发,我们可以不过分关注
关于web端的节点操作我们可以看
src\platforms\web\runtime\node-ops.ts
export function insertBefore(
parentNode: Node,
newNode: Node,
referenceNode: Node
) {
parentNode.insertBefore(newNode, referenceNode)
}
export function nextSibling(node: Node) {
return node.nextSibling
}
这里我们只需要知道insertBefore的作用结果即可,即把某个节点插入到指定节点的前面。
在移动完真实节点后,我们同样也要进行指针移动
非常规情况下的处理
我们根据diff图解的方式来分析了源码中4种节点比较情况
接下来继续分析源码是如何处理非常规情况下的节点处理
if {
// do something...
} else {
if (isUndef(oldKeyToIdx))
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
// do something...
}
我们可以看到先调用了createKeyToOldIdx创建了一个以node的key 为key ,索引下标为value的对象,将它保存为oldKeyToIdx
其实这里就是建立一个key和下标之间的映射关系,方便后面的查找
这里有个小细节,代码中if (isUndef(oldKeyToIdx))的情况满足时才建立这个映射关系
即这个映射关系只建立一次,后续节点移动也还是依靠这个来查找节点
function createKeyToOldIdx(children, beginIdx, endIdx) {
let i, key
const map = {}
for (i = beginIdx; i <= endIdx; ++i) {
key = children[i].key
if (isDef(key)) map[key] = i
}
return map
}
接下来就是找到idxInOld,顾名思义就是在找到新的头节点在所有旧子节点中的索引位置
这里有两种情况,使用了一个三目运算符进行区分
-
当节点存在key,则在映射关系oldKeyToIdx中找
-
当节点不存在key,则使用findIdxInOld函数去找到对应的idx
findIdxInOld作用是在未移动的旧节点范围内找到对应的可复用的节点,并返回索引下标
function findIdxInOld(node, oldCh, start, end) {
for (let i = start; i < end; i++) {
const c = oldCh[i]
if (isDef(c) && sameVnode(node, c)) return i
}
}
ok现在我们找到了索引下标
接下来我们来进行移动,新增的操作
if{
// do something...
} 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 {
// do something...
}
newStartVnode = newCh[++newStartIdx]
}
先来看上面的第一种情况,即idxInOld的结果为undefined的情况时,直接调用createElm去创建一个新的dom并插入并插入到oldStartVnode前面
这里我们就不展开讨论createElm
当idxInOld的结果不为undefined时,代码处理如下
if{
// do something...
} 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
// do something...
} 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]
}
这里对idxInOld的结果不为undefined的情况多做了一次sameVnode的判断
先找到需要移动的节点vnodeToMove,然后使用sameVnode让vnodeToMove与newStartVnode进行比较
这里其实主要是比较节点的tag是否相同,因为前面我们是根据key来找到的旧节点,所以key值一定是相同的
- 当tag相同时,复用该旧节点,调用patchVnode与nodeOps.insertBefore进行子节点diff与移动,这里注意oldCh[idxInOld] = undefined需要把原来旧节点的vnode位置设置为undefined,避免数组长度崩塌
- 当tag不同时,直接调用createElm创建新节点,并插入到oldStartVnode前面
最后操作完,需要把newStartIdx指针后移动一位
循环结束条件与边界处理
我们知道双端指针算法肯定需要用到循环,这里就需要设置一个循环结束条件
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 结束条件1:oldStartIdx <= oldEndIdx
// 结束条件2:newStartIdx <= newEndIdx
}
即有两个前后指针重合时,循环结束
我们使用该循环语句包裹上面的diff比较过程,来重复进行diff操作
但循环结束后还是会存在剩余的旧节点
下面来看看如何处理
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(oldCh, oldStartIdx, oldEndIdx)
}
当oldStartIdx > oldEndIdx时,即旧子节点已经复用完,新节点还有未创建的节点,这里调用addVnodes遍历去创建剩余的节点并插入
function addVnodes(
parentElm,
refElm,
vnodes,
startIdx,
endIdx,
insertedVnodeQueue
) {
for (; startIdx <= endIdx; ++startIdx) {
createElm(
vnodes[startIdx],
insertedVnodeQueue,
parentElm,
refElm,
false,
vnodes,
startIdx
)
}
}
当newStartIdx > newEndIdx时,即新子节点已经创建完,旧节点还有剩余未使用的节点,这里调用removeVnodes去删除剩余的节点
function removeVnodes(vnodes, startIdx, endIdx) {
for (; startIdx <= endIdx; ++startIdx) {
const ch = vnodes[startIdx]
if (isDef(ch)) {
if (isDef(ch.tag)) {
removeAndInvokeRemoveHook(ch)
invokeDestroyHook(ch)
} else {
// Text node
removeNode(ch.elm)
}
}
}
}
至此,一个diff算法的代码实现已经完成。
让diff算法更高效
在日常开发中如何编写代码可以让diff算法更高效的运作呢?
我们得合理运用我们的key值
使用v-for时不要使用index当key
为何不要使用index当key呢,因为在日常开发中,我们通常需要对列表数据进行操作,这大概率会影响到一个数组的index值,所以这个index值并不是一个稳定不变的值。
由于index一直在变,在diff算法的可复用节点的比较中,会影响到能否找到真的可以复用的节点,同时也会影响到后续递归的diff过程,这是非常低效的。
不要用随机数作为key
为何不要用随机数作为key呢,具体描述是每次更新时的产生的随机数都不一样,会造成每次diff的key都不一样,根据diff算法中的相同节点判定,key值不同就不是可复用节点,这就会造成每次更新都会创建新的节点,销毁旧的节点,造成diff算法非常低效。