React 调度器与微任务(Microtask)的协同:分析渲染后副作用与 Promise 回调的执行顺序竞争

各位好,欢迎来到今天的“React 内部架构深度解剖”讲座。

把手机静音,把咖啡杯放下。今天我们不聊怎么用 useEffect 做防抖,也不聊怎么用 memo 避免不必要的渲染。今天,我们要揭开 React 那层神秘的面纱,去窥探那个被称为“调度器”的大脑,以及它如何与浏览器底层的“微任务”进行一场惊心动魄的竞速。

准备好了吗?让我们把键盘敲得像钢琴一样响。


第一部分:舞台的规则——事件循环与任务队列

在 React 里,我们常说“渲染”和“更新”。但在浏览器这个巨大的舞台背后,真正的导演是事件循环

想象一下,你是一个忙碌的舞台经理。你面前有两个篮子:一个叫“宏任务”,一个叫“微任务”。

宏任务就像是那些大场面:解析 HTML,执行主线程的脚本,比如 setTimeout,比如用户点击鼠标,比如网络请求完成。这些是大老板,一个接一个地来,耗时较长。

微任务就像是那些跑腿的小弟:Promise.thenMutationObserver,还有 queueMicrotask。这些家伙特别快,特别急。当宏任务队列里的“大老板”刚一退场,微任务队列里的“小弟”们就会瞬间冲出来,把活儿干完,而且要全部干完,不能留尾巴,才能允许“大老板”入场。

所以,执行顺序大概是:宏任务 -> 微任务(全部) -> 宏任务 -> 微任务(全部) -> 浏览器绘制。

这就是我们理解 React 调度器的基石。


第二部分:React 的调度器——那个看不见的导演

React 并不直接调用浏览器 API。它有一个独立的包,叫 scheduler。这玩意儿比浏览器的 setTimeout 强大得多,也狡猾得多。

scheduler 的核心任务只有一个:决定什么时候干活

如果用户在疯狂点击,React 就得赶紧干活(高优先级)。如果页面在后台,React 就可以慢慢来,甚至歇会儿(低优先级)。它利用了浏览器的 requestIdleCallback(如果支持)或者 setTimeout 来实现时间切片。

但是,React 的渲染过程分两个阶段:

  1. Render 阶段:计算发生了什么变化。这是纯计算,不涉及 DOM 操作,可以被打断,也可以被取消。
  2. Commit 阶段:把变化应用到 DOM 上。这是同步的,一旦开始就不能停。

我们今天要聊的,就是 Commit 阶段 发生的事情,以及那个让无数开发者头秃的“执行顺序竞争”。


第三部分:渲染后的“副作用”——useLayoutEffect 与 useEffect

React 为了解决 DOM 操作的问题,给了我们两个钩子:useLayoutEffectuseEffect

  • useEffect:这是“异步”的。它在微任务队列执行完、浏览器开始绘制之前的那一刹那——也就是绘制之后——才跑。你可以在里面做网络请求,不用担心阻塞渲染。
  • useLayoutEffect:这是“同步”的。它在 Commit 阶段完成 DOM 更新后,但在浏览器绘制之前立即执行。它的名字叫“布局”,因为它允许你读取 DOM 的最新尺寸。

关键点来了: useLayoutEffect 是同步执行的,但它是在微任务队列之前执行的。


第四部分:核心冲突——Promise 回调 vs. useLayoutEffect

现在,我们引入主角:Promise

当你写 new Promise(resolve => resolve()).then(...) 时,这个 .then 回调会被推入微任务队列。

这里就产生了一个经典的竞态场景:渲染后的副作用Promise 回调,到底谁先跑?

让我们来做个实验。代码如下:

import React, { useEffect, useLayoutEffect, useState } from 'react';

