Fiber:协作式调度

摘要用一份可运行的最小实现复盘 Fiber 的协作式调度:如何把深度优先遍历拆成可暂停、可恢复、最终统一提交的工作单元。

文章目录6 节
  1. JSX 转虚拟 DOM
  2. 先用一次 DFS 跑完
  3. Fiber
  4. Concurrency
  5. Render 与 Commit
  6. 这份小实现省略了什么

Fiber 很容易被讲成一堆名词。我更想把它缩成一份能跑的小实现:从 JSX 出发,先造出 DOM,再把一次跑到底的遍历拆开。这不是 React 源码复刻,只保留理解 Fiber 必须的那条主线。Talk is cheap, show me the code。

JSX 转虚拟 DOM

先定一段有几层嵌套、但还能一眼看完的业务代码:

function App() {
    return(
        <ul>
            <li>
                1
                <ul>
                    <li>2</li>
                    <li>3</li>
                    <li>4</li>
                </ul>
            </li>
            <li>
                5
                <ul>
                    <li>6</li>
                    <li>7</li>
                    <li>8</li>
                </ul>
            </li>
            <li>9</li>
        </ul>
    );
}

自动 JSX runtime 会把 App 函数里的 JSX 编译成从 react/jsx-runtime 导入的 jsxjsxs 调用;classic runtime 则会生成 React.createElement。这一步只负责语法转换,不会建立 FiberTree,也不会在编译时执行 App

为了让后面的遍历代码保持简单,我手工把 App() 的结果归一化成一棵 demo vnode。它只借用了 type / props / children 这几个概念,不是 Babel 输出,也不是 React element 或 Fiber 的真实内部结构:

const vnode = {
  type: "ul",
  props: {
    children: [
      {
        type: "li",
        props: {
          children: [
            "1",
            {
              type: "ul",
              props: {
                children: [
                  {
                    type: "li",
                    props: { children: "2" },
                  },
                  {
                    type: "li",
                    props: { children: "3" },
                  },
                  {
                    type: "li",
                    props: { children: "4" },
                  },
                ],
              },
            },
          ],
        },
      },
      {
        type: "li",
        props: {
          children: [
            "5",
            {
              type: "ul",
              props: {
                children: [
                  {
                    type: "li",
                    props: { children: "6" },
                  },
                  {
                    type: "li",
                    props: { children: "7" },
                  },
                  {
                    type: "li",
                    props: { children: "8" },
                  },
                ],
              },
            },
          ],
        },
      },
      { type: "li", props: { children: "9" } },
    ],
  },
};

JSX 的实际编译结果可以直接在 Babel REPL 里看:

https://babeljs.io/repl

230cbb8384.png

jsx/jsxs 会创建 element 描述;组件函数要等到 render 时才会执行。本文跳过这层运行时细节,直接使用上面的手工 vnode 作为遍历输入。

70d1789df4.png

先用一次 DFS 跑完

先不管 Fiber。拿到这棵树以后,一次 DFS 就能把它变成真实 DOM:

function createDomElement(vnode) {
  // 创建元素节点
  const element = document.createElement(vnode.type);

  // 如果有子节点,递归处理每个子节点
  const children = vnode.props.children;
  if (Array.isArray(children)) {
    children.forEach(child => {
      if (typeof child === 'object') {
        element.appendChild(createDomElement(child));  // 对象表示子元素节点
      } else if (typeof child === 'string') {
        element.appendChild(document.createTextNode(child));  // 字符串表示文本节点
      }
    });
  } else if (typeof children === 'string') {
    element.appendChild(document.createTextNode(children));
  }

  // 返回构建好的 DOM 元素
  return element;
}

// 获取根节点
const root = document.querySelector("#root");

// 清空原有内容
root.innerHTML = '';

// 创建 DOM 并挂载
const realDom = createDomElement(vnode);
root.appendChild(realDom);

到这里,一个只支持首次挂载的声明式 UI 雏形已经出来了。它还没有属性和事件更新、组件状态、diff、生命周期等机制;这里只继续处理“深度优先遍历会长时间占住主线程”这一件事。

Fiber

上面的 DFS 有个硬伤:它一旦开始就会跑到底。虚拟 DOM 足够大时,主线程一直被占着,交互只能等。要中途让出主线程,需要解决两件事:把大遍历拆成小任务,以及在恢复时知道下一步去哪里。

这里把一个 Fiber 当作最小的工作单元。原先的 vnode 只有父结点持有 children,对可暂停的遍历不够用。比如组件挂载按后序遍历触发 componentDidMount,“结点 1”处理完后,它得能找回父结点,再继续兄弟分支。

