React 18是React团队历时数年打造的版本,其核心价值不在于新增了几个API,而在于对渲染架构的彻底重构。在旧版本中,React的渲染过程是同步且不可中断的,一旦组件树开始更新,就必须一口气执行到底,期间用户的所有交互都会被阻塞。React 18引入的并发渲染能力改变了这一局面,同时自动批处理的扩展也让状态更新的合并策略更加智能。本文将围绕这两个核心特性展开,结合实际代码讲解它们的工作原理与实战用法。

一、自动批处理:从事件处理器扩展到全部更新
批处理并不是React 18提出的新概念。早在React 17甚至更早的版本中,React就会将同一个事件处理器内的多次setState调用合并为一次渲染,这就是批处理的雏形。举个例子,如果你在点击事件中连续调用了两次setState,React并不会触发两次完整的重新渲染,而是把两次更新收集起来,在事件处理器执行完毕后统一处理一次。
但React 17的批处理有一个明显的局限:它只在React事件系统的合成事件回调中生效。一旦更新发生在Promise的then回调、setTimeout定时器、原生DOM事件监听器或者任何异步代码中,批处理就会失效,每一次setState都会触发一次独立的同步渲染。来看一个典型的对比:
// React 17 中的行为
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// React 17:这里会渲染两次,每次setState单独生效
// React 18:这里只会渲染一次,两次更新被自动合并
}, 1000);
// Promise 回调同样如此
fetch('/api/data').then(res => res.json()).then(data => {
setUser(data.user);
setLoaded(true);
// React 17 渲染两次,React 18 渲染一次
});React 18通过重写更新调度机制,把批处理的适用范围扩展到了所有场景,无论更新来自事件处理器、Promise、setTimeout还是别的异步上下文,都会默认合并。这对开发者来说是零成本的收益——不需要改任何代码,升级之后应用的渲染次数就自动减少了,尤其对于列表、表格等重渲染成本较高的组件,性能提升往往可以直接从Profiler面板中观察到。
如果你确实需要在某些场景下让某次更新立即渲染,不参与批处理,React 18也保留了逃生口:flushSync函数。例如在更新状态后需要立刻读取DOM尺寸的场景,可以这样使用:
import { flushSync } from 'react-dom';
function handleClick() {
flushSync(() => {
setCount(c => c + 1);
});
// 这里的DOM已经是更新后的状态
const height = ref.current.offsetHeight;
console.log(height);
}需要注意flushSync会强制同步刷新,频繁使用会抵消批处理带来的性能收益,因此只在确有必要时才使用,不要把它当成常规手段。
二、并发模式的核心思想:可中断的渲染
并发模式是React 18最底层的架构变化。要理解它,先要看旧架构的问题。在React 17及之前,一次组件树的更新是同步完成的:React从根节点开始递归比对虚拟DOM、计算差异、提交到真实DOM,整个过程中主线程被完全占用。如果组件树很大、计算量很高,用户在更新期间的点击、输入等操作就无法得到响应,界面上表现为掉帧、卡顿。
并发模式的关键在于把渲染拆成多个小任务,让React可以把控制权交还给浏览器。具体来说,React将一次更新拆分为多个工作单元,每完成一部分工作就检查一下是否需要让出主线程,比如响应用户输入、处理动画帧。这样一来,高优先级的更新(比如用户输入)可以打断低优先级的更新(比如数据加载后的列表刷新),等高优先级任务完成后再回来继续之前的工作。
这里有一个容易混淆的概念需要厘清:并发模式并不是让多个更新真正并行执行(JavaScript是单线程的),而是指React可以在多个更新任务之间灵活调度和切换。打个比方,旧模式像是只有一个窗口的银行,前面那位客户业务再复杂也得等他办完;并发模式则像是增加了优先级叫号,VIP客户(用户交互)到了可以先办理,普通业务(后台数据渲染)暂停一下再继续。
启用并发能力非常简单,只需要把旧的渲染入口替换为新的createRootAPI:
import ReactDOM from 'react-dom/client';
// React 17 的写法
// ReactDOM.render(<App />, document.getElementById('root'));
// React 18 的写法
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);如果不使用createRoot,应用仍会以旧模式运行,自动批处理等新特性也不会完全生效,因此升级React 18后第一件事就是替换渲染入口。
三、startTransition实战:让输入交互不再卡顿
并发架构为上层API提供了基础,startTransition就是其中最重要的一个。它的作用是把某次状态更新标记为过渡更新,即不紧急的更新。被标记的更新会以低优先级调度,如果期间出现了用户输入等高优先级更新,过渡更新会被中断并放弃,重新基于最新状态计算。
一个经典场景是搜索过滤:用户在输入框中打字,同时下方列表根据关键字实时过滤。如果把关键字同时绑定到输入框和列表,每敲一个字符都会触发大列表的重新渲染,输入体验会明显卡顿。用startTransition可以把列表更新分离出来:
import { useState, startTransition, useDeferredValue } from 'react';
function SearchList({ items }) {
const [keyword, setKeyword] = useState('');
const [list, setList] = useState(items);
function handleChange(e) {
const value = e.target.value;
// 输入框的更新是高优先级,立即响应
setKeyword(value);
// 列表过滤是低优先级,可以被打断
startTransition(() => {
setList(items.filter(item => item.includes(value)));
});
}
return (
<>
<input value={keyword} onChange={handleChange} />
<ul>
{list.map(item => <li key={item}>{item}</li>)}
</ul>
</>
);
}除了手动包裹更新,React 18还提供了useDeferredValue这个更声明式的方案。它接收一个值,返回一个滞后于该值的副本:当原值快速变化时,返回值会延迟更新,从而让依赖该值的重计算不阻塞交互。
function SearchList({ items }) {
const [keyword, setKeyword] = useState('');
// deferredKeyword 的更新会被自动延迟
const deferredKeyword = useDeferredValue(keyword);
const list = items.filter(item => item.includes(deferredKeyword));
return (
<>
<input value={keyword} onChange={e => setKeyword(e.target.value)} />
<ul>
{list.map(item => <li key={item}>{item}</li>)}
</ul>
</>
);
}两者的选择原则很简单:如果你能控制状态更新的代码位置,优先用startTransition;如果值来自组件外部(比如props或自定义Hook),无法在源头包裹更新,就用useDeferredValue在消费端做延迟。另外React 18.3之后还提供了useTransitionHook,它在startTransition的基础上额外返回一个isPending状态,方便在过渡期间展示加载提示。
最后提一下升级时的注意事项。React 18在开发模式的StrictMode下会额外执行一次组件渲染来帮助发现副作用问题,如果你在项目中做本地调试时看到控制台日志输出两次,这并非Bug,而是严格模式的刻意行为。同时,类型定义中children需要显式声明、ReactDOM.render被标记为废弃等变化,都建议在升级时逐一排查。总体而言,只要理解了批处理与并发优先级这两个核心机制,React 18的新特性就能真正为应用的交互体验和渲染性能带来实质性提升。