const RaceDemo = () => {
  const [count, setCount] = useState(0);
  const [log, setLog] = useState<string[]>([]);

  const addLog = (msg: string) => {
    setLog(prev => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`]);
  };

  React.useEffect(() => {
    addLog('useEffect: 开始');
    // 模拟一个耗时的同步操作
    const start = performance.now();
    while (performance.now() - start < 100) {
      // 忙着呢
    }
    addLog('useEffect: 结束 (微任务队列中)');
  }, [count]);

  React.useLayoutEffect(() => {
    addLog('useLayoutEffect: 开始');
    // 模拟一个耗时的同步操作
    const start = performance.now();
    while (performance.now() - start < 100) {
      // 忙着呢
    }
    addLog('useLayoutEffect: 结束 (微任务队列前)');
  }, [count]);

  const handleClick = () => {
    setCount(prev => prev + 1);

    // 这里的 Promise 是在渲染逻辑中创建的
    new Promise<void>((resolve) => {
      addLog('Promise.then: 开始 (微任务队列中)');
      setTimeout(() => {
        addLog('Promise.then: 结束 (微任务队列中)');
        resolve();
      }, 0);
    });
  };

  return (
    <div style={{ padding: 20 }}>
      <h1>Count: {count}</h1>
      <button onClick={handleClick}>增加计数</button>
      <div style={{ marginTop: 20, fontFamily: 'monospace', background: '#f0f0f0', padding: 10 }}>
        <pre>{log.map((l, i) => <div key={i}>{l}</div>)}</pre>
      </div>
    </div>
  );
};

export default RaceDemo;

执行顺序分析

当你点击按钮时,React 调度器开始工作:

  1. Render 阶段:React 计算出 count 变了,准备重新渲染。这是同步的,JS 栈还没空。
  2. Commit 阶段开始:React 把 DOM 更新了。
  3. useLayoutEffect 执行:因为它是同步的,所以它紧接着 DOM 更新就跑起来了。它会阻塞浏览器,直到它跑完。在这个例子里,我们加了 while 循环来模拟耗时,所以它会死死地把浏览器钉在原地。
  4. 微任务队列执行:当 useLayoutEffect 结束后,浏览器才终于松了一口气,回头看一眼任务队列。此时,Promise.then 的回调被取出来执行了。

结论:
useLayoutEffect > 微任务队列 (Promise.then) > useEffect

这意味着什么?
这意味着如果你在 useLayoutEffect 里创建了一个 Promise,然后在这个 Promise 的回调里去读取 DOM,你读到的 DOM 和你在 useLayoutEffect 里刚修改完的 DOM 是一样的。因为 Promise 回调还没跑呢!

但是,如果你在 useEffect 里创建 Promise,那情况就反过来了。useEffect 是在微任务队列之后才跑的。


第五部分:深入 Scheduler——调度器的优先级博弈

上面的例子比较简单。但在实际开发中,React 的调度器并没有那么好猜。

React 引入了 Scheduler 包,它不仅仅是个计时器,它是个精算师。

让我们看看 Scheduler 是如何处理这种竞争的。

// 这是 React 内部调度器的一个简化示意
import { unstable_scheduleCallback, unstable_shouldYield } from 'scheduler';

function workLoop() {
  // 如果还有任务,且当前时间允许(没到过期时间),继续调度
  while (tasks.length > 0) {
    const task = tasks.shift();

    // 执行任务
    task.callback();

    // 关键点:React 会检查是否应该让出主线程
    // 如果有更高优先级的任务来了(比如用户点击),或者时间片用完了
    if (unstable_shouldYield()) {
      // 把剩下的任务放回队列,下次再跑
      tasks.push(...remainingTasks);
      return;
    }
  }
}

当你在渲染函数里创建了一个 Promise,这个 Promise 的 .then 回调会被放入微任务队列。但是,微任务队列是由浏览器管理的,而不是 React 直接管理的。

React 的调度器在 Commit 阶段结束后,会等待浏览器把微任务队列清空吗?

答案是否定的。

React 调度器关注的是渲染周期。一旦 Commit 阶段结束,React 就认为它的工作完成了。至于那个 Promise 回调什么时候跑,那是浏览器事件循环的事,React 不操心。

但是,这带来了一个副作用

如果你在渲染函数里(或者在 useLayoutEffect 里)创建了一个异步操作,并且这个操作依赖于渲染的结果,你可能会遇到“竞态条件”。

案例演示:脏数据

const RaceConditionDemo = () => {
  const [data, setData] = useState(null);

  // 这里有个坑
  React.useLayoutEffect(() => {
    // 假设我们根据当前 DOM 元素的高度来决定数据
    const height = document.getElementById('target')?.offsetHeight;

    // 哎呀,我这里直接去取数据了,而且用的是 async/await
    // 实际上这个 Promise 是在微任务队列里跑的,useEffect 可能还没跑
    fetch('/api/get-data').then(res => res.json()).then(result => {
      // 此时,如果用户在别处点击了按钮,data 可能已经被更新了
      // 但我们这里拿到了旧数据,或者新数据,取决于谁先抢到 DOM
      console.log('Got data:', result);
    });
  }, []);

  return <div id="target">Hello World</div>;
};

在这个例子中,fetch 是异步的。如果你在 useLayoutEffect 里写逻辑,而 useEffect 里的逻辑也依赖同样的数据,或者 UI 状态,你就得非常小心。因为 useLayoutEffect 会阻塞微任务,导致 Promise 的回调延后执行。


第六部分:flushSync——打破规则的暴力美学

既然有竞争,那有没有办法强制规则?

有的。React 提供了一个叫 flushSync 的 API。这东西就像个暴徒,它强行把 React 的渲染过程变成同步的,并且强制把所有副作用都执行完,不让你有喘息的机会。

让我们看看 flushSync 如何改变顺序。

import { flushSync } from 'react-dom';

const FlushSyncDemo = () => {
  const [count, setCount] = useState(0);
  const [logs, setLogs] = useState([]);

  const handleClick = () => {
    // 强制同步更新状态
    flushSync(() => {
      setCount(prev => prev + 1);
    });

    // 这时候,count 已经是 1 了
    // useLayoutEffect 已经跑完了
    // 浏览器刚刚开始绘制

    // 如果这时候有一个 Promise.then...
    new Promise(resolve => {
      console.log('Promise then runs immediately after flushSync');
      resolve();
    });
  };

  return <button onClick={handleClick}>Count: {count}</button>;
};

flushSync 的机制:

  1. 它会暂停整个 React 的调度器。
  2. 它会强制执行 Render 阶段和 Commit 阶段。
  3. 它会强制执行所有 useLayoutEffect
  4. 它会清空微任务队列(因为它在主线程上跑完才返回)。
  5. 然后它才把控制权交还给浏览器。

所以,如果你在 flushSync 里面写 Promise,那个 Promise 回调会在 flushSync 返回之前就执行完毕。这通常用于测试或者极其特殊的状态同步场景,比如你需要在点击按钮的瞬间,让按钮的文本立刻变成“已点击”,而不是等待下一帧。


第七部分:React 18 的并发模式与调度器的进化

React 18 引入了并发模式,这让调度器和微任务的关系变得更加复杂。

以前,React 是单线程的,渲染就是一个接一个的。现在,React 可以同时准备多个渲染。

想象一下,你正在渲染一个列表(Render 阶段 A),同时用户疯狂点击了按钮(Render 阶段 B)。

React 的调度器会根据优先级:

  1. 如果 B 是高优先级,React 会暂停 A,去跑 B。
  2. 如果 B 完成了,React 会继续跑 A。

那么,微任务呢?

微任务是由浏览器触发的。如果 React 在 Render 阶段(还没到 Commit)就暂停了,微任务会插入进来吗?

会的。

如果 React 在 Render 阶段暂停了,浏览器事件循环会跑。如果这时候有一个 setTimeout 或者用户交互触发了微任务,React 必须处理它们。因为 React 的调度器通常也是通过 setTimeoutrequestIdleCallback 实现的,它其实也是嵌入在浏览器的事件循环里的。

所以,在 React 18 的并发模式下:

  • Render 阶段:可能会被中断。微任务可以插入。
  • Commit 阶段:不可中断。微任务队列在 Commit 结束后执行。

这就意味着,你可能在渲染过程中就执行了某些 Promise 回调,这会改变你组件的状态,从而影响后续的渲染结果。

这就是所谓的“副作用在渲染期间发生”。React 18 对此非常敏感。它警告不要在渲染期间(Render 阶段)调用 setState 或其他副作用,因为这可能导致不可预测的渲染行为。


第八部分:实战中的陷阱与最佳实践

说了这么多理论,咱们来点干货。在实际开发中,怎么避免被调度器和微任务搞晕?

1. 不要在渲染函数里创建 Promise

这是新手最容易犯的错误。

// ❌ 错误示范
function BadComponent() {
  // 这个函数在每次渲染时都会创建一个新的 Promise
  // 即使数据没变,Promise 也变了
  useEffect(() => {
    fetch('/api').then(res => ...); 
  }, []); // 依赖项是空的,但 Promise 实例变了!
}

原因: 每次渲染,useEffect 的依赖项检查都会失败(因为 new Promise() 是一个新的引用)。这会导致 useEffect 在每次渲染时都运行,不仅浪费资源,还可能因为微任务的竞争导致逻辑错误。

修正: 把异步逻辑放在组件外部,或者使用 useMemo 缓存 Promise。

2. 优先使用 useLayoutEffect 进行 DOM 测量

如果你需要获取 DOM 的宽高来计算布局,请务必用 useLayoutEffect

function MyComponent() {
  const ref = React.useRef<HTMLDivElement>(null);
  const [size, setSize] = useState({ width: 0, height: 0 });

  // ✅ 正确示范
  React.useLayoutEffect(() => {
    if (ref.current) {
      setSize({
        width: ref.current.offsetWidth,
        height: ref.current.offsetHeight
      });
    }
  }, []); // 确保依赖项正确

  return <div ref={ref}>Hello</div>;
}

为什么不用 useEffect 因为 useEffect 在微任务之后执行。如果你在 useEffect 里读取宽高,浏览器已经绘制完第一帧了。此时,如果父组件的布局发生变化,或者用户缩放了窗口,你的宽高数据可能已经过时了。

3. 理解 requestAnimationFrame 的位置

requestAnimationFrame 也是一个微任务吗?不完全是。它在微任务队列之后,浏览器绘制之前执行。

所以,如果你想在绘制前做一些准备工作,但又不想像 useLayoutEffect 那样阻塞主线程(虽然它也是同步的,但它是 React 内部的同步),你可以用 requestAnimationFrame

React.useEffect(() => {
  const timer = requestAnimationFrame(() => {
    // 这里在微任务之后,绘制之前
    console.log('RAF: Before paint');
  });
  return () => cancelAnimationFrame(timer);
}, []);

4. 避免在 useLayoutEffect 中进行网络请求

这是性能大忌。useLayoutEffect 会阻塞浏览器。如果你在 useLayoutEffect 里发个请求,那用户会感觉页面卡顿了 200ms。

正确姿势:

  • 读取 DOM:用 useLayoutEffect
  • 写入 DOM / 网络请求:用 useEffect
  • 动画:用 requestAnimationFrame 或 CSS 动画。

第九部分:总结——调度器与微任务的共舞

好了,各位,咱们把镜头拉远。

React 调度器、微任务、渲染、副作用,它们就像是一支配合默契的管弦乐团。

  • 调度器 是指挥家,它决定什么时候起拍。
  • 渲染 是乐器的调音,是内部的计算。
  • Commit 是乐手们同时奏响的那一刻。
  • 微任务 是乐手们中间的一次深呼吸,或者是快速的换弦动作。

当你写代码时,尤其是写那些涉及 DOM 操作、动画或者状态更新的代码时,脑子里要有这个时间轴:

  1. Render (同步)
  2. Commit (同步)
  3. useLayoutEffect (同步,阻塞微任务)
  4. 微任务队列 (Promise.then, MutationObserver) (浏览器接管)
  5. 浏览器绘制 (浏览器接管)
  6. useEffect (异步,微任务之后)

记住这个顺序,你就不会再被 setTimeoutPromise 的执行时机搞晕了。

最后,送给大家一句 React 的格言:“相信调度器。”

不要试图去猜测 React 什么时候跑,不要试图去手动控制微任务队列。React 的调度器比你更懂浏览器,比你更懂性能。你要做的,就是写出干净的、声明式的代码,让 React 在后台为你安排好这一切。

好了,今天的讲座就到这里。下课!记得写代码的时候,别让 Promise 堆在角落里发霉。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注