Day15 拆过 Fiber,说它让渲染"可中断";Day16 顺着 Fiber 讲了 Hooks 的跨渲染记忆从哪来。今天把"可中断"三个字兑现成真实产品能力,也就是 React 18 的并发特性。官方叫 Concurrent Features,民间还习惯叫 Concurrent Mode。名字里有 Concurrent,但它和"多线程并行"没有一毛钱关系,JS 是单线程的。React 做的是另一件更微妙的事:把一次渲染拆成可以随时暂停、让位、重来的多个片段,然后按优先级决定谁先谁后。
1. 技术难点:同步渲染是一把焊死主线程的锁
先回到没有并发时的渲染模型。一次 setState 触发一次渲染,React 从根 Fiber 出发,把需要更新的整棵组件树一口气全部执行完,再统一提交 DOM。这个流程是同步的、不可中断的:render phase 一旦开始,必须跑完才能碰下一件事。
想清楚这个模型的代价,用一个经典场景:搜索框过滤 2 万条数据。onChange 每敲一个字都触发一次完整渲染,单次渲染算上过滤、diff、虚拟 DOM 重建要 100ms 以上。打字是个高频事件,两次按键间隔可能只有 80ms,于是输入事件永远排在渲染后面排队。表现就是:input 里字母半天不出现、光标闪烁停滞、列表一顿一顿。浏览器连一帧都画不出来,因为主线程被 React 占着。
把问题抽象出来,同步渲染有三宗罪:
- 长任务独占主线程。渲染吃掉全部 CPU 时间片,浏览器没有机会执行帧绘制和事件处理,交互被饿死。
- 所有更新一视同仁。用户打字(紧急)和列表过滤结果(不紧急)走同一条同步路径,紧急的等不紧急的。
- 没有中间态。旧 UI 和"渲染中的新 UI"之间没有过渡,要么卡在旧界面,要么等新界面整体就绪后跳变。
难点本质在这里:渲染一旦开始就必须跑完,CPU 不够用时它选择牺牲交互而不是让出。解决它需要三个能力:渲染能被打断并让出主线程;被打断后能干净地恢复或直接放弃(不产出脏 UI);高优先级更新能插队,低优先级更新能被推迟甚至重来。
2. 完整解法:可中断渲染 + Lane 优先级 + 三个并发 API
2.1 地基:Fiber 让"打断"成为可能
为什么 React 17 之前做不到可中断?因为递归。旧的 reconcile 用递归遍历组件树,函数调用栈一旦入栈,语言层面就无法从中间退出再回来。Fiber 的改造(Day15 讲过)本质是把递归改成链表上的迭代 :每个 Fiber 节点只做一小份工作,做完通过 return / sibling / child 指针找到下一个节点。工作单元小到可以随时放下,链表结构让"下次从哪里继续"有据可查。
于是 render phase 变成了一个可以被任意暂停的循环:
scss
while (workInProgress !== null && !shouldYield()) {
workInProgress = performUnitOfWork(workInProgress); // 处理一个 Fiber
}
shouldYield() 返回 true,循环就停在这里,workInProgress 指针记住了现场。等主线程有空了,从指针处接着干。这就像读一本长篇小说,随时能夹书签走人,下次翻开不用从头找。
2.2 调度器:5ms 让出 + MessageChannel
谁来决定 shouldYield() 什么时候返回 true?React 内置的 Scheduler 包。它维护一个任务队列,每个渲染任务有个过期时间。Scheduler 用 MessageChannel 把任务排到宏任务里执行,每处理一段时间就检查一次当前帧还剩多少时间(performance.now() 对比帧预算)。超过预算(默认约 5ms)就暂停当前工作,把控制权交还浏览器去绘制帧、处理输入。
5ms 是刻意选的:一帧 16.6ms,React 只占用不到三分之一,剩下的留给浏览器。用户感受到的效果是"页面一直能响应",而不是"卡一下,然后猛地出结果"。
2.3 Lane:优先级不是数字,是位图
有了中断能力,还要回答"谁先谁后"。React 没有用简单的数字优先级,而是一组叫 Lane(车道) 的位图。一个 31 位的二进制数,每一位代表一条车道,从 SyncLane(最高)到 IdleLane(最低),可以同时标记多个 lane。更新的优先级用位运算表达:lane & allLanes 判断是否包含某级,getHighestPriorityLane 取最高位。
车道分三档大方向:同步(SyncLane,如 flushSync)、连续输入(InputContinuousLane,如 onChange 里的更新)、默认与过渡(DefaultLane / TransitionLane,如 startTransition 包起来的更新)。渲染时 React 永远先处理高 lane 的更新;低 lane 的更新跑到一半被高 lane 打断,直接丢弃重来(反正 Fiber 树还在,重跑成本只是时间)。
2.4 三个并发 API:startTransition、useDeferredValue、Suspense
可中断和调度是引擎,API 是方向盘。React 18 把它们暴露成三个面向开发者的工具:
startTransition:手动把不紧急的状态更新标记为"过渡"。标记后这个更新走 TransitionLane,可被任何紧急更新打断。
jsx
import { useState, useTransition } from "react";
export default function Tabs() {
const [tab, setTab] = useState("home");
const [isPending, startTransition] = useTransition();
function switchTab(next) {
// 不包:setTab 是紧急更新,切到重型 tab 时会卡住当前页面
// 包上:切换变成过渡,旧界面保持可交互,新内容在后台慢慢渲染
startTransition(() => setTab(next));
}
return (
<div>
<button onClick={() => switchTab("home")}>首页</button>
<button onClick={() => switchTab("heavy")}>重型报表</button>
{isPending ? <p style={{ opacity: 0.6 }}>正在准备视图...</p> : null}
{tab === "home" ? <Home /> : <HeavyReport />}
</div>
);
}
isPending 是附带福利:过渡进行中为 true,可以用来给旧 UI 加个"加载中"的视觉提示,而不用真的白屏等新内容。
useDeferredValue :声明式的"延迟值",适合自己控制不了 setState 调用点的场景。它返回一个值的滞后版本,React 会先用旧值渲染(保证响应),再用低优先级渲染新值。最经典的落地就是大列表过滤:
jsx
import { useState, useDeferredValue, useMemo } from "react";
const ALL_ITEMS = Array.from({ length: 20000 }, (_, i) => ({
id: i,
name: `商品 ${i}`,
}));
export default function SearchBox() {
const [keyword, setKeyword] = useState("");
// keyword 的"延迟副本":紧急渲染用 keyword,闲下来再渲染 deferredKeyword
const deferredKeyword = useDeferredValue(keyword);
const isStale = keyword !== deferredKeyword;
const list = useMemo(() => {
const kw = deferredKeyword.toLowerCase();
return ALL_ITEMS.filter((it) => it.name.includes(kw));
}, [deferredKeyword]);
return (
<div style={{ font: "14px/1.6 system-ui" }}>
<input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="过滤 2 万条数据,试试连打"
/>
<p style={{ opacity: isStale ? 0.45 : 1 }}>
{list.length} 条结果{isStale ? "(渲染中...)" : ""}
</p>
<ul style={{ maxHeight: 240, overflow: "auto" }}>
{list.slice(0, 80).map((it) => (
<li key={it.id}>{it.name}</li>
))}
</ul>
</div>
);
}
运行它然后飞快打字:input 里的字母即时回显(紧急更新),列表区域显示半透明"渲染中"状态并慢慢追上来(过渡更新反复被按键打断,直到你停顿才真正算完)。React 18 之前这个组件每敲一个字都会白屏卡顿。useDeferredValue 与 startTransition 的区别只有一个:前者延迟的是值 (读它的一方重渲染),后者延迟的是更新本身;效果等价,前者代码侵入更小。
Suspense + use :渲染可以"等待"。配合并发,Suspense 让组件在异步数据未就绪时先渲染 fallback,数据到达后无缝补上,不必用 loading 状态手搓。React 19 的 use() 可以直接接收 promise,在渲染中挂起:
jsx
import { Suspense, use } from "react";
const fetchProfile = () =>
fetch("/api/profile").then((r) => r.json());
function Profile() {
const data = use(fetchProfile()); // 数据没到就挂起,让 Suspense 接管
return <div>{data.name} · {data.title}</div>;
}
export default function App() {
return (
<Suspense fallback={<div>加载中...</div>}>
<Profile />
</Suspense>
);
}
2.5 代价:tearing 与 useSyncExternalStore
可中断渲染带来一个隐蔽的新问题。既然同一时刻可以存在"旧版本 UI 正在展示、新版本 UI 正在后台渲染"两个世界,那么外部 store(Redux、Zustand、自研 store)的值可能在一次渲染中间发生变化。两个组件在同一轮渲染里读到不同版本的值,UI 就撕裂了(tearing)。React 的解法是 useSyncExternalStore:渲染期间反复调用 getSnapshot 校验,值变了就放弃本轮重新渲染,保证单次渲染内看到的 store 版本一致:
jsx
import { useSyncExternalStore } from "react";
const store = {
state: 0,
listeners: new Set(),
subscribe(listener) {
store.listeners.add(listener);
return () => store.listeners.delete(listener);
},
getSnapshot() {
return store.state;
},
inc() {
store.state++;
store.listeners.forEach((l) => l());
},
};
function Counter() {
const count = useSyncExternalStore(
store.subscribe,
store.getSnapshot
);
return (
<button onClick={() => store.inc()}>
点击了 {count} 次
</button>
);
}
这个 API 取代了老的 useSyncExternalStore 前身(useMutableSource)和无数手写 useEffect + setState 订阅模式,是 React 18 给第三方状态库的"标准插座"。
3. 应用场景
什么时候值得用,什么时候别用,这是并发特性最容易被误伤的地方。判断标准一句话:更新是不是"用户正在等"的那一个。
- 值得用 :输入框联动的重型过滤 / 搜索联想(
useDeferredValue);点击后内容很重但用户不急着看的视图切换(startTransition);路由懒加载与按需数据(Suspense流式渲染);图表库、编辑器这类第三方 store 接入 React(useSyncExternalStore)。 - 别乱用:表单提交、保存按钮的反馈、模态框开关这类用户盯着等结果的更新,包进 transition 反而让反馈延迟,制造"点了没反应"的错觉。
- 踩坑提醒 :
startTransition里不能包输入框自身的setState(输入要即时回显,属于紧急更新),只能包依赖它的计算结果;useDeferredValue要和useMemo配合,否则延迟值每次渲染都触发整棵子树重建,优化变负优化。
4. 总结
把三天的线索串起来:Fiber 给了 React 一台"可随时暂停的引擎"(Day15),Hooks 给了组件跨渲染的记忆(Day16),并发特性则给了这套引擎调度规则。它们的共同地基是同一个:渲染不再是一次性的全有或全无,而是一个可以排队、插队、让路、重来的持续过程。
并发模式没有引入并行,它引入的是秩序 :主线程的时间被切成小片,紧急的事先做,不紧急的事让路,做一半的事可以放弃重来。这套秩序最终表现为开发者手里三个克制而精确的 API:startTransition 标记不紧急的更新,useDeferredValue 声明可以等的值,Suspense 让渲染本身学会等待。理解了"更新有优先级、渲染可中断",React 的性能调优就不再是玄学式的"加 memo",而是顺着优先级思路去找真正卡住交互的那次更新。