2026.01.11
Renderer 更新 -> Diff 算法基础
在上一章中,我们实现了渲染器的挂载和卸载功能。现在,让我们继续完善渲染器的更新机制,当数据发生变化时,渲染器需要高效地对比新旧虚拟节点,并将变化应用到真实 DOM 上。
在上一章中,我们实现了渲染器的挂载和卸载功能。现在,让我们继续完善渲染器的更新机制,当数据发生变化时,渲染器需要高效地对比新旧虚拟节点,并将变化应用到真实 DOM 上。

有效的更新策略
判断节点类型是否相同
在更新过程中,并不是所有情况都需要进行对比更新。考虑这样一个场景:如果最开始渲染的是 span 元素,而更新后变成了 div 元素,这两个节点类型完全不同,直接复用是不合理的。
function patch(n1, n2, container) {
// 如果新旧节点是同一个对象引用,无需任何操作
if (n1 === n2)
return
// 如果老节点存在,且新旧节点类型不同
if (n1 && !isSameVNodeType(n1, n2)) {
// 先卸载老节点,然后将 n1 置为 null
// 这样后续逻辑就会将 n2 作为新节点进行挂载
unmount(n1)
n1 = null
}
// 如果 n1 为 null,说明是首次挂载或类型不同需要重新挂载
if (n1 == null) {
mountElement(n2, container)
}
else {
// 新旧节点类型相同,进行更新
patchElement(n1, n2)
}
}
通过这个逻辑,我们实现了节点类型变化时的优雅处理:先卸载旧节点,再挂载新节点,而不是尝试进行无意义的对比。
元素的对比更新
patchElement 的核心流程
当新旧节点类型相同时,我们需要对元素进行对比更新。patchElement 函数负责处理这个过程:
function patchElement(n1, n2) {
// 1. 复用 DOM 元素
// 将老节点的真实 DOM 赋值给新节点的 el 属性
// 这样新节点就指向了同一个 DOM 元素,实现了复用
const el = (n2.el = n1.el)
// 2. 更新 props(属性和事件)
const oldProps = n1.props
const newProps = n2.props
patchProps(el, oldProps, newProps)
// 3. 更新 children(子节点)
patchChildren(n1, n2)
}
patchElement 的核心思想是复用已有的 DOM 元素,然后分别更新其属性和子节点。这样可以避免不必要的 DOM 创建和销毁操作,提升性能。
Props 的更新策略
patchProps 的实现
属性更新需要处理两种情况:删除老的属性,设置新的属性。
function patchProps(el, oldProps, newProps) {
// 1. 删除老的 props
// 遍历老属性,如果不在新属性中,则删除
if (oldProps) {
for (const key in oldProps) {
hostPatchProp(el, key, oldProps[key], null)
}
}
// 2. 设置新的 props
// 遍历新属性,更新到 DOM 元素上
if (newProps) {
for (const key in newProps) {
// 对比老值和新值,如果不同则更新
hostPatchProp(el, key, oldProps?.[key], newProps[key])
}
}
}
使用 hostPatchProp 函数来统一处理属性的增删改,具体的 DOM 操作由平台相关的渲染选项提供。
子节点的更新策略
patchChildren 的复杂场景
子节点的更新是渲染器中最复杂的部分,因为需要考虑多种组合情况。新旧节点的子节点可能是文本、数组或者 null,让我们逐一分析:
function patchChildren(n1, n2) {
const el = n2.el
const prevShapeFlag = n1.shapeFlag
const shapeFlag = n2.shapeFlag
/**
* 子节点更新有以下几种情况:
* 1. 新节点的子节点是文本
* 1.1 老的是数组 - 卸载所有老的子节点,然后设置文本
* 1.2 老的也是文本 - 如果文本不同,直接更新文本内容
* 2. 新节点的子节点是数组
* 2.1 老的是文本 - 清空文本,然后挂载新的数组子节点
* 2.2 老的也是数组 - 进行完整的 diff 算法
* 2.3 老的是 null - 直接挂载新的数组子节点
* 3. 新节点的子节点是 null
* 3.1 老的是数组 - 卸载所有老的子节点
* 3.2 老的是文本 - 清空文本内容
*/
// 情况 1:新节点的子节点是文本
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 1.1 老的是数组,先卸载所有老的子节点
unmountChildren(n1.children)
}
// 无论老的是什么类型,只要新旧文本不同就更新
if (n1.children !== n2.children) {
hostSetElementText(el, n2.children)
}
}
else {
// 情况 2 和 3:新的可能是数组或 null
if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
// 老的是文本,先清空文本内容
hostSetElementText(el, '')
// 如果新的是数组,挂载新的子节点
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
mountChildren(n2.children, el)
}
}
else {
// 老的是数组或 null
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 2.2
// TODO: 全量 diff
}
else {
// 3.1 新的不是数组(是 null),卸载老的数组
unmountChildren(n1.children)
}
}
else {
// 老的是 null
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 2.3 新的是数组,直接挂载
mountChildren(n2.children, el)
}
}
}
}
}
关键点解析
- 使用
ShapeFlags位运算:通过位运算快速判断节点类型,避免多次类型检查 - 分情况处理:根据新旧子节点的类型组合,采用不同的更新策略
- 性能优化:
- 文本到文本:直接更新文本内容
- 数组到文本:先卸载,再设置文本
- 文本到数组:先清空,再挂载
- 数组到数组:使用
diff算法(TODO)
总结
通过本章的学习,我们完善了渲染器的更新机制,了解了:
- 节点类型判断:
- 使用
isSameVNodeType判断新旧节点是否为同一类型 - 类型不同时,卸载旧节点并重新挂载新节点
- 类型相同时,进行对比更新以提高性能
- 使用
- 元素更新流程:
- 复用已有的 DOM 元素(
n2.el = n1.el) - 分别更新 props 和 children
- 避免不必要的 DOM 操作
- 复用已有的 DOM 元素(
- Props 更新策略:
- 遍历旧属性,删除不再需要的属性
- 遍历新属性,更新或添加属性
- 使用平台相关的
hostPatchProp处理具体操作
- Children 更新策略:
- 根据
ShapeFlags判断子节点类型 - 处理文本、数组、null 三种类型的各种组合
- 文本到文本:直接更新
- 数组到数组:需要 diff 算法(后续实现)
- 其他情况:卸载旧的,挂载新的
- 根据
- 性能优化思路:
- 使用位运算快速判断类型
- 复用 DOM 元素而不是重新创建
- 针对不同场景采用最优更新策略
至此,我们已经实现了一个功能相对完整的渲染器更新机制。虽然数组到数组的 diff 算法还未实现,但基础的更新框架已经搭建完成。