React 18 完结:最佳实践与注意事项

升级到 React 18 真正会咬人的不是新 API,是旧代码在新 root 下行为变了 —— 自动批处理、StrictMode 把 effect 跑两遍、startTransition 只认同步回调。这一篇是前五篇那些机制落到改代码时的样子。

位置
第 06 篇 / 共 6 篇
预计
8 分钟
系列
React 18

升级 React 18 的动作本身很小:换掉创建根节点的那几行,别的一个字不改,应用照跑。

会咬人的是后面 —— 旧代码在新 root 下行为变了,而且变的地方不报错。前五篇拆的是机制,这一篇是那些机制落到改代码上的样子。

换 root 是唯一一次性的动作

index.jsx · 换根节点
// React 17
import ReactDOM from 'react-dom';
ReactDOM.render(<App />, document.getElementById('root'));
// React 18
import { createRoot } from 'react-dom/client';
const root = createRoot(document.getElementById('root'));
root.render(<App />);

导入路径是 react-dom/client,不是 react-dom 这一条写错的话报的是「createRoot is not a function」,跟根节点看着毫无关系。服务端渲染的页面对应换成同一个包里的 hydrateRoot

ReactDOM.render 在 18 里还能用,但会打一条弃用警告,而且走的是 legacy root —— 本系列讲的并发能力一样都不给。卸载也换了名字,用 root.unmount()

换完之后默认行为几乎没变,这一点第一篇和第三篇都验证过:普通更新拿的是 DefaultLane,走 workLoopSync,跟 17 一样一口气跑完。升级不会自动让页面变流畅。

真正立刻生效的只有一件事,在下一节。

自动批处理是行为变更,不是新特性

React 一直会把同一个事件处理器里的多次 setState 合并成一次渲染。18 之前,这个合并只发生在 React 自己的事件处理器里;setTimeout、Promise 回调、原生事件监听器里的更新一次一渲染。

换成 createRoot 之后,这些地方也开始批处理了。官方升级指南把它明确列为破坏性变更。

数一数渲染了几次
function Counter() {
const [a, setA] = useState(0);
const [b, setB] = useState(0);
console.log('render');
return (
<button
onClick={() => {
setTimeout(() => {
setA((n) => n + 1);
setB((n) => n + 1);
}, 0);
}}
>
{a} / {b}
</button>
);
}

你会看到:ReactDOM.render 下点一次打印两行 render,换成 createRoot 之后同样的点击只打印一行。把那两句 setStatesetTimeout 里挪出来直接写在 onClick 里,两个版本都只打印一行 —— 那部分批处理 17 就有。

第三篇看过它的落点:同步更新不当场执行,而是排一个微任务去冲刷队列,队列里的更新因此有机会并到一起。

会因此坏掉的是这一类代码:在两次 setState 中间读 DOM,或者指望第一次 setState 之后立刻量得到新的布局。 以前它们各自触发一次渲染,读得到;现在合并了,读到的是旧值。官方给的退路是 flushSync

需要立刻落到 DOM 时
import { flushSync } from 'react-dom';
flushSync(() => {
setTab('detail');
});
// 这一行执行时,DOM 已经是新的了
scrollToDetail();

flushSyncreact-dom 导入,不是 react-dom/client。用它就等于放弃这一次的批处理,别拿它当顺手的工具。

StrictMode 会把 effect 跑两遍

18 给 <StrictMode> 加了一条只在开发环境生效的检查:组件首次挂载时,React 会模拟一次卸载再重新挂载,state 保持不变。effect 的顺序因此变成建立 → 销毁 → 再建立。

看起来的症状是请求发两次、订阅建两次、动画播两遍。

正确的反应是补清理函数,不是关掉 StrictMode。 这条检查存在的意义就是把「没写清理」提前暴露在开发环境 —— 一个 effect 只要建立和销毁能配平,跑两遍和跑一遍的结果就是一样的。

配平的 effect 长这样
useEffect(() => {
const controller = new AbortController();
fetch(`/api/user/${id}`, { signal: controller.signal })
.then((r) => r.json())
.then(setUser);
// 少了这一行,第一次请求的结果可能覆盖第二次的
return () => controller.abort();
}, [id]);

这段代码在 18 之前也该这么写。StrictMode 的双跑只是让「没这么写」变得看得见。生产构建不受影响。

startTransition 只认同步回调

第三篇的结论是:标成 transition 是让一次更新变得可中断的唯一办法。落到写法上有一个陷阱,而且不报错。

React 18 的文档写得很直接:传给 startTransition 的函数必须是同步的。React 会立刻执行它,把执行期间发生的所有状态更新标成 transition;之后再发生的更新——比如在定时器或者 .then 回调里——不会被标进去。

