本部分内容是关于如何高效的进行子节点的更新操作相关的算法。
简单 diff 算法
第八章中关于子节点的更新操作用了一种很粗暴的方式,即直接卸载掉旧的节点,再挂载新的节点。这种方式没有考虑 dom 元素的复用,会进行大量的 dom 操作,而 dom 操作是很耗费性能的操作。
简单 diff 算法的核心思路是:找出可以进行复用的元素,然后尽可能地通过 DOM 移动操作来完成更新,避免过多地对 DOM 元素进行销毁和重建。
简单 Diff 算法的核心逻辑是,拿新的一组子节点中的节点去旧的一组子节点中寻找可复用的节点。如果找到了,则记录该节点的位置索引。我们把这个位置索引称为最大索引。在整个更新过程中,如果一个节点的索引值小于最大索引,则说明该节点对应的真实 DOM 元素需要移动。
找出可以进行复用的 dom 元素
这里的实现逻辑是进行两次 for 循环,通过 vnode 上的 key 属性,找出新旧两组节点中相同的节点。
这里的 key 属性就是 v-for 中需要有:key 绑定的原因;
// 遍历新的 children
for (let i = 0; i < newChildren.length; i++) {
const newVNode = newChildren[i]
// 遍历旧的 children
for (let j = 0; j < oldChildren.length; j++) {
const oldVNode = oldChildren[j]
// 如果找到了具有相同 key 值的两个节点,说明可以复用,但仍然需要调用 patch 函数更新
if (newVNode.key === oldVNode.key) {
patch(oldVNode, newVNode, container)
break // 这里需要 break
}
}
}
判断可复用元素是否需要移动
这里我们遍历新节点组时,新节点组中每个节点在旧节点中的索引会形成一个索引数组(可以理解为新节点在移动前的顺序),比如说下边这个例子,新节点遍历后形成的队列为[3,1,2]
,并不是一个递增的队列,而我们最终排序后的队列应该是此时新节点组中节点的顺序,所以肯定是一个递增的队列,所以需要进行移动。
所以可以这样理解,我们 最终需要让这个队列变为递增队列
落实到代码中,我们可以再新节点遍历过程中,记录一个新节点在旧节点中(具有相同 key) 的最大索引lastIndex
,如果在后续查找过程中,存在索引值比当前记录的lastIndex
小,则说明该节点应该在lastIndex
需要移动。
移动元素
在找出了需要移动的元素后,我们需要解决如何移动元素(移动到哪里)。
我们最终元素(更新后的真实 dom 节点)的排序就是根据新节点组中节点的顺序,所以只需要把当前找到的新 children 中需要进行移动的节点p<sub>n</sub>
移动到上一个节点p<sub>n-1</sub>
所对应的真实 dom 的后边。
落实到代码中也很简单:
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
// 省略部分代码
} else if (Array.isArray(n2.children)) {
const oldChildren = n1.children
const newChildren = n2.children
let lastIndex = 0
for (let i = 0; i < newChildren.length; i++) {
const newVNode = newChildren[i]
let j = 0
for (j; j < oldChildren.length; j++) {
const oldVNode = oldChildren[j]
if (newVNode.key === oldVNode.key) {
patch(oldVNode, newVNode, container)
if (j < lastIndex) {
// 代码运行到这里,说明 newVNode 对应的真实 DOM 需要移动
// 先获取 newVNode 的前一个 vnode,即 prevVNode
const prevVNode = newChildren[i - 1]
// 如果 prevVNode 不存在,则说明当前 newVNode 是第一个节点,它不需要移动
if (prevVNode) {
// 由于我们要将 newVNode 对应的真实 DOM 移动到 prevVNode 所对应真实 DOM 后面,
// 所以我们需要获取 prevVNode 所对应真实 DOM 的下一个兄弟节点,并将其作为锚点
const anchor = prevVNode.el.nextSibling
// 调用 insert 方法将 newVNode 对应的真实 DOM 插入到锚点元素前面,
// 也就是 prevVNode 对应真实 DOM 的后面
insert(newVNode.el, container, anchor)
}
} else {
lastIndex = j
}
break
}
}
}
} else {
// 省略部分代码
}
}
找出新增的元素
对于新 children 中需要新增的节点,我们需要解决:
- 找出新增的节点
- 挂载到正确的位置
这两个问题都不难解决,我们在两层 for 循环遍历中,如果 新 children 中的某个节点没有在旧 children 中找到,则说明他是新增的节点newVNode
,我们只需要把他挂载到newVNode
的前一个虚拟节点prevVNode
所对应的真实 DOM 的后边。
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
// 省略部分代码
} else if (Array.isArray(n2.children)) {
const oldChildren = n1.children
const newChildren = n2.children
let lastIndex = 0
for (let i = 0; i < newChildren.length; i++) {
const newVNode = newChildren[i]
let j = 0
// 在第一层循环中定义变量 find,代表是否在旧的一组子节点中找到可复用的节点,
// 初始值为 false,代表没找到
let find = false
for (j; j < oldChildren.length; j++) {
const oldVNode = oldChildren[j]
if (newVNode.key === oldVNode.key) {
// 一旦找到可复用的节点,则将变量 find 的值设为 true
find = true
patch(oldVNode, newVNode, container)
if (j < lastIndex) {
const prevVNode = newChildren[i - 1]
if (prevVNode) {
const anchor = prevVNode.el.nextSibling
insert(newVNode.el, container, anchor)
}
} else {
lastIndex = j
}
break
}
}
// 如果代码运行到这里,find 仍然为 false,
// 说明当前 newVNode 没有在旧的一组子节点中找到可复用的节点
// 也就是说,当前 newVNode 是新增节点,需要挂载
if (!find) {
// 为了将节点挂载到正确位置,我们需要先获取锚点元素
// 首先获取当前 newVNode 的前一个 vnode 节点
const prevVNode = newChildren[i - 1]
let anchor = null
if (prevVNode) {
// 如果有前一个 vnode 节点,则使用它的下一个兄弟节点作为锚点元素
anchor = prevVNode.el.nextSibling
} else {
// 如果没有前一个 vnode 节点,说明即将挂载的新节点是第一个子节点
// 这时我们使用容器元素的 firstChild 作为锚点
anchor = container.firstChild
}
// 挂载 newVNode
patch(null, newVNode, container, anchor)
daima
代码中,我们使用了find
变量来表示是否在旧节点中找到了相同 key 的节点,如果没有找到,说明是新增节点,则进入另一段逻辑:将新增节点挂载到正确位置。
找出需要删除的元素
也有可能存在新 children 中没有,而旧 children 中有,也就是需要删除节点的情况。
这种情况处理和新增元素的处理办法基本相同。
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
// 省略部分代码
} else if (Array.isArray(n2.children)) {
const oldChildren = n1.children
const newChildren = n2.children
let lastIndex = 0
for (let i = 0; i < newChildren.length; i++) {
// 省略部分代码
}
// 上一步的更新操作完成后
// 遍历旧的一组子节点
for (let i = 0; i < oldChildren.length; i++) {
const oldVNode = oldChildren[i]
// 拿旧子节点 oldVNode 去新的一组子节点中寻找具有相同 key 值的节点
const has = newChildren.find(
vnode => vnode.key === oldVNode.key
)
if (!has) {
// 如果没有找到具有相同 key 值的节点,则说明需要删除该节点
// 调用 unmount 函数将其卸载
unmount(oldVNode)
}
}
} else {
// 省略部分代码
}
}
TODO: 双端 diff
简单 diff 算法对于 dom 的移动操作并不是最优的,比如说对于下边这种情况来说,简单 diff 算法需要进行两次 dom 移动:
- 第一次移动
p-1
移动到p-3
后边 - 第二次移动
p-2
移动到p-3
后边
而我们可以发现,其实只需要直接将 p-3
移动到p-1
的前边就可以达到目的了。
那么双端 diff 算法是怎么做的呢?为什么可以解决这个问题?
双端 diff 算法是从两端进行比较,我们指定四个索引值分别指向新旧两组 vnode 的头尾元素,然后我们按照下图中每轮四步走的顺序进行比较,
① 新子节点的头部节点newStartIdx
跟旧子节点中的头部节点oldStartIdx
比较,如果两者 key 相同,说明是同一个节点,