snabbdom的diff算法
1、首先阐述个人对diff算法的理解:
diff算法是==比较两个vnode(虚拟dom)的不同,将其中旧vnode所描述真实dom更新成新vnode所描述的样子==,目的是减少创建dom所进行的开销。
==为什么这么做?==
1、 首先知道 在一个组件里如果涉及到更新页面数据的时候, 我们最简单的方法当然是直接替换dom元素。在写jQuery年代大家都是如此做的。
2、 其次,明白vue是响应式,我们写vue的数据一旦做出修改,会触发页面修改。我们能想到最理想的方式当然是哪里修改了,可以精确定位到改哪里。但是我们无法做到精确定位到你想修改的地方。
==如果是我的话,我会怎么做?== 也可以理解为没有vnode,我们会怎么做。
我能想到笨方法当然是 根据template重新创建新dom 进行整体替换。 这样当然可以实现效果,但是当组件足够大,而你改动内容却很少,那么进行的创建开销就很大了。 如果你改动内容多,那你的创建内容可能确实需要那么大开销,但是大多数时候,我们修改的内容可能只是一个span内容,一个变量。
==3、 引入diff后呢,我们得到了什么。
==
diff 实际上比较新旧vnode,将旧vnode所描述的dom树更新成新vnode所描述的样子,当改动的内容足够少,那么对dom操作肯定也足够小。当然这其中也涉及到了如果改动特别那么大,我们可能白白浪费时间diff两个vnode那么久,还不如创建新dom来的划算。这里既然引入了这个算法,那么官方肯定是认定我们平常写代码,对dom操作更新的内容没有那么多。
结论: 也就是当修改dom内容的时候引入diff可以减少创建dom的时间开销
2、diff过程理解
- 我们更新dom会更新什么,web平台上如果标签(div、span等)相同我们需要比较属性、class、style、监听事件,如果标签不一样,那么就是需要重新创建dom。
- 根据内容 diff 会走三步 patch => patchVnode => updateChildren,我梳理了一下每步做的事情
3、以下是对查看下面代码的建议:
1、一般diff算法过程是 patch => patchVnode => updateChildren,无论是snabbdom还是vue的源码都是这样的。所以学习的的时候可以先参考snabbdom做学习,直接看vue可能看不懂,毕竟vue涉及的很多钩子的执行,可能让你看不懂
2、建议理解代码的时候把代码沾到vscode在看,在markdown下看其实不是很美观
patch方法:
==patch (oldVnode, vnode)== 很容易看出是需要传入旧vnode和新vnode
该方法为起始方法,也为diff方法,怎么理解,初始化的时候我们只有一个vnode,那么只需要根据vnode直接创建dom树然后进行挂载就行了,此时oldvnode传进去的可能只是div标签,然后再这个标签内容上进行插入操作
那如果是diff 的时候 那就是需要比较了,如何判断是创建dom还是更新dom只需要比较 它们的标签是否相同
相同会走到下一步patchVnode
function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
let i: number, elm: Node, parent: Node
const insertedVnodeQueue: VNodeQueue = [] // 其实储存起来有从来执行mounted钩子的,这样才能保证子组件的挂载先执行
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]() // 钩子执行忽略
if (!isVnode(oldVnode)) { // 初始化时是dom元素而不是vnode
oldVnode = emptyNodeAt(oldVnode) // 旧节点非vnode,创建
}
if (sameVnode(oldVnode, vnode)) { // key值和ele值相同可以直接patchVnode
patchVnode(oldVnode, vnode, insertedVnodeQueue)
} else {
elm = oldVnode.elm! // ! typescript的断言 表示必存在
parent = api.parentNode(elm) as Node // 获取父的dom
createElm(vnode, insertedVnodeQueue) // 根据vnode创建dom
if (parent !== null) {
api.insertBefore(parent, vnode.elm!, api.nextSibling(elm)) // 插入
removeVnodes(parent, [oldVnode], 0, 0) // 移除旧元素
}
}
for (i = 0; i < insertedVnodeQueue.length; ++i) { // 执行钩子 忽视别看
insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i])
}
for (i = 0; i < cbs.post.length; ++i) cbs.post[i]()
return vnode
}
// 比较key值 和 标签
// 如果没有key值 undefined === undefined 为true
// 一般只有for循环才会指定key值
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)
) || (
isTrue(a.isAsyncPlaceholder) &&
a.asyncFactory === b.asyncFactory &&
isUndef(b.asyncFactory.error)
)
)
)
}
patchVnode
- 当前标签进行属性更新、事件更新等
- 如果双方有children则进行 updateChildren 方法。(毕竟子dom一多,比较其他麻烦)
- 否则一方没有孩子,只是进行直接创建,或者删除dom的操作,又或者是文本更新
function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
const hook = vnode.data?.hook
hook?.prepatch?.(oldVnode, vnode)
const elm = vnode.elm = oldVnode.elm!
const oldCh = oldVnode.children as VNode[] // 旧孩子
const ch = vnode.children as VNode[] // 新孩子
if (oldVnode === vnode) return // 相同返回
if (vnode.data !== undefined) { // 更新属性
for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
vnode.data.hook?.update?.(oldVnode, vnode)
}
if (isUndef(vnode.text)) { // vnode非文本元素
if (isDef(oldCh) && isDef(ch)) { // 都有children,只有非文本元素才有
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue) // 更新children
} else if (isDef(ch)) { // 只有新vnode有children
if (isDef(oldVnode.text)) api.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) { // 只有旧vnode有children
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) { // 都没有children,设置dom内容为空文本
api.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) { // vnode是文本元素
if (isDef(oldCh)) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1) // 删除dom上的children
}
api.setTextContent(elm, vnode.text!) // 设置文本内容
}
hook?.postpatch?.(oldVnode, vnode)
}
updatechildren
1、updatechildren 理解比较吃力,我建议去看个up主视频, 写起来麻烦 bilibili: (完整版)快速掌握虚拟DOM和diff算法【Vue】
2、但总体理解上就是将dom树更新成新vnode的样子,因为可能是一样的内容不做处理,也可能是位置变更,我们做移动操作,新增元素做创建,不存在元素做删除。
3、vue2在diff上原理上是一样,vue3在children上的diff比较有大的差别,但这里据说vue2是参考snabbdom写的,实际上确实很相似,但经常听同事说vue2引入snabbdom去做的diff,那就是错误的了。
function updateChildren (parentElm: Node,
oldCh: VNode[],
newCh: VNode[],
insertedVnodeQueue: VNodeQueue) {
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: KeyToIndexMap | undefined
let idxInOld: number
let elmToMove: VNode
let before: any
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVnode == null) {
oldStartVnode = oldCh[++oldStartIdx] // Vnode might have been moved left
} else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (newStartVnode == null) {
newStartVnode = newCh[++newStartIdx]
} else if (newEndVnode == null) {
newEndVnode = newCh[--newEndIdx]
// 如果具有相同的tag和key 开始和开始
} else if (sameVnode(oldStartVnode, newStartVnode)) {
//递归调整之下的节点
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
//结尾和结尾
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
// 开始和结尾
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
api.insertBefore(parentElm, oldStartVnode.elm!, api.nextSibling(oldEndVnode.elm!))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
}
idxInOld = oldKeyToIdx[newStartVnode.key as string]
if (isUndef(idxInOld)) { // New element // 创建新的节点插到老节点起始的前面
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
} else {
elmToMove = oldCh[idxInOld]
if (elmToMove.sel !== newStartVnode.sel) {
//如果tab不一样 一样插到老节点起始的前面
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
} else {
// 递归将节点调整成一样
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
oldCh[idxInOld] = undefined as any // 将原来的置为 undefined
// 将节点插入
// 将该节点节点插到老节点起始的前面
api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!)
}
}
// 只移动新结点的开始
newStartVnode = newCh[++newStartIdx]
}
}
if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
if (oldStartIdx > oldEndIdx) {
// 旧的节点遍历完了加入新vode数组剩余的
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
} else {
// 新节点遍历完了移除旧的
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}
}
}