两种写法,只有一种真的生效
// ✗ setResults 是在 Promise 兑现之后才调用的,
// 那时 startTransition 早就返回了,这次更新是普通优先级
startTransition(() => {
searchAPI(query).then((results) => {
setResults(results);
});
});
// ✓ 先把数据等回来,再把状态更新包进去
const results = await searchAPI(query);
startTransition(() => {
setResults(results);
});

上面那种写法最阴的地方是它看起来在工作 —— isPending 会变成 true 再变回 false,页面也刷新了,只是那次渲染根本没走可中断的路径。想验证,去第一篇那个输入框的例子里把两种写法都试一遍,看长任务有没有被切开。

值类型的更新还有另一个写法:useDeferredValue。18 里它只接受一个参数,返回一个「慢半拍」的值 —— 首次渲染返回原值,之后 React 先用旧值渲染一遍,再在后台用新值重渲一遍,而这次后台渲染是可以被打断重来的。

外部状态读一致快照要靠 useSyncExternalStore

组件读 Redux store、读一个模块级的可变对象、订阅浏览器 API —— 这些数据不归 React 管,而并发渲染下一次渲染可以被打断再恢复。中途外部数据变了,同一棵树的不同部分就可能读到不同的值。

18 为此加了 useSyncExternalStore。官方的说法是:它让外部 store 支持并发读取,办法是强制 store 的更新同步生效。

useSyncExternalStore 的三个参数
import { useSyncExternalStore } from 'react';
const width = useSyncExternalStore(
// 1. subscribe:数据变了就调 callback,返回值是退订函数
(callback) => {
window.addEventListener('resize', callback);
return () => window.removeEventListener('resize', callback);
},
// 2. getSnapshot:读当前值。React 用 Object.is 比它,
// 每次返回新对象会导致无限重渲染
() => window.innerWidth,
// 3. getServerSnapshot:服务端渲染时读什么。
// 不传的话,这个组件在服务端渲染会抛错
() => 1024,
);

第二个参数那条注释是踩得最多的:getSnapshot 返回 { width, height } 这样的新对象,每次比较都不相等,页面就转不出来。要么返回原始值,要么在外面缓存住同一个对象。

多数人不需要直接写它。官方把它定位成给库作者的 API —— Redux、Zustand 这些库内部已经接好了,升级它们的版本就够。

Transition 挡得住的和挡不住的

第四篇讲过 Suspense 的 fallback 会闪。transition 能缓解,但范围比大多数说法窄,值得把话说准。

React 18 文档的表述是:transition 只「等」到足够避免已经显示出来的内容被藏起来为止。也就是说:

  • 页面上已经渲染出来的那块内容,在 transition 期间不会被换成 fallback,旧内容继续留在屏幕上。
  • 但新渲染的内容里如果还嵌着一层 <Suspense>,那一层照样显示自己的 fallback,transition 不会等它。
  • 从来没显示过内容的边界,第一次也照样显示 fallback。

所以 transition 解决的是「切换页签时整块内容闪成骨架屏」,解决不了「首次进入时要等」。后者是第五篇那套流式 SSR 的活。

测试里不用手动包 act

并发渲染下断言要等渲染真的落地。Testing Library 的 findBy*waitFor 本身就是异步的,外面再套一层 act 是多余的写法,还会让失败信息变得难读。

等它出现就行
import { render, screen } from '@testing-library/react';
test('加载完成后显示问候语', async () => {
render(<Greeting />);
expect(await screen.findByText('Hello, world!')).toBeInTheDocument();
});

findByText 会一直重试到超时,中间的渲染、effect、状态更新都被它等住。同步的 getBy* 在异步内容上必然失败,这一点 17 和 18 没有区别,只是 18 下更容易撞上。

升级的账单

把这一篇的东西汇成一张单子:

变的东西 会怎么坏 怎么办
自动批处理 两次 setState 之间读 DOM 读到旧值 那一处用 flushSync
StrictMode 双跑 请求发两次、订阅重复 补 effect 的清理函数
新 root ReactDOM.render 打弃用警告 react-dom/clientcreateRoot
外部 store 并发渲染下读到不一致的值 升级状态库,或改用 useSyncExternalStore

真正要花时间的只有第二行。StrictMode 的双跑会一次性暴露一批本来就存在的 effect 缺陷,而它们看起来像是 18 引入的新 bug —— 这是升级过程中最容易把人劝退的地方。

反过来,收益不会自己到账。第三篇说清楚了:默认更新根本不走时间切片,页面变不变流畅取决于你有没有动手标 transition。升级和提速是两件事,先做完第一件,再决定第二件值不值得做。

值不值得,看你的页面卡在哪儿。卡在一次更新要渲染几千个组件,标 transition 有用;卡在某个函数里跑了 200 毫秒的计算,或者一次性往 DOM 里塞两万个节点,那 React 18 帮不上忙 —— 该动的是那段代码本身。

这篇是 React 18 的最后一篇,前一篇是 React 18 五:Selective Hydration