
React 18 标志着 React 进入“并发渲染”时代。本文不重复官方文档的入门示例,而是直接从 Fiber 架构、Lane 优先级、时间切片、Suspense 流式 SSR 等底层机制切入,剖析并发渲染如何实现“可中断、可恢复”的 UI 更新。读完你将理解为什么 React 18 能显著提升交互体验,以及如何在项目中真正用好这些新能力。
在 React 15 及之前,reconciliation(协调)过程基于递归的 Stack 算法,一旦开始更新,就无法中断,直到整棵虚拟 DOM 树计算完成。这导致两个问题:
React 16 引入 Fiber 架构,将可中断的渲染能力带入 React,但当时并未开放并发特性(Concurrent Mode 一直作为实验性功能)。React 18 正式将并发渲染设为默认(或通过 createRoot 开启),并提供了丰富的 API 来利用这一能力。
并发渲染不是“并行处理多个任务”(JavaScript 仍为单线程),而是 将渲染工作拆分为多个小单元(Fiber 节点),并借助调度器(Scheduler)在浏览器空闲时执行,同时支持高优先级任务抢占低优先级任务。
实现这一目标的核心组件:
SyncLane、DefaultLane、IdleLane),调度器根据优先级决定执行顺序。MessageChannel 或 setTimeout 实现的时间切片机制,将渲染任务交给浏览器空闲时段(requestIdleCallback 的 polyfill)。每个 React 元素对应一个 Fiber 节点,关键字段包括:
type Fiber = {
tag: WorkTag; // 组件类型(FunctionComponent, HostRoot...)
key: null | string;
elementType: any;
type: any;
stateNode: any; // 实例引用(DOM 节点、组件实例)
return: Fiber | null; // 父节点
child: Fiber | null;
sibling: Fiber | null;
index: number;
pendingProps: any;
memoizedProps: any;
memoizedState: any;
updateQueue: mixed;
flags: Flags; // 副作用标记(Placement, Update...)
subtreeFlags: Flags;
deletions: Array<Fiber> | null;
lanes: Lanes; // 该节点相关的优先级
childLanes: Lanes;
alternate: Fiber | null; // 指向当前树的对应节点(双缓冲)
// ...
};React 维护两棵树:
每次更新时,React 会克隆 current 树作为 workInProgress,并在 workInProgress 上执行所有副作用。完成后,将 current 指针指向 workInProgress,完成切换。这种机制使更新过程中可以随时中断,而不影响当前显示,保证了 UI 一致性。
performUnitOfWork 递归遍历 Fiber 树,执行组件 render,计算差异,标记 flags。此阶段可被高优先级任务打断。并发渲染主要优化 render 阶段,使其具备可中断性。
React 16 使用 expirationTime 表示任务超时时间,但存在优先级粒度不够细的问题。React 18 采用 Lane 模型,使用 31 位整数表示不同优先级的“车道”,可以同时处理多个不同优先级的任务。
常见 Lane 常量(来自 ReactFiberLane):
SyncLane (0b0000000000000000000000000000001) : 最高优先级,对应同步更新(如 flushSync)。DefaultLane (0b0000000000000000000000000000100) : 默认优先级,通常用于用户交互。IdleLane (0b0100000000000000000000000000000) : 最低优先级,用于一些不太重要的后台工作。当触发更新时(如 setState),React 会根据事件上下文分配一个优先级(通过 getCurrentEventPriority)。调度器会将更新放入对应 Lane 的队列,然后在 ensureRootIsScheduled 函数中比较当前正在执行的任务与待处理任务的优先级,决定是否中断当前任务。
关键代码(简化):
function ensureRootIsScheduled(root, currentTime) {
const newCallbackPriority = getHighestPriorityLane(nextLanes);
const existingCallbackPriority = root.callbackPriority;
if (newCallbackPriority === existingCallbackPriority) {
// 相同优先级,复用已有调度
return;
}
if (existingCallbackPriority !== NoLane) {
// 取消当前调度,因为新任务优先级更高
cancelCallback(root.callbackNode);
}
// 使用新优先级调度任务
root.callbackPriority = newCallbackPriority;
root.callbackNode = scheduleCallback(
schedulerPriorityLevel,
performConcurrentWorkOnRoot.bind(null, root),
);
}这样,高优先级的交互更新可以打断低优先级的列表渲染,之后低优先级任务会继续执行,但已完成的 workInProgress 树会被丢弃并重新构建(从根节点开始)。
React 的调度器 Scheduler 借鉴了浏览器 requestIdleCallback 的思想,但使用 MessageChannel 实现更精确的控制(requestIdleCallback 在部分浏览器中不稳定)。
核心流程:
scheduleCallback 将任务放入优先级队列。requestHostCallback 通过 MessageChannel 触发异步回调。flushWork 中,执行任务并检查当前时间是否超过 frameDeadline(默认每帧 5ms)。// SchedulerHostConfig.default.js
let schedulePerformWorkUntilDeadline;
if (typeof setImmediate === 'function') {
// Node.js 环境
} else if (typeof MessageChannel !== 'undefined') {
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
schedulePerformWorkUntilDeadline = () => {
port.postMessage(null);
};
} else {
schedulePerformWorkUntilDeadline = () => {
setTimeout(performWorkUntilDeadline, 0);
};
}
function performWorkUntilDeadline() {
if (scheduledHostCallback !== null) {
const currentTime = getCurrentTime();
// 获取帧截止时间(当前时间 + 5ms)
const deadline = currentTime + yieldInterval;
const hasTimeRemaining = true;
try {
const hasMoreWork = scheduledHostCallback(hasTimeRemaining, deadline);
if (!hasMoreWork) {
scheduledHostCallback = null;
} else {
// 还有任务,继续调度下一帧
schedulePerformWorkUntilDeadline();
}
} catch (error) {
// 错误处理...
}
}
}每次 performWorkUntilDeadline 被调用,React 会执行一段工作,若时间片用完且还有剩余工作,则再次通过 MessageChannel 调度,从而让出主线程给浏览器渲染、事件响应等。
useTransition 返回一个 isPending 布尔值和 startTransition 函数,用于将某些更新标记为“非紧急”的过渡任务,从而允许 React 在渲染这些更新时被更高优先级的更新(如输入)打断。
原理:startTransition 会将内部 setState 的优先级降低为 TransitionLane(低于 DefaultLane),因此调度器会在空闲时处理它们。同时,isPending 可以用于显示加载状态。
示例:搜索输入框,输入时更新关键字(高优先级),同时根据关键字搜索列表(低优先级)。
function SearchPage() {
const [keyword, setKeyword] = useState('');
const [searchResults, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
const handleChange = (e) => {
const value = e.target.value;
setKeyword(value); // 高优先级更新
startTransition(() => {
// 低优先级更新:搜索并设置结果
const results = search(value);
setResults(results);
});
};
return (
<div>
<input value={keyword} onChange={handleChange} />
{isPending ? <Spinner /> : <ResultList data={searchResults} />}
</div>
);
}useDeferredValue 类似防抖,但更智能。它接受一个值,并返回一个“延迟版本”,该版本会在低优先级渲染中更新。当有高优先级更新时,旧值会继续保留,新值在后台计算。
与 useTransition 的异同:
useTransition 控制的是状态更新本身,可以获取 pending 状态。useDeferredValue 控制的是值的派生,适合与 props 配合(如接收外部数据)。原理:useDeferredValue 内部使用 useState 和 useEffect,通过 startTransition 来延迟设置新值。
示例:实时过滤大列表。
function CityList({ query }) {
const deferredQuery = useDeferredValue(query);
const cities = useMemo(() => filterCities(deferredQuery), [deferredQuery]);
// 当 query 变化,但 deferredQuery 尚未更新时,显示旧列表
// 但可以配合 isStale 指示器
return <div>{cities.map(...)}</div>;
}用于 SSR 和客户端 hydration,生成跨端稳定的唯一 ID,避免 :r0: 这类不稳定的标记。
原理:使用全局计数器 + 当前组件树路径,保证同一组件在不同渲染中 ID 一致。
React 18 之前,只有事件处理函数中的多个 setState 会自动批处理(合并为一次更新),而在 Promise、setTimeout、原生事件回调中则不会批处理,导致多次渲染。
React 18 通过 createRoot 开启的并发模式,将所有在同一个事件循环中的更新自动批处理,无论它们来自何处。
示例:
function App() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
function handleClick() {
setCount(c => c + 1);
setFlag(f => !f);
// React 18 中,这两个更新会合并为一次重新渲染
}
function handleAsync() {
Promise.resolve().then(() => {
setCount(c => c + 1);
setFlag(f => !f);
// React 18 中,这里也会批处理!
});
}
return <button onClick={handleClick}>...</button>;
}如果确实需要立即更新(比如依赖 DOM 同步读取),可以使用 flushSync 强制同步刷新:
import { flushSync } from 'react-dom';
flushSync(() => {
setCount(c => c + 1);
});
// 这里 DOM 已经更新React 18 之前,SSR 是“全有或全无”的:服务器必须渲染完整 HTML 才能发送,如果某个组件需要异步数据,则整个页面被阻塞(需在服务端获取数据才能继续)。
React 18 引入 流式 SSR(Streaming SSR) 和 Suspense 集成,允许服务端在等待异步数据时,先发送静态 HTML 骨架,数据加载后,再通过 <script> 标签注入补丁(或流式写入),使客户端逐步 hydration。
React 18 提供了新的 SSR 入口:
import { renderToPipeableStream } from 'react-dom/server';
import { Suspense } from 'react';
function ServerApp() {
return (
<html>
<body>
<Suspense fallback={<Spinner />}>
<LazyComponent />
</Suspense>
</body>
</html>
);
}
// 在 Node.js 服务器中
app.get('/', (req, res) => {
const { pipe } = renderToPipeableStream(<ServerApp />, {
onShellReady() {
res.setHeader('Content-type', 'text/html');
pipe(res);
},
onError(error) {
// 错误处理
}
});
});renderToPipeableStream 支持流式输出,使得 Shell(即不包含 Suspense 的部分)可以立即发送,而内部 Suspense 边界的数据在完成后通过流追加。
服务端发送的 HTML 已经包含 Suspense 占位符。客户端使用 hydrateRoot 进行水合时,React 可以识别哪些部分已经就绪,哪些还在等待,并优先水合就绪的部分,而不是等待全部。
这种“选择性水合”大大提升了首屏可交互时间(TTI)。
ReactDOM.render 替换为 createRoot:
jsx
const root = createRoot(document.getElementById('root')); root.render(<App />);
startTransition、useDeferredValue 等新 API 优化体验。对于大型列表或复杂表格,可以将列表更新放入 startTransition,避免阻塞输入响应:
const [query, setQuery] = useState('');
const [filteredData, setFilteredData] = useState([]);
useEffect(() => {
startTransition(() => {
const result = expensiveFilter(data, query);
setFilteredData(result);
});
}, [query, data]);React 18 本身不绑定任何数据框架,但可以配合 Relay、React Query、SWR 等库,这些库已经支持 Suspense。或者使用 React.lazy 进行代码分割。
使用 React DevTools 4.18+ 可以查看并发更新时的渲染时间和优先级标记。注意 startTransition 中的更新会被标记为“过渡”并显示不同颜色。
❌ 不是。只有放在 startTransition 中的更新会降级,其他更新保持原有优先级。
❌ 不是。JavaScript 单线程,并发是“异步中断”而非“并行”。真正并行需依赖 Web Worker(React 并未内置)。
❌ 可以用于任何异步操作,如图片加载、代码分割等。
解决方案:使用 flushSync 强制同步,或使用 useLayoutEffect 替代 useEffect。
React 18 的并发渲染不仅仅是一组新 API,它从根本上改变了 React 的渲染模型,使其更贴近浏览器的调度机制。这种“优先级驱动”的更新方式,让开发者能够轻松构建更流畅的用户体验,同时保持代码声明式风格。
startTransition 让开发者明确划分“紧急”和“非紧急”更新,将主线程优先让给用户输入。理解这些底层原理,你将更自信地使用 React 18 的新特性,并能在性能瓶颈时精准定位问题。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。