在前三篇中,我们构建了 Fiber 架构(骨架)、梳理了渲染流程(经络)、解析了 Hooks 原理(记忆)。现在,我们要解决最后一个、也是最直接影响用户体验的问题:

如何让应用在繁重的计算下依然保持丝滑的响应?

这就是 React 18 并发模式要回答的核心命题。


1. 经典难题:输入框卡顿之谜

想象一个常见的场景:搜索框

用户在输入框打字,下方需要根据输入内容渲染一个包含几千条数据的列表。

这里有两个性质完全不同的任务:

  • 输入回显(High Priority):用户敲键盘,屏幕需要立即显示字符,延迟超过 100ms 就会产生明显的”卡顿感”
  • 列表渲染(Low Priority):下方的搜索结果需要大量 DOM 操作,可能要跑几百毫秒

在 React 18 之前(或不加优化):

React 会把这两个更新当成一回事,放在同一个渲染任务里处理。一旦开始渲染列表,主线程就被占满。用户敲键盘,但屏幕上字出不来——因为浏览器正忙着画那几千条列表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
用户体验时间线(React 17 / 无优化):

用户按下 'a'

├── setInput('a') ─┐
├── setList(filter('a')) ─┤── 合并为一个同步任务
│ │
▼ │
┌────────────────────────────┤
│ 渲染输入框 + 渲染列表 │ ← 200ms 阻塞
│ (主线程被占满) │
└────────────────────────────┘


用户看到 'a' 出现在输入框(已经过去 200ms,感觉很"卡")

2. 并发模式的核心思想:任务分优先级

React 18 引入的并发模式(Concurrent Mode) 核心思想非常直觉:不是所有更新都一样紧急。

React 把更新分成了两大类:

  1. 紧急更新(Urgent updates):打字、点击、拖拽——这些必须在毫秒级内响应
  2. 过渡更新(Transition updates):渲染列表、切换页面——这些稍慢一点,用户完全可以接受

打个比方:

React 15 就像一条单车道高速公路——所有车都排着队。前面的大货车(列表渲染)开得慢,后面的跑车(用户打字)被堵死了。

React 18 开辟了 VIP 车道——跑车可以随时超车,甚至可以让大货车先靠边停(中断低优先级渲染),等跑车过去了,大货车再继续。


3. Lane 模型:React 的优先级系统

在 React 内部,优先级不是简单的”高 / 中 / 低”,而是用一套精心设计的位运算系统来表达的,叫做 Lane 模型

用二进制位表达优先级

每条”车道(Lane)”对应一个二进制位。位置越低(越靠右),优先级越高:

1
2
3
4
5
6
7
8
9
10
// React 源码中的 Lane 定义(简化)
const NoLane = 0b0000000000000000000000000000000;
const SyncLane = 0b0000000000000000000000000000010; // 同步,最高
const InputContinuousLane = 0b0000000000000000000000000001000; // 连续输入(拖拽)
const DefaultLane = 0b0000000000000000000000000100000; // 默认
const TransitionLane1 = 0b0000000000000000000001000000000; // 过渡 1
const TransitionLane2 = 0b0000000000000000000010000000000; // 过渡 2
// ... 一共 31 条 Lane
const IdleLane = 0b0100000000000000000000000000000; // 空闲
const OffscreenLane = 0b1000000000000000000000000000000; // 离屏

为什么用位运算

位运算让优先级的合并、比较、过滤变得非常高效:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 合并多个 Lane(用 OR)
const mergedLanes = SyncLane | DefaultLane;

// 判断某个 Lane 是否被包含(用 AND)
const isSyncIncluded = (mergedLanes & SyncLane) !== 0; // true

// 从集合中移除某个 Lane(用 AND NOT)
const remaining = mergedLanes & ~SyncLane; // 只剩 DefaultLane

// 取最高优先级的 Lane(取最低位的 1)
function getHighestPriorityLane(lanes) {
return lanes & -lanes;
}

Lane 如何影响渲染

当一个 setState 被调用时,React 会给这个更新分配一条 Lane:

