06|双缓冲与 Commit:让 Fiber 支持第二次渲染

对应代码:lessons/06-fiber-reconciliation

mini-react 仓库地址

第 05 章的 Fiber 树只活到首次提交结束。渲染器没有保存"页面现在对应哪棵工作树",第二次渲染也就无从判断哪些 DOM 可以复用。

这一篇加入已提交树、工作中树和 alternate,再把协调结果标记为 PLACEMENTUPDATE 或删除。重点不只是"比较两棵树",而是理解为什么 Render 阶段只准备变化、Commit 阶段才修改页面。

本章仍然按同级位置匹配,暂时没有 keyMOVE

为什么首次渲染版不能更新

第 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,所以 alternatenull。提交过一次以后,同一容器的新根会指向旧根。

这个教学运行时把调度状态保存在模块级变量中,实质上只完整演示一个活动根;它不是生产 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 配对。有三种主要结果。

同位置、同 typeUPDATE

js 复制代码
newFiber = {
  type: oldFiber.type,
  props: element.props,
  dom: oldFiber.dom,
  parent: parentFiber,
  alternate: oldFiber,
  effectTag: UPDATE,
};

新 Fiber 复用旧 DOM,并通过 alternate 保留旧 props。Render 阶段不会立即更新这个已挂载 DOM,只记录 UPDATE

函数组件的 domnull,但它仍然可以按 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) 会:

  1. 查看当前 Fiber 后面的 sibling;
  2. 跳过同样是 PLACEMENT 的子树;
  3. 若 sibling 是函数组件,则向下寻找它输出的第一个稳定宿主 DOM;
  4. 当前层找不到时,沿 parent 向上继续检查祖先的 sibling;
  5. 到达某个宿主 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 章:

  1. 保存两个按钮的 DOM 引用,点击 +1 后验证它们仍是同一对象。
  2. 标题会在 h2h3 之间切换。验证旧标题被删除,新标题被插入,而后面的计数和按钮顺序保持正确。
  3. 切换"显示 / 隐藏说明",观察删除发生在 Commit,而不是组件返回 false 的瞬间。
  4. reconcileChildren 中打印新旧 typeeffectTag,先预测每个节点的标记再运行。

小结

  • currentRoot 表示已提交页面,WIP root 表示本轮计算。
  • alternate 把新 Fiber 连接到匹配的旧 Fiber,用于复用 DOM 和读取旧 props。
  • Render 可以创建未挂载 DOM,但不修改当前已挂载页面。
  • Commit 集中执行删除、插入与属性更新,完成后才交换 currentRoot
  • 按位置匹配无法在排序和头部插入时保留列表项身份。

下一篇会从元素中提取 key,用 Map 查找旧兄弟,并用 MOVE 移动原 DOM,而不是删除后重建。

相关推荐
涛涛ing14 分钟前
4.8K Star!一套规则让 AI 帮你写出“像人写的”前端代码
前端
万物智能17 分钟前
OpenHarmony源码树解剖—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
前端·后端
天才熊猫君19 分钟前
通用虚拟滚动表格行拖拽排序:从思路到完整实现
前端·javascript
hh95022 分钟前
Agent Plan × DeepSeek Harness:角色 Prompt 驱动的 Agent 分工优化与协作质量实验
java·前端·人工智能·prompt·adg·agent plan·adg成都社区
xiaominlaopodaren23 分钟前
three.js最小地图运行时(九):TileKey 线框与 cursor 坐标探针
javascript·gis·three.js
CarIise24 分钟前
JavaScript进阶与轮播图实现 课堂笔记
开发语言·javascript·笔记
专业抄代码选手25 分钟前
05|把递归渲染拆成 Fiber:让一棵大树可以暂停
前端·javascript·react.js
像我这样帅的人丶你还28 分钟前
🚀苹果的液态玻璃咋做?🚀
前端·webgl·three.js
mayaairi29 分钟前
JS DOM节点操作完全指南:增删改查与性能优化
开发语言·javascript·性能优化