把一个项目从 React 17 升到 18,package.json 改两行、yarn install 跑一遍,最快五分钟。麻烦的是跑起来之后那一堆现象:控制台冒出 ReactDOM.render is no longer supported,某个 useEffect 里的接口请求发了两次,TypeScript 突然报一堆 Property 'children' does not exist,还有几个原本靠「setState 之后立刻读 DOM」的地方悄悄失效了。
这篇就是把这些坑一次讲清楚。内容聚焦在 React 18 面向使用者的那一层,也就是你实际要改的 API 和要处理的行为差异;至于并发渲染底下的 Fiber 可中断、Lane 优先级、时间切片这些机制原理,我拆到了 React 18 并发机制深度解析 那篇里,这里不重复。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
createRoot/hydrateRoot入口迁移,以及它为什么是所有新特性的开关- 自动批处理的真实生效条件,什么时候需要
flushSync退出批处理 Suspense在服务端渲染里的流式能力,onShellReady和onAllReady怎么选useId、useSyncExternalStore、useInsertionEffect三个新 Hook 分别是给谁用的- 严格模式二次挂载会炸出哪些问题,为什么不建议直接关掉它
- 一份可以照着跑的升级 checklist 和废弃 API 对照表
- 2026 年这个时间点,React 18 和 19 该怎么取舍
React 18 是 2022 年 3 月 29 日发布的,到现在已经是很多项目的存量基线。哪怕你今天直接上 19,下面这些行为差异也一样绕不开,因为 19 是在 18 的基础上继续往前走的。
# 一、先把入口换成 createRoot
# 1.1 改法本身很简单
客户端渲染的入口从 react-dom 换到 react-dom/client:
// React 18之前
import { render } from 'react-dom';
render(<App />, container);
// React 18
import { createRoot } from 'react-dom/client';
const root = createRoot(container);
root.render(<App />);
// 卸载
root.unmount();
服务端渲染的注水入口同理:
// React 18之前
import { hydrate } from 'react-dom';
hydrate(<App />, container);
// React 18
import { hydrateRoot } from 'react-dom/client';
hydrateRoot(container, <App />);
注意 hydrateRoot 的参数顺序和老的 hydrate 是反过来的,容器在前、元素在后。这个我踩过,改的时候手快按老顺序写,页面白屏也不报错,找了一会儿才反应过来。
# 1.2 为什么这一步不能跳过
先说结论:不换 createRoot,React 18 装了等于没装。
装上 react@18 之后继续用 ReactDOM.render,React 会打一条 deprecation warning,然后把你的应用跑在 legacy 模式下。legacy 模式意味着调度行为和 React 17 完全一致,自动批处理不生效,startTransition 之类的并发 API 也起不了作用。
所以升级顺序应该是这样:先只改入口,把项目跑起来,观察一轮行为差异并修掉;等稳定了再考虑要不要引入 startTransition、流式 SSR 这些主动开启的能力。这两件事分两次做,出问题的时候好定位得多。
顺带说一句,ReactDOM.unmountComponentAtNode(container) 也一起废弃了,对应写法是先把 createRoot 的返回值存下来,卸载时调 root.unmount()。很多项目在微前端子应用的生命周期钩子里会用到这个,别漏。
# 二、自动批处理
# 2.1 批处理是什么
批处理指的是 React 把一次同步流程里的多个状态更新合并成一次渲染:
// React会自动将这两个状态更新合并为一次渲染
function handleClick() {
setCount(c => c + 1);
setFlag(f => !f);
// React只会re-render一次
}
这个能力 React 17 就有,只不过范围窄得多。
# 2.2 React 18 之前的边界在哪
老版本的批处理只在 React 合成事件的处理器里生效。跳出这个范围,比如 setTimeout、Promise 回调、原生事件监听器里,每个 setState 都会各自触发一次渲染:
// React 18之前:只在事件处理器中批处理
function handleClick() {
setCount(c => c + 1);
setFlag(f => !f);
// 自动批处理,只渲染一次
}
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// 不会批处理,会渲染两次
}, 1000);
这个割裂一直挺别扭的,同样两行代码,放在不同位置渲染次数不一样,老项目里为了绕它写 unstable_batchedUpdates 的地方不少。
# 2.3 React 18 统一了
18 之后所有场景都批处理,不管你在哪里调 setState:
// React 18:所有场景都自动批处理
function handleClick() {
setCount(c => c + 1);
setFlag(f => !f);
// 自动批处理,只渲染一次
}
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// 自动批处理,只渲染一次
}, 1000);
// Promise同样适用
fetch('/api').then(() => {
setCount(c => c + 1);
setFlag(f => !f);
// 自动批处理,只渲染一次
});
这里有个前提条件很多人漏掉:自动批处理只在 createRoot 创建的根上生效。如果你还在用 ReactDOM.render,行为和 17 一模一样。所以第一节那步入口迁移不是可选项。
# 2.4 什么时候需要退出批处理
绝大多数情况下批处理只会让你的应用更快,但有一类代码会被它打破,就是「改完状态立刻读 DOM」:
import { flushSync } from 'react-dom';
function handleClick() {
flushSync(() => {
setCounter(c => c + 1);
});
// DOM已经更新
flushSync(() => {
setFlag(f => !f);
});
// DOM已经更新
}
flushSync 会强制在回调结束时同步刷新这一批更新,DOM 立刻变。典型用途是列表新增一条之后要滚到底部、或者展开面板之后要测量高度,这类需要拿到最新 DOM 尺寸的场景。
但别拿它当万金油。flushSync 等于主动放弃批处理和并发调度,在循环里连着调几次,性能会明显掉下来。我的用法是先想能不能用 useLayoutEffect 或者 ref 回调解决,实在不行才上 flushSync,并且只包必要的那一行 setState。
# 三、Suspense 在 SSR 里终于能用了
# 3.1 老 SSR 的两个瓶颈
传统 SSR 的流程是四步串行:服务端取全部数据,渲染出完整 HTML,客户端加载全部 JS,然后一次性 hydrate。任何一步慢,整页都得等。
一个评论区接口慢 800ms,整个页面的 HTML 就晚 800ms 才发出去,哪怕文章正文早就准备好了。
React 18 的解法是让 Suspense 在服务端也起作用,把页面切成几块,好了一块发一块:
// 传统SSR:必须等待所有数据准备好才能返回
// React 18 SSR:可以流式返回,逐步交付
function App() {
return (
<Layout>
<NavBar />
<Sidebar />
<RightPane>
<Post />
<Suspense fallback={<Spinner />}>
<Comments />
</Suspense>
</RightPane>
</Layout>
);
}
服务端先把不含 Comments 的 HTML 发出去,Comments 的位置先占一个 Spinner。等数据好了,React 通过同一条流把这块 HTML 补发过来,再用一小段内联脚本把它替换到正确位置。选择性 hydration 也是配套的能力,哪块的 JS 先到就先 hydrate 哪块,用户点了谁就优先 hydrate 谁。
# 3.2 服务端 API 换了
renderToString 是同步的,撑不起流式,所以 React 18 给了两套新 API。Node 环境用 renderToPipeableStream:
import { renderToPipeableStream } from 'react-dom/server';
function handleRequest(req, res) {
const stream = renderToPipeableStream(<App />, {
bootstrapScripts: ['/main.js'],
onShellReady() {
// 外壳渲染完成,可以开始往响应里灌数据
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html');
stream.pipe(res);
},
onShellError(err) {
res.statusCode = 500;
res.send('<h1>Something went wrong</h1>');
},
});
}
Edge / Cloudflare Workers 这类 Web Streams 环境用 renderToReadableStream:
import { renderToReadableStream } from 'react-dom/server';
const stream = await renderToReadableStream(<App />, {
bootstrapScripts: ['/main.js'],
});
onShellReady 和 onAllReady 的选择是个容易搞错的点。onShellReady 表示最外层那圈非 Suspense 的内容渲染完了,这时候开始 pipe,用户最快看到首屏,代价是搜索引擎爬到的初始 HTML 里 Suspense 内容还是 fallback;onAllReady 会等所有 Suspense 边界都 resolve,适合爬虫和静态生成。真实项目里常见做法是判断 User-Agent,爬虫走 onAllReady,普通用户走 onShellReady。