各位好,欢迎来到今天的“React 内部架构深度解剖”讲座。
把手机静音,把咖啡杯放下。今天我们不聊怎么用 useEffect 做防抖,也不聊怎么用 memo 避免不必要的渲染。今天,我们要揭开 React 那层神秘的面纱,去窥探那个被称为“调度器”的大脑,以及它如何与浏览器底层的“微任务”进行一场惊心动魄的竞速。
准备好了吗?让我们把键盘敲得像钢琴一样响。
第一部分:舞台的规则——事件循环与任务队列
在 React 里,我们常说“渲染”和“更新”。但在浏览器这个巨大的舞台背后,真正的导演是事件循环。
想象一下,你是一个忙碌的舞台经理。你面前有两个篮子:一个叫“宏任务”,一个叫“微任务”。
宏任务就像是那些大场面:解析 HTML,执行主线程的脚本,比如 setTimeout,比如用户点击鼠标,比如网络请求完成。这些是大老板,一个接一个地来,耗时较长。
微任务就像是那些跑腿的小弟:Promise.then,MutationObserver,还有 queueMicrotask。这些家伙特别快,特别急。当宏任务队列里的“大老板”刚一退场,微任务队列里的“小弟”们就会瞬间冲出来,把活儿干完,而且要全部干完,不能留尾巴,才能允许“大老板”入场。
所以,执行顺序大概是:宏任务 -> 微任务(全部) -> 宏任务 -> 微任务(全部) -> 浏览器绘制。
这就是我们理解 React 调度器的基石。
第二部分:React 的调度器——那个看不见的导演
React 并不直接调用浏览器 API。它有一个独立的包,叫 scheduler。这玩意儿比浏览器的 setTimeout 强大得多,也狡猾得多。
scheduler 的核心任务只有一个:决定什么时候干活。
如果用户在疯狂点击,React 就得赶紧干活(高优先级)。如果页面在后台,React 就可以慢慢来,甚至歇会儿(低优先级)。它利用了浏览器的 requestIdleCallback(如果支持)或者 setTimeout 来实现时间切片。
但是,React 的渲染过程分两个阶段:
- Render 阶段:计算发生了什么变化。这是纯计算,不涉及 DOM 操作,可以被打断,也可以被取消。
- Commit 阶段:把变化应用到 DOM 上。这是同步的,一旦开始就不能停。
我们今天要聊的,就是 Commit 阶段 发生的事情,以及那个让无数开发者头秃的“执行顺序竞争”。
第三部分:渲染后的“副作用”——useLayoutEffect 与 useEffect
React 为了解决 DOM 操作的问题,给了我们两个钩子:useLayoutEffect 和 useEffect。
- 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 调度器开始工作:
- Render 阶段:React 计算出
count变了,准备重新渲染。这是同步的,JS 栈还没空。 - Commit 阶段开始:React 把 DOM 更新了。
- useLayoutEffect 执行:因为它是同步的,所以它紧接着 DOM 更新就跑起来了。它会阻塞浏览器,直到它跑完。在这个例子里,我们加了
while循环来模拟耗时,所以它会死死地把浏览器钉在原地。 - 微任务队列执行:当
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 的机制:
- 它会暂停整个 React 的调度器。
- 它会强制执行 Render 阶段和 Commit 阶段。
- 它会强制执行所有
useLayoutEffect。 - 它会清空微任务队列(因为它在主线程上跑完才返回)。
- 然后它才把控制权交还给浏览器。
所以,如果你在 flushSync 里面写 Promise,那个 Promise 回调会在 flushSync 返回之前就执行完毕。这通常用于测试或者极其特殊的状态同步场景,比如你需要在点击按钮的瞬间,让按钮的文本立刻变成“已点击”,而不是等待下一帧。
第七部分:React 18 的并发模式与调度器的进化
React 18 引入了并发模式,这让调度器和微任务的关系变得更加复杂。
以前,React 是单线程的,渲染就是一个接一个的。现在,React 可以同时准备多个渲染。
想象一下,你正在渲染一个列表(Render 阶段 A),同时用户疯狂点击了按钮(Render 阶段 B)。
React 的调度器会根据优先级:
- 如果 B 是高优先级,React 会暂停 A,去跑 B。
- 如果 B 完成了,React 会继续跑 A。
那么,微任务呢?
微任务是由浏览器触发的。如果 React 在 Render 阶段(还没到 Commit)就暂停了,微任务会插入进来吗?
会的。
如果 React 在 Render 阶段暂停了,浏览器事件循环会跑。如果这时候有一个 setTimeout 或者用户交互触发了微任务,React 必须处理它们。因为 React 的调度器通常也是通过 setTimeout 或 requestIdleCallback 实现的,它其实也是嵌入在浏览器的事件循环里的。
所以,在 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 操作、动画或者状态更新的代码时,脑子里要有这个时间轴:
- Render (同步)
- Commit (同步)
- useLayoutEffect (同步,阻塞微任务)
- 微任务队列 (Promise.then, MutationObserver) (浏览器接管)
- 浏览器绘制 (浏览器接管)
- useEffect (异步,微任务之后)
记住这个顺序,你就不会再被 setTimeout 和 Promise 的执行时机搞晕了。
最后,送给大家一句 React 的格言:“相信调度器。”
不要试图去猜测 React 什么时候跑,不要试图去手动控制微任务队列。React 的调度器比你更懂浏览器,比你更懂性能。你要做的,就是写出干净的、声明式的代码,让 React 在后台为你安排好这一切。
好了,今天的讲座就到这里。下课!记得写代码的时候,别让 Promise 堆在角落里发霉。