React 18 完结:最佳实践与注意事项
升级到 React 18 真正会咬人的不是新 API,是旧代码在新 root 下行为变了 —— 自动批处理、StrictMode 把 effect 跑两遍、startTransition 只认同步回调。这一篇是前五篇那些机制落到改代码时的样子。
升级 React 18 的动作本身很小:换掉创建根节点的那几行,别的一个字不改,应用照跑。
会咬人的是后面 —— 旧代码在新 root 下行为变了,而且变的地方不报错。前五篇拆的是机制,这一篇是那些机制落到改代码上的样子。
换 root 是唯一一次性的动作
// React 17import ReactDOM from 'react-dom';ReactDOM.render(<App />, document.getElementById('root'));
// React 18import { 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 之后同样的点击只打印一行。把那两句 setState 从 setTimeout 里挪出来直接写在 onClick 里,两个版本都只打印一行 —— 那部分批处理 17 就有。
第三篇看过它的落点:同步更新不当场执行,而是排一个微任务去冲刷队列,队列里的更新因此有机会并到一起。
会因此坏掉的是这一类代码:在两次 setState 中间读 DOM,或者指望第一次 setState 之后立刻量得到新的布局。 以前它们各自触发一次渲染,读得到;现在合并了,读到的是旧值。官方给的退路是 flushSync:
import { flushSync } from 'react-dom';
flushSync(() => { setTab('detail');});// 这一行执行时,DOM 已经是新的了scrollToDetail();flushSync 从 react-dom 导入,不是 react-dom/client。用它就等于放弃这一次的批处理,别拿它当顺手的工具。
StrictMode 会把 effect 跑两遍
18 给 <StrictMode> 加了一条只在开发环境生效的检查:组件首次挂载时,React 会模拟一次卸载再重新挂载,state 保持不变。effect 的顺序因此变成建立 → 销毁 → 再建立。
看起来的症状是请求发两次、订阅建两次、动画播两遍。
正确的反应是补清理函数,不是关掉 StrictMode。 这条检查存在的意义就是把「没写清理」提前暴露在开发环境 —— 一个 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 的更新同步生效。
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/client 的 createRoot |
| 外部 store | 并发渲染下读到不一致的值 | 升级状态库,或改用 useSyncExternalStore |
真正要花时间的只有第二行。StrictMode 的双跑会一次性暴露一批本来就存在的 effect 缺陷,而它们看起来像是 18 引入的新 bug —— 这是升级过程中最容易把人劝退的地方。
反过来,收益不会自己到账。第三篇说清楚了:默认更新根本不走时间切片,页面变不变流畅取决于你有没有动手标 transition。升级和提速是两件事,先做完第一件,再决定第二件值不值得做。
值不值得,看你的页面卡在哪儿。卡在一次更新要渲染几千个组件,标 transition 有用;卡在某个函数里跑了 200 毫秒的计算,或者一次性往 DOM 里塞两万个节点,那 React 18 帮不上忙 —— 该动的是那段代码本身。
这篇是 React 18 的最后一篇,前一篇是 React 18 五:Selective Hydration。