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 才真正支持第二次渲染。

相关推荐
像我这样帅的人丶你还15 分钟前
🚀苹果的液态玻璃咋做?🚀
前端·webgl·three.js
mayaairi15 分钟前
JS DOM节点操作完全指南:增删改查与性能优化
开发语言·javascript·性能优化
天才熊猫君17 分钟前
Vue 3 插槽机制深度解析:两条线与三层树
前端·javascript
爱丶不疚20 分钟前
BrowserWindow:你的Electron 应用可以不用手写红绿灯
前端·electron
李溪白22 分钟前
事件循环(Event Loop):JavaScript 里的“时间管理大师”与“插队狂魔”
javascript
mONESY24 分钟前
告别 Vibe Coding:用 SDD 把一个 Chrome 翻译插件从 0 做到 1
javascript
用户5944041035627 分钟前
【完整fx】Vue3 + TS + Leafletjs 打造企业级原神大地图
前端
xcLeigh29 分钟前
Go入门:rune与byte的区别和使用场景
android·javascript·golang
计算机魔术师44 分钟前
AI开源社区迎来最大并购案:Hugging Face以129亿美元估值入局英伟达
前端