05|把递归渲染拆成 Fiber:让一棵大树可以暂停

对应代码:lessons/05-fiber

mini-react 仓库地址

前面的 resolve()createDom()patch() 都依赖递归调用栈。一旦开始处理一棵大树,这段 JavaScript 就要一直运行到递归返回,渲染器没有明确的中间恢复点。

Fiber 的第一层意义,是把隐含在调用栈中的遍历过程改造成一组可链接的工作节点:每次处理一个节点,记住下一个节点,需要时可以把主线程还给浏览器。

这一章是一次刻意的"能力回退":只实现首次渲染,不支持更新、删除、key 和 Hooks。这样我们可以先单独看清 Fiber 工作循环。后续章节会逐步把能力加回来。

递归为什么难以从外部暂停

同步递归大致是:

js 复制代码
function render(element) {
  const dom = createDom(element);
  for (const child of element.props.children) {
    render(child);
  }
  return dom;
}

"接下来处理哪个 child、处理完后回到哪个 parent"都保存在 JavaScript 调用栈里。调度器只看到一次尚未返回的 render() 调用,无法在普通函数边界外取得一个明确的未完成工作列表。

如果这段同步任务运行很久,浏览器就不能在其中穿插事件处理和绘制。Fiber 的做法不是让单个 JavaScript 函数神奇地并行执行,而是把任务拆小,在工作单元之间检查剩余预算。

Fiber 是元素之外的工作数据

元素对象回答"UI 是什么":

js 复制代码
{ type, props }

本章的 Fiber 还要回答"这份 UI 工作如何连接和提交":

js 复制代码
{
  type,
  props,
  dom,
  parent,
  child,
  sibling,
  effectTag
}

其中:

  • typeprops 来自元素对象;
  • dom 保存宿主元素对应的 DOM,函数组件则没有 DOM;
  • parentchildsibling 把树变成可逐步行走的数据结构;
  • effectTag 记录提交时要做的动作,本章所有新节点都是 PLACEMENT

请注意,本章还没有 alternatehookscurrentRootdeletions。这些字段和状态会在真正需要时再出现,不应该提前把最终版结构套到这一章。

从 children 数组到指针结构

元素对象的孩子是数组:

text 复制代码
section.children = [h2, p, button]

协调时会把它们连成:

text 复制代码
section.child ──→ h2
                  │ sibling
                  ▼
                  p ──sibling──→ button

h2.parent = section
p.parent = section
button.parent = section

reconcileChildren() 正在做这件事:

js 复制代码
function reconcileChildren(parentFiber, elements) {
  let previousSibling = null;

  elements.forEach((element, index) => {
    const newFiber = {
      type: element.type,
      props: element.props,
      dom: null,
      parent: parentFiber,
      child: null,
      sibling: null,
      effectTag: PLACEMENT,
    };

    if (index === 0) parentFiber.child = newFiber;
    else previousSibling.sibling = newFiber;
    previousSibling = newFiber;
  });
}

数组适合表达 UI 输入,指针结构则让遍历器可以从任意节点继续向下、向右或向上寻找后续工作。

根 Fiber 从哪里来

createRoot().render() 不再立即递归渲染。它创建一个特殊的宿主根 Fiber:

js 复制代码
workInProgressRoot = {
  dom: container,
  props: { children: normalizeChildren([element]) },
};

nextUnitOfWork = workInProgressRoot;
scheduleWork();

根 Fiber 没有 type,因为它不对应新的 HTML 标签;它的 dom 就是已有的容器。顶层元素被放进 props.children,等待协调成根的 child

两个全局变量分别表示:

  • nextUnitOfWork:下一个要处理的 Fiber,也是暂停后的恢复点;
  • workInProgressRoot:本轮正在构建、尚未提交的整棵树。

scheduled 则避免为同一批工作重复登记工作循环回调。

工作循环如何让出主线程

调度器优先使用 requestIdleCallback;环境不支持时,课程代码用 setTimeout 构造约 8ms 的模拟预算:

js 复制代码
function workLoop(deadline) {
  scheduled = false;

  while (
    nextUnitOfWork
    && (deadline.didTimeout || deadline.timeRemaining() > 1)
  ) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
  }

  if (nextUnitOfWork) scheduleWork();
  else if (workInProgressRoot) commitRoot();
}

只要还有时间,就处理下一个 Fiber;预算不足时,nextUnitOfWork 保留当前位置,再安排下一轮。整棵树走完后才进入 Commit。

暂停点只存在于两个 Fiber 之间。一次组件函数调用、一次 reconcileChildren() 或一次 DOM 创建仍然是同步工作;如果单个工作单元本身非常重,这个简化调度器也无法从它的中间切开。

这段实现用于解释"工作可切片"这条主线。现代 React 的调度器不等于简单调用 requestIdleCallback;本项目也没有优先级、Lane、可取消渲染或多更新冲突处理。

一个工作单元做什么

performUnitOfWork() 先根据 type 分流:

js 复制代码
if (typeof fiber.type === "function") {
  updateFunctionComponent(fiber);
} else {
  updateHostComponent(fiber);
}

函数组件会执行函数,并把返回元素协调为 child:

js 复制代码
function updateFunctionComponent(fiber) {
  const output = fiber.type(fiber.props);
  reconcileChildren(fiber, normalizeChildren([output]));
}