cf3559b9ea.png

我给每个结点加三个指针:

5398af0106.png

  • child 第一个孩子结点
  • sibling 相邻的右兄弟结点
  • return 父结点

还是深度优先,只是这次每处理一个结点就把下一个结点返回:

function performUnitOfWork(fiber) {
    let children = fiber.props.children || [];
    let previousSibling = null;

    // 仅当非文本节点时处理子节点
    if (fiber.type !== "txt") {
        for (let i = 0; i < children.length; i++) {
            let child = children[i];

            let newFiber = {
                type: typeof child === "string" ? "txt" : child.type,
                props:
                    typeof child === "string"
                        ? { nodeValue: child }
                        : child.props || {},
                child: null,
                sibling: null,
                return: fiber,
                stateNode: null,
                effectTag: "PLACEMENT"
            };

            if (i === 0) {
                fiber.child = newFiber;
            } else {
                previousSibling.sibling = newFiber;
            }
            previousSibling = newFiber;
        }
    }

    // 继续深度优先遍历
    if (fiber.child) {
        return fiber.child;
    }

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

    return null;
}

这段代码一边补齐 child / sibling / return,一边返回下一个工作单元。走到叶子后,先找兄弟;没有兄弟就沿 return 往上找。现场就在这三个指针里,不需要靠 JS 调用栈把整次递归压住。

这三个指针最容易在代码里看懂、合上文章又忘掉。下面把它们变成一棵可以单步执行的 FiberTree。每点一次,只处理一个结点;把“每帧预算”调小,再按“执行一个时间片”,就能看到遍历如何在任意结点暂停,下一帧从现场继续。

动手实验 · 约 30 秒

沿着 child / sibling / return 走一遍

橙色是当前工作单元,深色已经完成,虚线仍在等待。

root A B C A1 A2 B1 B2 child ↓ sibling → return ↑
0 / 8 已让出 0 次

下一个工作单元是 root。它有 child,所以处理后先向下走。

Concurrency

这里说的是 concurrency,不是 parallel。JavaScript 仍然在一条线上执行,关键只是及时停下,把主线程还给浏览器。

下面用 requestIdleCallback 写一个最小教学调度器。它只演示“有剩余时间就继续、时间不够就让出”的形状,不是 React 当前 Scheduler 的实现;真实 Scheduler 还要处理优先级、过期任务和宿主环境调度。

let nextUnitOfWork = { text: "根结点" };

function workLoop(deadline) {
    let shouldYield = false;
    while (nextUnitOfWork && !shouldYield) {
        nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
        shouldYield = deadline.timeRemaining() < 1;
    }
    requestIdleCallback(workLoop);
}

requestIdleCallback(workLoop);

function performUnitOfWork(nextUnitOfWork) {
    const next = { text: "下一个结点" };
    console.log(next);
    return next;
}

再加一个 setInterval 模拟持续发生的用户交互。这里的 performUnitOfWork 故意永远返回新结点,相当于一棵永远跑不完的树:

<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Document</title>
    </head>
    <body>
        <div></div>

        <script>
            let nextUnitOfWork = { text: "根结点" };

            function workLoop(deadline) {
                let shouldYield = false;
                while (nextUnitOfWork && !shouldYield) {
                    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
                    shouldYield = deadline.timeRemaining() < 1;
                }
                requestIdleCallback(workLoop);
            }

            requestIdleCallback(workLoop);

            function performUnitOfWork(nextUnitOfWork) {
                const next = { text: "下一个结点" };
                console.log(next);
                return next;
            }

            let i = 1;
            const dom = document.querySelector("div");
            setInterval(() => {
                dom.innerHTML = i++;
            }, 20);
        </script>
    </body>
</html>

工作永远做不完,但页面上的数字还在更新。这就是切片要达到的效果:不是让计算消失,而是别让它一口气霸占主线程。

Render 与 Commit

前面只是在内存里造 Fiber,还没有改 DOM。React 官方通常把一次更新分成 render 与 commit:render 会调用组件、计算差异并准备变更,commit 再把必要修改写入 DOM。

并发模式下的 render 可能在工作单元之间暂停、继续或放弃,但不是每次更新都会被时间切片;同步更新仍会一次完成。DOM 变更所在的 commit 则保持同步,浏览器不会绘制一棵只改到一半的 DOM 树。

