你在搜索框里飞快打字,下面的列表每敲一个键就重新渲染一次。字母还没打完,页面已经开始卡了——光标不跟手,输入框像灌了水泥。
问题不是"渲染太慢",而是 React 把所有状态更新都当成同等紧急。输入框要刷新、列表也要刷新,它们挤在同一条车道上排队。你的手指在等输入框,输入框在等列表渲染完——这就是阻塞。
昨天我们聊了浏览器层面的"长任务"优化:用 scheduler.yield() 把占用太久的主线程让出来。今天换个视角——React 在框架层面做了同一件事,而且做得更聪明。
一、一个 Hook,两个东西
const [isPending, startTransition] = useTransition();
就这一行。返回值只有两个:
| 返回值 | 类型 | 作用 |
|---|---|---|
isPending | boolean | "低优先级的活还没干完吗?" |
startTransition | function | "把这个 setState 标记为低优先级" |
不接受任何参数,没有配置项。这是 React 给你的一张"优先级贴纸"——你贴在哪个 setState 上,那个更新就自动降级。
二、贴纸贴上去,发生了什么
来看搜索框的经典场景:
function Search() {
const [inputValue, setInputValue] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [isPending, startTransition] = useTransition();
function handleChange(e) {
setInputValue(e.target.value); // ← 同步,立刻刷新输入框
startTransition(() => {
setSearchQuery(e.target.value); // ← Transition,列表可以等一等
});
}
return (
<>
<input value={inputValue} onChange={handleChange} />
{isPending && <span>搜索中...</span>}
<HeavyList query={searchQuery} />
</>
);
}
两个 setState 在同一个事件处理函数里,但命运完全不同:
• setInputValue 走的是 SyncLane——最高优先级,浏览器下一帧就得看到字母出现。
• setSearchQuery 被 startTransition 包裹,走的是 TransitionLane——React 会说"行,这个不急,我先把输入框搞定,有空再渲染列表"。
一个事件处理函数里拆出两条车道,紧急的先走,不急的靠边。 这就是 useTransition 的全部。
三、Lane 模型:React 内部的高速公路
为什么叫"车道"不是随便说的。React 内部真的有一套叫 Lane 的优先级模型,用 31 位二进制数表示 31 条赛道。位数越低,优先级越高。
| 车道 | 优先级 | 典型场景 | 位数 |
|---|---|---|---|
| SyncLane | 🔴 最高 | flushSync、过期任务 | 1 位 |
| InputDiscreteLanes | 🟠 高 | 点击、按键 | 2 位 |
| InputContinuousLanes | 🟡 较高 | 拖拽、滚动 | 2 位 |
| DefaultLanes | 🟢 一般 | 网络请求回调 | 3 位 |
| TransitionLanes | 🔵 较低 | useTransition、useDeferredValue | 9 位 |
| IdleLanes | ⚪ 最低 | 空闲任务 | 2 位 |
注意一个精妙的设计:越低优先级的车道,分配的位数越多。原因很直觉——低优先级的任务容易被反复打断导致积压,需要更多车道来容纳排队的更新。最高优先级的 SyncLane 只需要 1 位,因为它从不排队,来了就执行。
这和高速公路的车道设计是一个道理:应急车道只需要一条,但普通行车道有三四条。
React 的所有优先级计算都用位运算完成——与、或、非,O(1) 复杂度。 这不是巧合,这是用数据结构的选择来保证调度本身不成为瓶颈。
四、isPending:并发时代的状态信号
很多人用 useTransition 只用了 startTransition,忽略了 isPending。但 isPending 才是这个 Hook 的灵魂之一。
它告诉你:低优先级的更新正在后台渲染,还没完成。
有了它,你可以做到"即时反馈":
// Tab 切换的例子
function TabButton({ action, children, isActive }) {
const [isPending, startTransition] = useTransition();
if (isActive) return <b>{children}</b>;
if (isPending) return <b className="pending">{children}</b>; // ← 立刻变灰
return (
<button onClick={() => startTransition(async () => { await action(); })}>
{children}
</button>
);
}
用户点了"Posts"标签,不用等列表渲染完,标签立刻变成 pending 样式。用户的感知是"系统在响应我",而不是"系统卡住了"。 这个区别在体验上是天壤之别。
如果不用 Transition,React 会怎么做?它会触发最近的 <Suspense> 边界的 fallback——你精心渲染好的当前页面"哗"地消失,换成一个大大的 spinner。用户会觉得页面坏了。
isPending 让你在"旧世界"和"新世界"之间搭了一座桥:旧内容还在,新内容正在路上,过渡期有明确信号。
五、陷阱:受控输入绝对不能放进 Transition
这是 React 官方文档里反复强调的,也是新手最容易踩的坑:
// ❌ 千万不要这样写
const [text, setText] = useState('');
function handleChange(e) {
startTransition(() => {
setText(e.target.value); // 输入框会"吃"字!
});
}
return <input value={text} onChange={handleChange} />;
为什么?因为 startTransition 标记的更新是可以被打断的。用户每敲一个键,前一次渲染可能还没完成就被中断了。结果就是输入框的值追不上手指的速度——你打了"abc",屏幕上可能只显示"a"。
输入框必须同步响应,这是用户对交互设备最底层的预期。
正确做法永远是拆两个 state:
const [inputValue, setInputValue] = useState(''); // 同步:控制输入框
const [searchQuery, setSearchQuery] = useState(''); // Transition:控制下游
function handleChange(e) {
setInputValue(e.target.value); // ← 立刻生效
startTransition(() => {
setSearchQuery(e.target.value); // ← 可以等
});
}
一个管门面,一个管后台。两条路各走各的,互不阻塞。
六、async Action 的坑:await 之后要再包一层
React 19 让 startTransition 支持了异步函数,但留了一个反直觉的限制:
// ❌ await 之后的 setState 不在 Transition 中!
startTransition(async () => {
await updateQuantity(newQuantity);
setQuantity(savedQuantity); // 这行走的是 DefaultLane,不是 TransitionLane
});
// ✅ 必须再包一层
startTransition(async () => {
const savedQuantity = await updateQuantity(newQuantity);
startTransition(() => {
setQuantity(savedQuantity); // 现在才是 TransitionLane
});
});
原因在 JavaScript 语言层面:await 之后的代码在微任务队列中执行,React 在那个时间点已经退出了 Transition 的作用域。
这和 startTransition 的内部模型有关。你可以把它想象成:
let isInsideTransition = false;
function startTransition(scope) {
isInsideTransition = true;
scope(); // 同步执行
isInsideTransition = false; // await 之后,这行早就跑完了
}
同步部分结束的那一刻,"贴纸"就撕掉了。await 恢复执行时,贴纸已经不在了。
好消息是:当 TC39 的 AsyncContext 提案落地后,React 将能在异步边界中保持上下文,这个限制会被移除。
七、useTransition vs useDeferredValue
这两个 Hook 经常被一起问。核心区别一句话:
useTransition 包裹的是动作(setState),useDeferredValue 包裹的是值。
| 维度 | useTransition | useDeferredValue |
|---|---|---|
| 包裹对象 | setState 函数 | 状态值或 prop |
| 使用前提 | 能访问 set 函数 | 不需要访问 set 函数 |
提供 isPending | ✅ 是 | ❌ 否 |
| 典型场景 | 按钮点击、表单提交、Tab 切换 | 输入框打字时延迟派生渲染 |
| 控制力 | 更强(你决定哪些 setState 降级) | 更弱(React 自动判断) |
选择标准也简单:
• 你能控制 setState 的地方 → useTransition
• 你只拿到一个 prop 或值,管不了上游 → useDeferredValue
八、架构洞察:两层 yield 的对称
如果你读过昨天那篇讲 scheduler.yield() 的文章,现在可以画出一个完整的图景:
| 层级 | 工具 | 让给谁 | 谁来调度 |
|---|---|---|---|
| 浏览器层 | scheduler.yield() | 主线程上排队的所有任务(输入、绘制、其他脚本) | 浏览器事件循环 |
| 框架层 | useTransition | 同一个 React 应用内更高优先级的状态更新 | React Fiber Scheduler |
它们解决的是同一个问题——让紧急的先走——只是作用域不同。
scheduler.yield() 是和整个浏览器说"我让一下,你先处理别的"。useTransition 是在 React 内部说"这个 setState 不急,先渲染那个急的"。
用操作系统的术语来说:
• scheduler.yield() 是进程级调度——操作系统决定哪个进程获得 CPU。
• useTransition 是线程级调度——进程内部决定哪个线程先执行。
两层协作,才是完整的"让路"体系。
如果你只想带走一句话,我建议记这个:
useTransition 不是"延迟渲染",是"我告诉 React 这件事不急,你先顾紧急的"。一个事件里拆两条车道,急的同步走,不急的靠边等——这就是并发渲染的全部直觉。
参考:useTransition — React 官方文档 · React 官方团队维护