对应代码:
lessons/03-reconciliation
每次状态变化,组件都会重新计算一份 UI 描述。新的元素对象和旧对象不是同一批 JavaScript 对象,但这不意味着页面中的真实 DOM 也必须全部重建。
协调(reconciliation)要回答的是:面对一棵旧树和一棵新树,哪些宿主资源可以继续使用,哪些需要创建、更新或删除?本篇用一个同步、按位置比较的实现,建立这条最小主线。
新元素对象,不等于新 DOM
课程示例把计数值保存在普通变量里:
js
let count = 0;
function increment() {
count += 1;
render(h(App, null), root);
}
第二次调用 h(App) 会创建新的 App 元素;App() 也会返回一批新的 section、p、button 和 li 元素对象。元素对象只是不可变式使用的 UI 快照,所以重新创建它们很自然。
真实按钮则可能带着焦点、事件监听和浏览器内部状态。只要新旧树中的按钮仍表示同一个位置、同一种类型,渲染器就可以保留原 DOM,只更新发生变化的部分。
text
旧元素树 ─┐
├─ patch → 必要的 DOM 操作
新元素树 ─┘
协调的价值是给复用和更新提供统一规则;它是一种启发式过程,并不保证求出理论上的全局最少操作。
先把函数组件展开
为了让第一版 Diff 足够直观,lessons/03-reconciliation/mini-react.js 会先执行所有函数组件,只保留宿主元素与文本:
js
function resolve(element) {
if (typeof element.type === "function") {
return resolve(element.type(element.props));
}
return {
...element,
props: {
...element.props,
children: element.props.children.map(resolve),
},
};
}
例如输入 { type: App },输出树的根会是 { type: "section" }。这样 patch() 不需要理解函数组件,只比较与 DOM 一一对应的节点。
代价也很明显:组件边界从结果树中消失,状态无法自然地挂到某个组件上。后面的 Fiber 版本会保留函数组件工作节点。
这一教学版还约定组件返回单个元素,不处理 Fragment 或数组返回值。
保存新旧两棵描述树
render() 的实现很短:
js
export function render(element, container) {
const nextTree = resolve(element);
patch(container, container.__previousTree, nextTree, 0);
container.__previousTree = nextTree;
}
第一次渲染时,container.__previousTree 不存在,patch() 会创建完整 DOM。提交结束后,新树被保存在容器的自定义属性上。
第二次渲染时:
previous是上次保存的描述树;nextTree是本次刚生成的描述树;- 容器中已有的 DOM 与
previous按相同结构对应。
把旧树挂到 DOM 容器上只是为了教学方便,并不是 React 保存 Fiber 树的方式。第 04 篇会引入 root 对象,第 06 篇则会正式区分 currentRoot 与 workInProgressRoot。
patch() 的四种情况
patch(parent, previous, next, index) 中,index 表示当前元素对应 parent.childNodes 中的哪个真实节点:
js
function patch(parent, previous, next, index) {
const dom = parent.childNodes[index];
if (!previous) {
parent.appendChild(createDom(next));
return;
}
if (!next) {
parent.removeChild(dom);
return;
}
if (previous.type !== next.type) {
parent.replaceChild(createDom(next), dom);
return;
}
updateProps(dom, previous.props, next.props);
// 接下来继续比较 children
}
规则可以归纳为:
| 旧元素 | 新元素 | 操作 |
|---|---|---|
| 不存在 | 存在 | 创建并追加 DOM |
| 存在 | 不存在 | 删除旧 DOM |
| 存在 | type 不同 |
创建新 DOM 并替换 |
| 存在 | type 相同 |
复用 DOM,比较 props 和 children |
这里的 type 是重要的复用边界。h2 变成 h3 时,节点语义和 DOM 类型都变了,当前实现直接替换整个子树;p 仍是 p 时,则保留节点对象。
props Diff 不只是改文本
同类型 DOM 被复用后,updateProps() 分三步工作:
- 解绑已经删除或引用发生变化的旧事件;
- 删除新 props 中不再存在的普通属性;
- 添加新事件,写入发生变化的属性。
事件需要先解绑旧函数再绑定新函数,否则组件每次执行产生的新闭包会在同一个 DOM 上不断累积。
属性的落地方式也不完全相同:
className通过classattribute 设置;- DOM 对象已有的名字优先写 property,例如
value、nodeValue; - 其他名字使用
setAttribute(); style对象会逐项清理旧样式并合并新样式。
文本元素的 type 始终是 TEXT_ELEMENT。它对应一个 Text DOM,文字内容保存在 props.nodeValue,所以文本更新最终也是一次 property Diff。
这个属性层是为了说明机制,并没有覆盖 React DOM 的全部浏览器兼容和表单规则。
children 为什么按位置比较
本章让第 N 个旧 child 与第 N 个新 child 配对。共有部分从前向后递归:
js
const commonLength = Math.min(oldChildren.length, newChildren.length);
for (let i = 0; i < commonLength; i += 1) {
patch(dom, oldChildren[i], newChildren[i], i);
}
如果新孩子更多,就从尾部创建;如果旧孩子更多,则必须从后向前删除:
js
for (let i = oldChildren.length - 1; i >= newChildren.length; i -= 1) {
patch(dom, oldChildren[i], null, i);
}
倒序删除是因为 childNodes 是动态集合。若先删除下标 1,原下标 2 会立刻移动到 1,后续下标就不再对应原节点;从末尾开始不会影响前面的下标。
跟踪一次 count: 0 → 1
示例初次渲染后,firstButton 保存了按钮 DOM 的引用。点击一次时,新旧树大致发生这些变化:
section、h2、p、button、ul类型和位置不变,因此 DOM 被复用;p的颜色样式改变;p的文本节点从0更新为1;ul尾部多出一个li,因此创建并追加该子树;increment是同一个函数引用,按钮事件无需重新绑定。
提交后:
js
root.querySelector("button") === firstButton // true
这证明"元素对象重新创建"和"DOM 对象重新创建"是两件不同的事。
按位置匹配的盲点
假设旧列表是:
text
下标 0 1 2
旧数据 A B C
现在在开头插入 X:
text
下标 0 1 2 3
新数据 X A B C
按位置比较会把旧 A 对应的 DOM 改写为 X,把旧 B 改写为 A,把旧 C 改写为 B,最后再创建 C。视觉文字可能正确,但 A、B、C 没有保留各自原来的 DOM 身份。
如果列表项内有输入框、播放状态或组件状态,这种身份错配就会变成真实问题。数组下标只说明当前位置,无法表达"移动后的它仍是同一条数据"。key 正是用来补充稳定身份的,不过完整算法要到第 07 篇才加入。
动手验证
运行 npm run dev 并打开第 03 章:
- 在 Elements 面板中选中按钮,点击更新后观察临时标记是否仍在;页面日志也会显示按钮引用相等。
- 让标题在奇数次渲染时使用
h3,偶数次使用h2。保存标题和按钮引用,验证标题被替换而按钮仍被复用。 - 把列表改为可在开头插入项目,并在某个
li中放入input。输入内容后再插入,观察内容为什么留在"位置"上,而不是跟随数据项移动。
做实验时先预测四条 patch 规则中的哪一条会触发,再查看结果。
小结
- 协调比较新旧 UI 描述,决定宿主资源的复用和修改。
- 同位置、同
type的元素复用 DOM;类型变化则替换。 - 属性、事件、样式、文本和孩子都需要参与 Diff。
- 本章按位置匹配,能处理尾部增删,却不能表达列表项的稳定身份。
现在页面可以更新了,但状态仍由业务代码手工保存在模块变量中。下一篇会实现 useState,并解释函数组件每次重跑时,状态为什么没有随局部变量一起消失。