function commitWork(fiber) {
    if (!fiber) return;
    if (fiber.effectTag === "PLACEMENT" && fiber.stateNode) {
        fiber.return.stateNode.append(fiber.stateNode);
    }

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

把教学调度循环和 Commit 拼起来,就得到下面这份仅支持首次挂载的完整实现。容器本身单独建成 HostRoot Fiber,业务 vnode 作为它的孩子;这样 <ul> 会被创建后挂进 #root,而不是把 <li> 直接塞进容器。

<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>React Fiber</title>
    </head>
    <body>
        <div id="root"></div>

        <script>
            let nextUnitOfWork = null;
            let wipRoot = null;

            function workLoop(deadline) {
                let shouldYield = false;

                while (nextUnitOfWork && !shouldYield) {
                    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
                    shouldYield = deadline.timeRemaining() < 1;
                }

                if (!nextUnitOfWork && wipRoot) {
                    commitRoot();
                    console.log("渲染完成");
                }

                if (nextUnitOfWork) {
                    requestIdleCallback(workLoop);
                }
            }

            function performUnitOfWork(fiber) {
                console.log(`处理 fiber: ${fiber.type}`);

                if (!fiber.stateNode) {
                    // 判断是否为文本节点,并特别处理
                    if (fiber.type === "txt") {
                        fiber.stateNode = document.createTextNode(
                            fiber.props.nodeValue
                        );
                    } else {
                        fiber.stateNode = document.createElement(fiber.type);
                    }
                }

                let children = fiber.props.children || [];
                let previousSibling = null;

                // 仅当非文本节点时处理子节点
                if (fiber.type !== "txt") {
                    for (let i = 0; i < children.length; i++) {
                        let child = children[i];

                        let newFiber = {
                            type:
                                typeof child === "string" ? "txt" : child.type,
                            props:
                                typeof child === "string"
                                    ? { nodeValue: child }
                                    : child.props || {},
                            child: null,
                            sibling: null,
                            return: fiber,
                            stateNode: null,
                            effectTag: "PLACEMENT"
                        };

                        if (i === 0) {
                            fiber.child = newFiber;
                        } else {
                            previousSibling.sibling = newFiber;
                        }
                        previousSibling = newFiber;
                    }
                }

                // 继续深度优先遍历
                if (fiber.child) {
                    console.log("一个 fiber 处理完成,可以被用户打断\n");
                    return fiber.child;
                }

                // 查找下一个工作单元
                let nextFiber = fiber;
                while (nextFiber) {
                    if (nextFiber.sibling) {
                        console.log("一个 fiber 处理完成,可以被用户打断\n");
                        return nextFiber.sibling;
                    }
                    nextFiber = nextFiber.return;
                }

                console.log("一个 fiber 处理完成,可以被用户打断\n");
                return null;
            }

            function commitRoot() {
                console.log("==========\n\n");
                console.log("Commit phase\n\n");

                commitWork(wipRoot.child);
                wipRoot = null;
            }

            function commitWork(fiber) {
                if (!fiber) return;
                if (fiber.effectTag === "PLACEMENT" && fiber.stateNode) {
                    fiber.return.stateNode.append(fiber.stateNode);
                }

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

            function h(type, children) {
                return {
                    type,
                    props: { children }
                };
            }

            const vnode = h("ul", [
                h("li", [
                    "1",
                    h("ul", [
                        h("li", "2"),
                        h("li", "3"),
                        h("li", "4")
                    ])
                ]),
                h("li", [
                    "5",
                    h("ul", [
                        h("li", "6"),
                        h("li", "7"),
                        h("li", "8")
                    ])
                ]),
                h("li", "9")
            ]);

            wipRoot = {
                type: "HOST_ROOT",
                props: { children: [vnode] },
                child: null,
                sibling: null,
                return: null,
                stateNode: document.querySelector("#root")
            };
            nextUnitOfWork = wipRoot;
            requestIdleCallback(workLoop);
        </script>
    </body>
</html>

这份小实现省略了什么

代码能跑,不等于它和真实 React 的流程一模一样。上面的完整 vnode 是为了教学手工准备的;真实 React 不会先物化一整棵 VirtualDomTree,再把它整体翻译成 FiberTree。

JSX runtime 先产生 element 描述。render 期间,React 调用类组件的 render 或函数组件,消费它们返回的 element,并与当前 FiberTree 对比,创建或复用 work-in-progress Fiber。element 更像不可变的 UI 描述,Fiber 则是可变的工作记录,保存状态、指针、优先级和副作用;后者不是前者简单“加三个指针”的扩展。

Fiber 也没有绕开 Run-to-completion。它能在两个工作单元之间暂停,但不能把正在执行的一个函数从中间剪开。这也是对业务代码最有用的提醒:一个组件不要吞下整个页面的工作。单个工作单元本身过大,调度器也没办法替你把它变小。