对应代码:
lessons/05-fiber
前面的 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
}
其中:
type、props来自元素对象;dom保存宿主元素对应的 DOM,函数组件则没有 DOM;parent、child、sibling把树变成可逐步行走的数据结构;effectTag记录提交时要做的动作,本章所有新节点都是PLACEMENT。
请注意,本章还没有 alternate、hooks、currentRoot 或 deletions。这些字段和状态会在真正需要时再出现,不应该提前把最终版结构套到这一章。
从 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;
可以记为:
- 有 child,先向下;
- 没 child,有 sibling,向右;
- 两者都没有,沿 parent 向上,直到找到某个祖先的 sibling;
- 回到根仍找不到 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 变成 null,commitRoot() 才从根的 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 名称。运行后:
- 根据
main.js先画出元素树,预测 Console 中的深度优先顺序,再刷新验证。 - 在
workLoop开头和commitRoot开头增加日志,观察多个工作批次一定发生在提交之前。 - 把回退预算从 8ms 调小,并临时创建更长的列表,观察工作被拆成更多轮;不要用这个实验做性能结论,因为日志本身也会显著占用时间。
- 尝试第二次调用
root.render(),确认课程代码抛出明确错误,而不是假装支持更新。
小结
- Fiber 把树中的节点变成带
parent/child/sibling指针的工作单元。 nextUnitOfWork是显式恢复点,工作循环可以在 Fiber 之间让出主线程。- 函数组件 Fiber 没有 DOM,宿主 Fiber 才持有宿主资源。
- Render 构建工作树和未挂载 DOM,Commit 再同步修改页面。
- 本章只支持首次渲染,还没有旧树可供复用。
下一篇会保留已提交 Fiber 树,并用 alternate 连接新旧工作节点。到那时,Fiber 才真正支持第二次渲染。