理解之Vue diff
背景
我们都知道,Vue的核心是双向绑定和虚拟DOM。浏览器的DOM 有Node, vDom 也有 vnode,vnode的elm属性可以访问实际Node。
Vue 怎样高效的将vdom的变更转换成DOM操作呢?—diff算法。
举栗
拿最简单的列表说起
<ul>
<li>1</li>
<li>2</li>
</ul>
那么 它的vnode 大概是这样
{
tag: 'ul',
children: [
{ tag: 'li', children: [ { vnode: { text: '1' }}] },
{ tag: 'li', children: [ { vnode: { text: '2' }}] },
]
}
假设我们把子节点顺序调换,更新:
{
tag: 'ul',
children: [
+ { tag: 'li', children: [ { vnode: { text: '2' }}] },
+ { tag: 'li', children: [ { vnode: { text: '1' }}] },
]
}
此时,要开始diff 了
1,数据更新
→ 触发了 渲染 Watcher 的回调函数 vm._update(vm._render())去驱动视图更新,
vm._render() 其实生成的就是 vnode,而 vm._update 就会带着新的 vnode 去走触发 patch 过程。
我们直接进入 ul 这个 vnode 的 patch 过程。
2.patch新旧节点
2.1 , 不是相同节点
直接销毁旧的 vnode,渲染新的 vnode。
2.2, 是相同节点----尽可能的做节点的复用
调用src/core/vdom/patch.js下的patchVNode方法:
patchVnode (oldVnode, vnode) {
const el = vnode.el = oldVnode.el
let i, oldCh = oldVnode.children, ch = vnode.children
if (oldVnode === vnode) return // 两个节点指向的对象相同直接返回
if (oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text) {
api.setTextContent(el, vnode.text) // 两个节点都有文本,而且不相同,直接替换
}else {
updateEle(el, vnode, oldVnode)
if (oldCh && ch && oldCh !== ch) {
updateChildren(el, oldCh, ch) // 两个节点都有子节点,进一步判断如何更新子节点
}else if (ch){
createEle(vnode) //create el's children dom
}else if (oldCh){
api.removeChildren(el)
}
}
}
新 vnode 是文字 vnode
就直接调用浏览器的 dom api 把节点的直接替换掉文字内容就好。
如果新 vnode 不是文字 vnode
如果有新 children 而没有旧 children
说明是新增 children,直接 addVnodes 添加新子节点。
如果有旧 children 而没有新 children
说明是删除 children,直接 removeVnodes 删除旧子节点。
如果新旧 children 都存在
那么就是我们 diff算法 想要考察的最核心的点了,也就是新旧节点的 diff 过程。
// 旧首节点
let oldStartIdx = 0
// 新首节点
let newStartIdx = 0
// 旧尾节点
let oldEndIdx = oldCh.length - 1
// 新尾节点
let newEndIdx = newCh.length - 1
这些变量分别指向旧节点的首尾、新节点的首尾。
根据这些指针,在一个 while 循环中不停的对新旧节点的两端的进行对比 (根据key 判断是否是 sameVnode),直到没有节点可以对比。
然后我们接着进入 diff 过程,每一轮都是同样的对比,其中某一项命中了,就递归的进入 patchVnode 针对单个 vnode 进行的过程(如果这个 vnode 又有 children,那么还会来到这个 diff children 的过程 :
旧首节点和新首节点用 sameNode 对比。
旧尾节点和新首节点用 sameNode 对比
旧首节点和新尾节点用 sameNode 对比
旧尾节点和新尾节点用 sameNode 对比
如果以上逻辑都匹配不到,再把所有旧子节点的 key 做一个映射表,然后用新 vnode 的 key 去找出在旧节点中可以复用的位置。
然后不停的把匹配到的指针向内部收缩,直到新旧节点有一端的指针相遇(说明这个端的节点都被patch过了)。
在指针相遇以后,还有两种比较特殊的情况:
1)有新节点需要加入。如果更新完以后,oldStartIdx > oldEndIdx,说明旧节点都被 patch 完了,但是有可能还有新的节点没有被处理到。接着会去判断是否要新增子节点。
2)有旧节点需要删除。如果新节点先patch完了,那么此时会走 newStartIdx > newEndIdx 的逻辑,那么就会去删除多余的旧子节点。
附此部分源码:
updateChildren (parentElm, oldCh, newCh) {
let oldStartIdx = 0, 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
let idxInOld
let elmToMove
let before
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVnode == null) { // 对于vnode.key的比较,会把oldVnode = null
oldStartVnode = oldCh[++oldStartIdx]
}else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEndIdx]
}else if (newStartVnode == null) {
newStartVnode = newCh[++newStartIdx]
}else if (newEndVnode == null) {
newEndVnode = newCh[--newEndIdx]
}else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
}else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
}else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode)
api.insertBefore(parentElm, oldStartVnode.el, api.nextSibling(oldEndVnode.el))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
}else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode)
api.insertBefore(parentElm, oldEndVnode.el, oldStartVnode.el)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
}else {
// 使用key时的比较
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 有key生成index表
}
idxInOld = oldKeyToIdx[newStartVnode.key]
if (!idxInOld) {
api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
newStartVnode = newCh[++newStartIdx]
}
else {
elmToMove = oldCh[idxInOld]
if (elmToMove.sel !== newStartVnode.sel) {
api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
}else {
patchVnode(elmToMove, newStartVnode)
oldCh[idxInOld] = null
api.insertBefore(parentElm, elmToMove.el, oldStartVnode.el)
}
newStartVnode = newCh[++newStartIdx]
}
}
}
if (oldStartIdx > oldEndIdx) {
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
}else if (newStartIdx > newEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}
}