学完这篇,你能把现有项目平滑升级到 React 18,并真正用上并发渲染(useTransition/useDeferredValue)和自动批处理这两个最影响体感的新能力。
React 18 最大的变化不是加了几个 Hook,而是换了渲染引擎(并发渲染器)。但它是渐进式的:不主动开启,行为几乎和 17 一样;一旦用上新 API,长列表卡顿、输入框掉帧这类问题会明显改善。下面按升级顺序一步步来。
第一步:升级依赖并确认版本
npm install react@18 react-dom@18
npm install -D @types/react@18 @types/react-dom@18
测试库必须同步升级,否则 render 会报错:
npm install -D @testing-library/react@13
注意:@testing-library/react 12.x 依赖旧的 ReactDOM.render,在 React 18 下会直接报 "ReactDOM.render is no longer supported"。必须升到 13+。
第二步:换掉入口,改用 createRoot
这是必做项,不做的话并发特性全部失效。
// 旧写法(React 17)
import ReactDOM from 'react-dom';
ReactDOM.render(<App />, document.getElementById('root'));
// 新写法(React 18)
import { createRoot } from 'react-dom/client';
const container = document.getElementById('root');
const root = createRoot(container);
root.render(<App />);
卸载也变了:ReactDOM.unmountComponentAtNode(container) → root.unmount()。
注意:用旧写法时页面照样能跑,控制台只会打一条黄色警告,很容易被忽略。但此时 useTransition 会退化成同步更新、自动批处理也不生效,等于白升级。
第三步:用 Suspense + lazy 做代码分割
import React, { Suspense } from 'react';
const Chart = React.lazy(() => import('./Chart'));
export default function Dashboard() {
return (
<Suspense fallback={<Spinner />}>
<Chart />
</Suspense>
);
}
React 18 里 Suspense 不再只是"加载中占位",它成了并发渲染的调度单位:组件树的一部分可以被挂起,其余部分继续响应交互。
第四步:useTransition 把重活降级
典型场景:搜索框输入,同时过滤一个几千条的大列表。
import { useState, useTransition } from 'react';
function Search({ all }) {
const [keyword, setKeyword] = useState('');
const [list, setList] = useState(all);
const [isPending, startTransition] = useTransition();
function onChange(e) {
const v = e.target.value;
setKeyword(v); // 紧急:必须立即反映到输入框
startTransition(() => { // 非紧急:可被打断、可延后
setList(all.filter(item => item.title.includes(v)));
});
}
return (
<>
<input value={keyword} onChange={onChange} />
{isPending && <span>过滤中…</span>}
<ul>{list.map(i => <li key={i.id}>{i.title}</li>)}</ul>
</>
);
}
原理:setKeyword 走紧急更新,立刻渲染;startTransition 里的更新优先级低,用户继续打字时会被新输入打断,从而不阻塞输入框。
注意:不要把控制输入框的 setState 放进 startTransition,否则输入框的值更新被降级,会出现打字不同步、光标跳动的现象。
第五步:useDeferredValue 处理"拿不到 setState"的值
如果那个"慢"的值来自 props 或外部,你无法包一层 startTransition,就用它:
const deferredKeyword = useDeferredValue(keyword);
const list = useMemo(
() => all.filter(i => i.title.includes(deferredKeyword)),
[deferredKeyword, all]
);
区别一句话:useTransition 是你主动声明"这次更新不急";useDeferredValue 是你拿到一个"迟到的副本"。
第六步:理解并利用自动批处理
React 17 只在 React 事件处理函数里合并多次 setState。定时器、Promise、原生事件里连续两次更新 → 渲染两次。
React 18 全部合并:
setTimeout(() => {
setA(1);
setB(2); // React 18:只渲染一次(17 会渲染两次)
}, 0);
fetch('/api').then(() => {
setA(1);
setB(2); // 同样合并
});
需要强制立刻同步刷新 DOM 时用 flushSync:
import { flushSync } from 'react-dom';
flushSync(() => setA(1)); // 立刻渲染
flushSync(() => setB(2)); // 再渲染一次
注意:flushSync 会破坏批处理、性能开销大,只在需要"更新后立刻读 DOM 尺寸/焦点"时用,别拿来当默认写法。另外,如果你依赖 React 17 的"定时器里两次渲染"来做某些时序 hack,升级后这段逻辑会失效,需要复查。
第七步:StrictMode 下的双调用不是 bug
开发模式下,组件会经历 挂载 → 卸载 → 再挂载,effect 执行两次,目的是逼你暴露没有清理干净的副作用。
useEffect(() => {
const ctrl = new AbortController();
fetch('/api/list', { signal: ctrl.signal }).then(...);
return () => ctrl.abort(); // 必须有清理
}, []);
生产环境不会双调用。
注意:看到接口被请求两次不要第一反应去关 StrictMode。那是在提示你:这个 effect 在组件重挂载时会泄漏。补上 cleanup 才是正解。
第八步(可选):SSR 换成流式接口
服务端入口改为 renderToPipeableStream(Node)或 renderToReadableStream(Web),客户端把 hydrate 换成 hydrateRoot(container, <App />)。这样配合 Suspense 能做到"边生成边下发 + 选择性水合",首屏更快。
小结
- 必做:装 react/react-dom 18,入口从
ReactDOM.render 换成 createRoot,否则一切新特性都不生效。
- 并发能力是渐进开启的:单个更新级别地选择紧急或非紧急,不需要重写整个应用。
useTransition 用于你掌控 setState 的场景,useDeferredValue 用于值来自 props 的场景。
- 自动批处理覆盖定时器、Promise、原生事件;只有
flushSync 内部不批处理。
- StrictMode 双调用是开发期提示,用 cleanup(如
AbortController)解决,不要关掉。
- 测试库、类型包必须跟着升到 18 对应版本。