diff 算法的目的是根据 key 复用 dom 节点,通过移动节点而不是创建新节点来减少 dom 操作。
由上一节可以看到我们只做同层的对比,type 变了就不再对比子节点
key
属性就像虚拟节点的"身份证"号,只要两个虚拟节点的 type 属性值和 key 属性值都相同,那么我们就认为它们是相同的,即可以进行 DOM 的复用
js
let vnode = reactive({
type: 'div',
children: [
{ type: 'p', children: '1', key: 1 },
{ type: 'p', children: '2', key: 2 },
{ type: 'p', children: '3', key: 3 }
]
})
vnode.children =
[
{ type: 'p', children: '10', key: 1 },
{ type: 'p', children: '20', key: 2 },
{ type: 'p', children: '30', key: 3 }
]
先从最简单的情况开始(只修改内容),只需要使用双层for循环找出key相同的替换既可
js
if (typeof n2.children === 'string') {
// 省略部分代码
} else if (Array.isArray(n2.children)) {
const oldChildren = n1.children
const newChildren = n2.children
// 遍历新的 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
}
}
}
}
如果位置调换应该怎么做呢
js
vnode.children =
[
{ type: 'p', children: '1', key: 1 },
{ type: 'p', children: '3', key: 3 }
{ type: 'p', children: '2', key: 2 },
]
第一步先确定需要移动的元素
- 先遍历新数组,根据新数组元素的key在旧数组查找对应key的元素,记录它在旧数组的索引值,这些索引值应该是
递增
的,如果不是递增代表他要被移动 - 需要一个变量记录索引值列表的最大值
let lastIndex = 0
- 第一步:在旧数组的索引值0,不小于于
lastIndex
,不需要移动 - 第二步:在旧数组的索引值2,不小于
lastIndex
,不需要移动,lastIndex
更新为2 - 第三步:在旧数组的索引值1,小于
lastIndex
,需要移动,lastIndex
不变
总之不是递增的序列就需要移动
js
let lastIndex = 0
for (let i = 0; i < newLen; i++) {
const newVnode = newChildren[i];
for (let j = 0; j < oldLen; j++) {
const oldVnode = oldChildren[i];
if (newVnode.key === oldVnode.key) {
patch(oldVnode, newVnode, container)
if (j < lastIndex) {
//进入代表需要移动元素
} else {
lastIndex = j
}
break
}
}
}
第二步如何移动
先换一个复杂一点的例子
js
vnode.children =
[
{ type: 'p', children: '3', key: 3 },
{ type: 'p', children: '1', key: 1 },
{ type: 'p', children: '2', key: 2 },
]
不难看出需要移动的是第二,三个元素.
- 如何移动第二个key为1的元素,可以看出这个元素的位置在新数组是排在key为3之后的,所以只需要把旧数组里的这个元素插入到key为3的元素之后.也就是
js
children: [
{ type: 'p', children: '2', key: 2 },
{ type: 'p', children: '3', key: 3 },
{ type: 'p', children: '1', key: 1 },
]
2.同理,如何移动第三个key为2的元素,这个元素的位置在新数组是排在key为1之后的,即:
js
children: [
{ type: 'p', children: '3', key: 3 },
{ type: 'p', children: '1', key: 1 },
{ type: 'p', children: '2', key: 2 }
]
使用insertBefore
在指定节点之前插入元素,parent是父节点,在这里也就是container容器,el是添加的元素,anchor即指定节点,如果不传anchor插入在最后.
另外这个insertBefore是在DOM下使用的,需要把它当成生成渲染器函数的选项传递,因为不同平台实现api不一样
.
parent.insertBefore(el, anchor)
js
let lastIndex = 0
for (let i = 0; i < newLen; i++) {
const newVnode = newChildren[i];
for (let j = 0; j < oldLen; j++) {
const oldVnode = oldChildren[i];
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
}
}
}
注意在mountElement
patchElement
方法里都处理保存了真实DOM,所以上面才能直接拿到对应的真实DOM.
js
//mountElement
const el = vnode.el = createElement(vnode.type)
//patchElement
const el = n2.el = n1.el
接下来考虑一下有新增元素的情况
- 想办法找到新增节点
- 将新增节点挂载到正确位置
所以整体的代码是
js
const oldChildren = n1.children const newChildren = n2.children const oldLen = oldChildren.length const newLen = newChildren.length const commonLength = Math.min(oldLen, newLen) let lastIndex = 0
for (let i = 0; i < newLen; i++) {
const newVNode = newChildren[i];
// 在第一层循环中定义变量 find,代表是否在旧的一组子节点中找到可复用的节点,
// 初始值为 false,代表没找到
let find = false
for (j = 0; 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) {
anchor = prevVNode.el.nextSibling
} else {
// 如果没有前一个 vnode 节点,说明即将挂载的新节点是第一个子节
// 这时我们使用容器元素的 firstChild 作为锚点
anchor = container.firstChild
}
// 挂载 newVNode
patch(null, newVNode, container, anchor)
}
}
注意最后并不是直接insert插入,而是重新调用patch方法
,因为新增节点的type不同处理流程也不同,需要重新调用patch.
所以patch
,patch中的mountElement
,mountElement中的insert
函数需要添加一个锚点参数用来在指定位置添加.
之后当然是考虑元素需要卸载的情况,这种情况其实处理起来很简单.
只需要在双层for循环之后,即更新操作完成.遍历旧数组,查找key在新数组没有对应的元素,将其卸载.
js
// 上一步的更新操作完成后
// 遍历旧的一组子节点
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)
}
}
unmount函数在上节渲染器有提到
js
function unmount(vnode) {
if (vnode.type === Fragment) {
vnode.children.forEach(c => unmount(c))
return
}
const parent = vnode.el.parentNode
if (parent) {
parent.removeChild(vnode.el)
}
}
下一节讲双端Diff