对应代码:
lessons/06-fiber-reconciliation
第 05 章的 Fiber 树只活到首次提交结束。渲染器没有保存"页面现在对应哪棵工作树",第二次渲染也就无从判断哪些 DOM 可以复用。
这一篇加入已提交树、工作中树和 alternate,再把协调结果标记为 PLACEMENT、UPDATE 或删除。重点不只是"比较两棵树",而是理解为什么 Render 阶段只准备变化、Commit 阶段才修改页面。
本章仍然按同级位置匹配,暂时没有 key 和 MOVE。
为什么首次渲染版不能更新
第 05 章的 commitRoot() 最后会把 workInProgressRoot 设为 null。它没有保留 Fiber 树,而且所有 child 都被标记为 PLACEMENT。
如果简单地再跑一次相同流程,渲染器只知道创建一套新节点并追加,无法回答:
- 新
button是否对应页面中的旧button? - 哪些 props 改变了?
- 哪个旧节点应该删除?
- 函数组件没有 DOM,应该向哪个宿主父节点提交?
更新需要一份完整、已提交的旧工作树作为比较基线。
currentRoot、WIP Root 与 alternate
本章用三条连接建立新旧关系:
currentRoot:已经完整提交、与当前页面对应的 Fiber 树;workInProgressRoot:本轮正在计算、尚未提交的新树;alternate:新 Fiber 指向本轮匹配到的旧 Fiber。
text
已提交树 currentRoot 工作中树 workInProgressRoot
ROOT_old <──────────────── alternate ─────────────── ROOT_new
└─ App_old <───────────── alternate ─────────────── App_new
└─ section_old <───── alternate ─────────────── section_new
│ dom │ 复用同一 dom
└──────────────────────────────────────┘
alternate 不是 DOM 的副本,也不是整棵树的快照。它只是新工作节点访问对应旧工作节点的桥梁,从旧节点可以取得 dom 和旧 props。
只要 Render 尚未完成,currentRoot 就继续代表用户当前看到的页面。WIP 树可以分片构建,中途不会取代它。
root.render() 怎样启动一轮更新
每次渲染会创建新的宿主根:
js
render(element) {
const alternate = currentRoot?.dom === container
? currentRoot
: null;
workInProgressRoot = {
dom: container,
props: { children: normalizeChildren([element]) },
alternate,
};
deletions = [];
nextUnitOfWork = workInProgressRoot;
scheduleWork();
}
首次渲染没有 currentRoot,所以 alternate 是 null。提交过一次以后,同一容器的新根会指向旧根。
这个教学运行时把调度状态保存在模块级变量中,实质上只完整演示一个活动根;它不是生产 React 的多根并发实现。
按位置连接新旧 child
协调从旧 parent 的第一个 child 开始:
js
let oldFiber = parentFiber.alternate?.child ?? null;
let index = 0;
while (index < elements.length || oldFiber) {
const element = elements[index];
const sameType = element
&& oldFiber
&& element.type === oldFiber.type;
// 根据 sameType 创建 newFiber 或记录删除
}
每轮循环都把当前位置的新元素与当前位置的旧 Fiber 配对。有三种主要结果。
同位置、同 type:UPDATE
js
newFiber = {
type: oldFiber.type,
props: element.props,
dom: oldFiber.dom,
parent: parentFiber,
alternate: oldFiber,
effectTag: UPDATE,
};
新 Fiber 复用旧 DOM,并通过 alternate 保留旧 props。Render 阶段不会立即更新这个已挂载 DOM,只记录 UPDATE。
函数组件的 dom 是 null,但它仍然可以按 type 复用工作边界,并继续用 alternate.child 比较其输出。
有新元素但不能复用:PLACEMENT
js
newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: parentFiber,
alternate: null,
effectTag: PLACEMENT,
};
可能是旧节点不存在,也可能是同位置类型不同。后续处理宿主 Fiber 时会创建一个尚未挂载的 DOM,Commit 再插入页面。
旧 Fiber 没有匹配:加入 deletions
js
if (oldFiber && !sameType) deletions.push(oldFiber);
类型替换时,旧 Fiber 进入删除列表,新元素同时产生 PLACEMENT Fiber。新元素耗尽后,循环仍会沿旧 sibling 前进,因此多余的旧节点也会全部进入删除列表。
删除没有对应的新 Fiber,无法挂在新树上继续遍历,所以需要独立的 deletions 数组。
Render 阶段到底能不能创建 DOM
"Render 阶段不操作 DOM"是一种容易过度简化的说法。这个项目在处理新的宿主 Fiber 时,会调用 document.createElement() 或 createTextNode(),也会给这个尚未挂载的节点设置初始 props。
真正的边界是:Render 阶段不修改当前页面中已经挂载的宿主树。
它可以做:
- 调用组件并创建 Fiber;
- 比较新旧 Fiber;
- 创建未挂载的 DOM;
- 标记新增、更新和删除。
它不会做:
- 把新节点插入当前页面;
- 修改复用 DOM 的属性;
- 从当前页面删除旧 DOM。
所以工作循环即使暂停,用户仍看到与 currentRoot 对应的完整旧页面,而不是半棵新树。
Commit 怎样找到宿主父节点
提交某个 Fiber 时,它的直接 parent 可能是函数组件。函数组件没有 DOM,因此要向上寻找:
js
let parentFiber = fiber.parent;
while (parentFiber && !parentFiber.dom) {
parentFiber = parentFiber.parent;
}
const parentDom = parentFiber?.dom;
找到宿主父节点后:
PLACEMENT:把新 DOM 插入宿主父节点;UPDATE:用alternate.props与新props调用updateDom();- 删除:在正式遍历新树前处理
deletions。
函数组件本身没有需要插入或更新的 DOM,但 commitWork 仍会继续访问它的 child 和 sibling。
为什么新增节点不能一律 appendChild
如果只在列表末尾增加节点,追加即可。但类型替换也可能发生在兄弟列表中间:
text
旧:<p>A</p> <span>B</span> <p>C</p>
新:<p>A</p> <strong>B</strong> <p>C</p>
若把 strong 追加到末尾,顺序就会变成 A、C、B。Commit 需要找到新 Fiber 后方第一个已经稳定存在的宿主 DOM,作为 insertBefore 的第二个参数。
getHostSibling(fiber) 会:
- 查看当前 Fiber 后面的 sibling;
- 跳过同样是
PLACEMENT的子树; - 若 sibling 是函数组件,则向下寻找它输出的第一个稳定宿主 DOM;
- 当前层找不到时,沿 parent 向上继续检查祖先的 sibling;
- 到达某个宿主 parent 边界仍找不到,就返回
null,此时insertBefore(dom, null)等价于追加。
因此提交新增节点使用:
js
parentDom.insertBefore(fiber.dom, getHostSibling(fiber));
删除函数组件输出的 DOM
删除目标也可能是一个没有 DOM 的函数组件 Fiber。commitDeletion() 先向上找到宿主父 DOM,再调用:
js
function removeFiberDom(fiber, parentDom) {
if (fiber.dom) {
if (fiber.dom.parentNode === parentDom) {
parentDom.removeChild(fiber.dom);
}
return;
}
let child = fiber.child;
while (child) {
removeFiberDom(child, parentDom);
child = child.sibling;
}
}
遇到宿主 Fiber 就删除对应宿主根并停止向其内部递归,因为移除父 DOM 会连同其后代一起离开页面;遇到函数组件则继续向下,删除它可能输出的一个或多个宿主根。
完整提交后才能交换身份
提交顺序是:
text
Render 完成整棵 WIP 树
↓
处理 deletions
↓
遍历 WIP,执行 PLACEMENT / UPDATE
↓
currentRoot = workInProgressRoot
↓
清空 WIP 与 deletions
关键代码是:
js
function commitRoot() {
for (const fiber of deletions) commitDeletion(fiber);
commitWork(workInProgressRoot.child);
currentRoot = workInProgressRoot;
workInProgressRoot = null;
deletions = [];
}
只有 DOM 提交完成后,新树才有资格成为下一轮的旧树。若提前交换,暂停中的半成品树就可能被后续更新当成页面事实。
"双缓冲"在这里表示一棵已提交树与一棵工作中树并存,并通过 alternate 对应;它不意味着复制真实 DOM,也不自动解决所有一致性和调度问题。
按位置匹配仍会错认列表身份
本章已经能正确处理第二次渲染,但协调依据仍是位置和 type。当旧 [A, B, C] 变成新 [X, A, B, C],如果它们都是 li:
text
旧 A ─UPDATE→ 新 X
旧 B ─UPDATE→ 新 A
旧 C ─UPDATE→ 新 B
新 C ← PLACEMENT
页面文字会正确,数据身份却错配。双树与 alternate 提供了"复用哪个旧节点"的入口,但稳定身份还需要 key。
动手验证
运行第 06 章:
- 保存两个按钮的 DOM 引用,点击
+1后验证它们仍是同一对象。 - 标题会在
h2与h3之间切换。验证旧标题被删除,新标题被插入,而后面的计数和按钮顺序保持正确。 - 切换"显示 / 隐藏说明",观察删除发生在 Commit,而不是组件返回
false的瞬间。 - 在
reconcileChildren中打印新旧type与effectTag,先预测每个节点的标记再运行。
小结
currentRoot表示已提交页面,WIP root 表示本轮计算。alternate把新 Fiber 连接到匹配的旧 Fiber,用于复用 DOM 和读取旧 props。- Render 可以创建未挂载 DOM,但不修改当前已挂载页面。
- Commit 集中执行删除、插入与属性更新,完成后才交换
currentRoot。 - 按位置匹配无法在排序和头部插入时保留列表项身份。
下一篇会从元素中提取 key,用 Map 查找旧兄弟,并用 MOVE 移动原 DOM,而不是删除后重建。