宿主 Fiber 会创建尚未挂载的 DOM,然后协调自己的 props.children

js 复制代码
function updateHostComponent(fiber) {
  if (!fiber.dom) fiber.dom = createDom(fiber);
  reconcileChildren(fiber, fiber.props.children);
}

因此函数组件 Fiber 提供工作边界但没有 DOM;"section""p"TEXT_ELEMENT 等宿主 Fiber 才持有 DOM。

不用递归,怎样走完一棵树

处理当前 Fiber 后,下一步遵循深度优先规则:

js 复制代码
if (fiber.child) return fiber.child;

let nextFiber = fiber;
while (nextFiber) {
  if (nextFiber.sibling) return nextFiber.sibling;
  nextFiber = nextFiber.parent;
}

return null;

可以记为:

  1. 有 child,先向下;
  2. 没 child,有 sibling,向右;
  3. 两者都没有,沿 parent 向上,直到找到某个祖先的 sibling;
  4. 回到根仍找不到 sibling,整棵树完成。

假设结构是:

text 复制代码
App
└─ section
   ├─ h2
   │  └─ text
   └─ p
      └─ text

工作顺序是:

text 复制代码
ROOT → App → section → h2 → text → p → text

每次返回的 Fiber 就是下一轮 nextUnitOfWork,所以恢复点已经从调用栈变成显式数据。

Render 与 Commit 第一次分开

Render / Reconciliation 阶段会:

  • 执行函数组件;
  • 创建 Fiber;
  • 为宿主 Fiber 创建尚未挂载的 DOM;
  • 标记 PLACEMENT

它不会把这些新节点放进当前页面。等 nextUnitOfWork 变成 nullcommitRoot() 才从根的 child 开始同步遍历:

js 复制代码
function commitWork(fiber) {
  if (!fiber) return;

  let parentFiber = fiber.parent;
  while (parentFiber && !parentFiber.dom) {
    parentFiber = parentFiber.parent;
  }

  if (fiber.dom && fiber.effectTag === PLACEMENT) {
    parentFiber.dom.appendChild(fiber.dom);
  }

  commitWork(fiber.child);
  commitWork(fiber.sibling);
}

函数组件没有 DOM,所以提交时要沿 parent 向上找到最近的宿主父节点。

这里的"一次提交"是指一个不主动让出的同步阶段,并不是只调用一次 DOM API,也不是数据库式原子事务。commitWork 仍会执行多次 appendChild();关键是 Render 可以分片,而 Commit 不在节点之间主动暂停。

本章提交结束后直接丢弃 workInProgressRoot,没有保存已提交 Fiber 树。因此 createRoot 明确禁止第二次 render,更新能力要等下一篇加入。

动手验证

第 05 章已经在 performUnitOfWork() 中打印 Fiber 名称。运行后:

  1. 根据 main.js 先画出元素树,预测 Console 中的深度优先顺序,再刷新验证。
  2. workLoop 开头和 commitRoot 开头增加日志,观察多个工作批次一定发生在提交之前。
  3. 把回退预算从 8ms 调小,并临时创建更长的列表,观察工作被拆成更多轮;不要用这个实验做性能结论,因为日志本身也会显著占用时间。
  4. 尝试第二次调用 root.render(),确认课程代码抛出明确错误,而不是假装支持更新。

小结

  • Fiber 把树中的节点变成带 parent/child/sibling 指针的工作单元。
  • nextUnitOfWork 是显式恢复点,工作循环可以在 Fiber 之间让出主线程。
  • 函数组件 Fiber 没有 DOM,宿主 Fiber 才持有宿主资源。
  • Render 构建工作树和未挂载 DOM,Commit 再同步修改页面。
  • 本章只支持首次渲染,还没有旧树可供复用。

下一篇会保留已提交 Fiber 树,并用 alternate 连接新旧工作节点。到那时,Fiber 才真正支持第二次渲染。

相关推荐
AlienZHOU3 小时前
AI Coding 时代下,我的技术面试实践分享
前端·后端·面试
Captaincc6 小时前
AI用量v0.1.11更新发布 新增 jusage doctor 诊断指令 托盘展示token 和余额 新增 AutoClaw 支持
前端·后端·vibecoding
计算机魔术师7 小时前
德国Wiki被黑后两周,OpenAI终于把模型失控的账本摊开了
前端
kyriewen8 小时前
我让 AI 当面试官面了我一轮:第 3 个追问我就卡住了(附 10 道追问清单)
前端·面试·ai编程
IT_陈寒8 小时前
Python的GIL把我坑惨了,多线程跑得比单线程还慢
前端·人工智能·后端
前端snow8 小时前
ai agent --- 多agent框架之图编排引擎-langgraph
前端
竹林8188 小时前
OmniPic Studio v3.2.1 核心技术架构与全平台发版解析文档
前端·浏览器
JamesZhang800788 小时前
页面内存只涨不跌? 一次泄漏排查, 牵出 WeakMap 的诞生
前端
Z小明8 小时前
第 6 章 组件进阶
前端·vue.js
江华森8 小时前
HTTP请求的完整过程详解:从DNS解析到TCP挥手的微秒级实战分析
前端