1
2
3
4
5
6
7
8
9
10
11
12
13
function requestUpdateLane(fiber) {
// 如果当前在 transition 中,分配 TransitionLane
if (isTransition) {
return claimNextTransitionLane();
}

// 根据事件类型判断
const eventLane = getCurrentEventPriority();
// 点击 → SyncLane
// 拖拽 → InputContinuousLane
// 默认 → DefaultLane
return eventLane;
}

然后在 Render 阶段,React 只处理当前批次选中的 Lane 对应的更新:

1
2
3
4
5
6
7
8
9
10
11
12
13
function beginWork(current, workInProgress, renderLanes) {
// ...

// 该节点上如果有 update,但 update 的 Lane 不在 renderLanes 中
// React 会跳过这个 update,等后续批次再处理
if (!isSubsetOfLanes(renderLanes, updateLane)) {
// 优先级不够,先跳过
return;
}

// 优先级匹配,处理这个更新
processUpdateQueue(workInProgress, renderLanes);
}

这就是”高优先级打断低优先级”的底层机制:

  1. 低优先级的 Transition 更新开始渲染(分配了 TransitionLane)
  2. 用户敲键盘,产生一个 SyncLane 的更新
  3. Scheduler 发现有更高优先级的任务,中断当前渲染
  4. 用 SyncLane 重新从根节点开始 Render,只处理高优先级的更新
  5. 高优先级渲染完毕、Commit 完毕后,再回来继续(或重启)低优先级的渲染

4. useTransition:手动标记”不着急”的更新

有了 Lane 模型的底层支持,React 提供了 useTransition Hook,让开发者手动告诉 React:”这个更新没那么急。”

基本用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { useState, useTransition } from 'react';

function SearchList() {
const [input, setInput] = useState('');
const [list, setList] = useState([]);
const [isPending, startTransition] = useTransition();

const handleChange = (e) => {
// 紧急任务:直接更新,保证输入框瞬间响应
setInput(e.target.value);

// 过渡任务:用 startTransition 包裹
startTransition(() => {
const results = heavyFilterFunction(e.target.value);
setList(results);
});
};

return (
<div>
<input value={input} onChange={handleChange} />
{isPending ? <p>加载中...</p> : <List data={list} />}
</div>
);
}

底层发生了什么

当你调用 startTransition(callback) 时,React 内部的处理流程是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function startTransition(callback) {
// 1. 先把 isPending 置为 true(这个 setState 是高优先级的!)
setPending(true);

// 2. 设置一个标记,表示后续的 setState 应该使用 TransitionLane
const prevTransition = ReactCurrentBatchConfig.transition;
ReactCurrentBatchConfig.transition = {};

try {
// 3. 执行回调——里面的 setState 会被分配 TransitionLane
callback();

// 4. 再把 isPending 置为 false(也是 TransitionLane)
setPending(false);
} finally {
// 5. 恢复标记
ReactCurrentBatchConfig.transition = prevTransition;
}
}

分解一下优先级分配:

操作 分配的 Lane 优先级
setPending(true) SyncLane(正常 setState)
setList(results) TransitionLane(在 transition 上下文中)
setPending(false) TransitionLane

这意味着:

  1. setPending(true) 立即被处理,UI 上马上显示 Loading
  2. setList(results) 被标记为低优先级,React 在”空闲时间”慢慢渲染
  3. 如果用户继续打字,React 会中断列表渲染,优先处理新的输入
  4. 等列表渲染完成,setPending(false) 也会一起被提交,Loading 消失
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
用户体验时间线(使用 useTransition):

用户按下 'a'

├── setInput('a') → SyncLane → 立即渲染
├── setPending(true) → SyncLane → 立即渲染(显示 Loading)
├── setList(filter('a')) → TransitionLane → 后台渲染


┌────────────┐
│ 渲染输入框 │ ← 几 ms,用户立刻看到 'a'
│ 显示 Loading│
└────────────┘

├── 用户又按了 'ab'?
│ ├── 中断列表渲染
│ ├── 立即渲染输入框 'ab'
│ └── 重新开始列表渲染(用 'ab' 过滤)

└── 用户没继续打字?
├── 继续渲染列表(每 5ms 一个切片)
└── 列表渲染完毕 → 显示结果 → Loading 消失

5. useDeferredValue:另一种标记”不着急”的方式

