props 更新和子节点 diff
上一节实现了元素的首次挂载和卸载。来完善一下 patchElement 方法
patchElement
patchElement 的更新流程可以分成三步:
- 复用旧
vnode对应的DOM - 对比并更新
props - 对比并更新
children
function patchElement(n1, n2) {
const el = n2.el = n1.el
patchProps(el, n1.props, n2.props)
patchChildren(n1, n2)
}
patchProps
更新 props 时,需要处理两种情况:
- 旧
props中存在、新props中不存在:删除旧属性 - 新
props中存在:设置新属性,或者更新已有属性
function patchProps(el, oldProps = {}, newProps = {}) {
for (const key in oldProps) {
if (!(key in newProps)) {
hostPatchProp(el, key, oldProps[key], null)
}
}
for (const key in newProps) {
const prevValue = oldProps[key]
const nextValue = newProps[key]
if (prevValue !== nextValue) {
hostPatchProp(el, key, prevValue, nextValue)
}
}
}
先删除已经失效的属性,再只更新真正发生变化的属性。
patchChildren
patchChildren 负责更新子元素。子元素情况比较多,分为
| 旧 children | 新 children | 处理方式 |
|---|---|---|
| 文本或空 | 文本 | 更新文本 |
| 数组 | 文本 | 卸载旧数组,设置新文本 |
| 文本或空 | 数组 | 挂载新数组 |
| 数组 | 数组 | 进入数组 diff |
| 文本或数组 | 空 | 清空或卸载旧节点 |
function patchChildren(n1, n2) {
const el = n2.el
const prevShapeFlag = n1.shapeFlag
const shapeFlag = n2.shapeFlag
const c1 = n1.children
const c2 = n2.children
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) { // 新节点是文本
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) { // 老节点是数组: 将老节点的 children 卸载
unmountChildren(c1)
}
if (c1 !== c2) {
hostSetElementText(el, c2)
}
}
else {
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) { // 老节点是数组
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) { // 新节点也是数组
patchKeyedChildren(c1, c2, el)
}
else {
// 新节点不是数组,卸载老节点的数组
unmountChildren(c1)
}
}
else {
// 老节点是 null
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
mountChildren(el, c2)
}
}
}
}
通过 shapeFlag 判断新旧 children 的类型。
如果新旧 children 都是数组,就不能简单地全部删除后重新挂载,而是需要进入 patchKeyedChildren。
Vue 的实现会先从头部和尾部开始比较,尽量快速处理已经对齐的节点。
头部对比
const c1 = [a, b]
const c2 = [a, b, c]

头部对比从 i = 0 开始。
只要新旧节点类型相同,就可以直接调用 patch 更新,并把指针向后移动。
如果遇到不同类型的节点,就暂时停止头部比较。
function patchKeyedChildren(c1, c2, container) {
let i = 0
let e1 = c1.length - 1
let e2 = c2.length - 1
while (i <= e1 && i <= e2) {
const n1 = c1[i]
const n2 = c2[i]
if (isSameVNodeType(n1, n2)) {
patch(n1, n2, container)
}
else {
break
}
i++
}
}
尾部对比
const c1 = [a, b]
const c2 = [c, a, b]
头部并没有完全对齐,但尾部的 a、b 是相同的。 尾部比较从最后一个节点开始,比较成功后同时向前移动 e1 和 e2。

while (i <= e1 && i <= e2) {
const n1 = c1[e1]
const n2 = c2[e2]
if (isSameVNodeType(n1, n2)) {
patch(n1, n2, container)
}
else {
break
}
e1--
e2--
}
处理新增和删除
头部、尾部比较完成后,剩余区间就是还没有处理的部分。
如果 i > e1,说明新的子节点多,老的子节点少。需要挂载新节点。
if (i > e1) {
const nextPos = e2 + 1
const anchor = nextPos < c2.length ? c2[nextPos].el : null
while (i <= e2) {
patch(null, c2[i], container, anchor)
i++
}
}
anchor 表示新节点应该插入到哪个节点之前。
如果插入位置已经是末尾,anchor 就是 null,最终效果就是追加到容器末尾。
如果 i > e2,说明老的子节点多,新的子节点少。需要卸载旧节点。
else if (i > e2) {
while (i <= e1) {
unmount(c1[i])
i++
}
}
乱序 diff
头部和尾部对比只能处理已经对齐的节点。
const c1 = [a, b, c, d, e]
const c2 = [a, c, d, b, e]
经过头部和尾部比较后,a 和 e 已经处理完成,剩余部分是:
c1 = [b, c, d]
c2 = [c, d, b]
这些节点仍然可以通过 key 找到对应关系,所以不需要全部重新创建。
先建立新节点的 key 到下标的映射:
const s1 = i
const s2 = i
const keyToNewIndexMap = new Map()
for (let j = s2; j <= e2; j++) {
const n2 = c2[j]
keyToNewIndexMap.set(n2.key, j)
}
然后遍历旧节点。
如果旧节点的 key 在新节点中还能找到,就调用 patch 更新。
如果找不到,说明这个旧节点已经被删除。
for (let j = s1; j <= e1; j++) {
const n1 = c1[j]
const newIndex = keyToNewIndexMap.get(n1.key)
if (newIndex != null) {
patch(n1, c2[newIndex], container)
}
else {
unmount(n1)
}
}
到这里,虽然节点内容已经更新了,但 DOM 顺序还不正确。
最后倒序遍历新节点,把节点插入到正确位置:
for (let j = e2; j >= s2; j--) {
const n2 = c2[j]
const anchor = c2[j + 1]?.el || null
if (n2.el) {
hostInsert(n2.el, container, anchor)
}
else {
patch(null, n2, container, anchor)
}
}
倒序插入时,后一个节点已经确定,因此可以把它作为当前节点的锚点。
如果节点已经存在,hostInsert 会把它移动到正确位置。
如果节点还没有 DOM,就调用 patch 完成挂载。
最强递增子序列
每一个对 DOM 都进行移动操作的话,还是比较耗费性能的。要尽可能的移动比较少的元素