除了 useTransition,React 18 还提供了 useDeferredValue,它从另一个角度来解决同样的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
function SearchList({ query }) {
// deferredQuery 会"延迟"跟上 query 的更新
const deferredQuery = useDeferredValue(query);

// 当 query 和 deferredQuery 不一致时,说明正在"追赶"
const isStale = query !== deferredQuery;

return (
<div style={{ opacity: isStale ? 0.6 : 1 }}>
<List query={deferredQuery} />
</div>
);
}

useTransition vs useDeferredValue

维度 useTransition useDeferredValue
控制对象 控制 setState 的优先级 控制的更新时机
使用场景 你能直接控制 setState 的地方 值来自 props 或外部,你控制不了更新方式
返回值 [isPending, startTransition] 延迟后的值
底层实现 直接修改 Lane 分配 内部触发一个低优先级的 setState

简单来说:

  • 如果你能控制 setState,用 useTransition
  • 如果值是从 props 传进来的,你控制不了上层怎么更新,用 useDeferredValue

6. 终极对比:Debounce vs Throttle vs useTransition

这是”面试杀手级”问题:为什么不用防抖(Debounce)或节流(Throttle)?

1
2
3
4
5
6
7
8
9
10
11
// 方案 A:防抖
const handleChange = (e) => {
setInput(e.target.value);
debounce(() => setList(filter(e.target.value)), 300);
};

// 方案 B:useTransition
const handleChange = (e) => {
setInput(e.target.value);
startTransition(() => setList(filter(e.target.value)));
};

看起来效果差不多?本质区别很大:

特性 Debounce(防抖) Throttle(节流) useTransition(并发)
策略 消极等待 定频执行 积极工作
原理 强制让 CPU 休息(等 300ms) 每隔固定时间执行一次 利用按键间隙尝试渲染
CPU 利用率 低(等待期空转) 中等 高(榨干每一毫秒)
用户体验 停止输入后还要等一会 固定延迟 即时响应,不卡顿
是否中断旧渲染 否(只是延迟触发) (旧渲染被丢弃)
响应速度 最慢(必须等超时) 中等 最快(有空就渲染)
等待时间 固定(300ms) 固定 动态(取决于 CPU 空闲)

核心差异用一句话概括:

Debounce 是让任务晚点开始,useTransition 是让任务可以被打断。

Debounce 在等待的 300ms 里 CPU 什么都没干——白白浪费了。而 useTransition 在用户两次按键之间的几十毫秒间隙里,可能已经渲染了一部分列表。如果用户不再打字,列表会比 debounce 方案更快出现;如果用户继续打字,旧的渲染会被中断,不会卡顿。


7. 并发模式的本质:可中断的渲染

回顾整个系列,并发模式并不是一个独立的”新功能”,而是前三篇所有基础设施的最终组合

1
2
3
4
Fiber 链表结构    → 让渲染可以暂停在任意节点
Scheduler 调度器 → 控制什么时候让出主线程
双缓存机制 → 未完成的渲染可以安全丢弃
Lane 优先级模型 → 区分不同更新的紧急程度

这四层叠在一起,才实现了”高优先级可以打断低优先级”这个看似简单的用户体验优化。


8. 全系列总结

至此,我们已经完成了 React 核心原理的四大拼图:

篇章 主题 解决的问题 核心概念
第一篇 Fiber 架构 渲染不可中断导致卡顿 Fiber 链表、时间切片、Scheduler
第二篇 渲染流程 更新过程中 UI 可能不一致 双缓存、Render/Commit 分离
第三篇 Hooks 原理 函数组件如何保存状态 闭包快照、链表存储、更新队列
第四篇 并发模式 繁重计算导致输入卡顿 Lane 优先级、useTransition

这四篇的关系是层层递进的:

1
2
3
4
第一篇(骨架)──→ 第二篇(经络)──→ 第三篇(记忆)──→ 第四篇(智能)
Fiber Render/Commit Hooks Concurrent
↓ ↓ ↓ ↓
可中断的结构 → 安全的更新流程 → 状态的持久化 → 优先级调度

拥有了这套思维模型,再去看 React 的源码或面对面试难题,你将不再是死记硬背,而是能够从设计哲学的角度去理解每一行代码为什么要那样写。


相关链接

Happy Coding!