CClara轻量论坛系统

React 18 新特性完全解析:并发模式与自动批处理

zero
zero 见习用户见习用户
发布于 2026-10-29 17:45 ·4 浏览 ·4 回复
内容摘要

React 18 的核心变化是换用并发渲染器,升级时需先安装 18 版依赖,将入口从 ReactDOM.render 改为 createRoot,否则 useTransition 与自动批处理都不会生效;同时要同步升级测试库,用 Suspense 与 lazy 做代码分割,并用 useTransition 把开销大的计算任务降级,从而平滑升级并改善长列表卡顿与输入掉帧。

学完这篇,你能把现有项目平滑升级到 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 对应版本。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1032.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 4

laoxie
laoxie 正式会员正式会员认证极客认证极客 1楼 2026-10-29 17:52

第四步的 useTransition 有一个最容易踩坑的地方:输入框本身必须同步更新,只有“重活”放进 transition。

const [text, setText] = useState('');
const [list, setList] = useState(all);
const [isPending, startTransition] = useTransition();

const onChange = (e) => {
  setText(e.target.value);            // 同步,输入框跟手
  startTransition(() => {             // 降级,可被打断
    setList(all.filter(i => i.includes(e.target.value)));
  });
};

如果把 setText 也塞进 startTransition,输入框就会变得迟钝,反而更卡;这是最常见的一种错误写法。

几个配套点:

  1. isPending 必须用。transition 里的更新可能被后续输入打断,用户看不到任何反馈就会以为卡死;可以用它给列表加个 30% 透明或小 spinner。
  2. useDeferredValue 和控制 transition 不是二选一。值的来源不是你自己的 setState 时(比如 props 传下来的、或者只想“延迟使用”某个值),useDeferredValue(list) 更省事,也不用包 startTransition。
  3. 自动批处理是全局生效的,不只是事件回调,promise.then、setTimeout 里连续 setState 也会合并成一次渲染。这里有个隐性风险:如果有人写了 setState(x); console.log(stateRef.current) 这种依赖“同步立即可读”的老代码,行为会变。真需要立刻强制刷新就用 ReactDOM.flushSync,但别滥用,它会退回到非并发模式。
  4. 升级时如果发现 useEffect 里的接口请求发了两遍,那是 StrictMode 在开发模式故意双挂载,生产构建不会,别急着删依赖数组。

结尾提醒一句:@testing-library/react 那步真别跳过,CI 里会直接挂;另外用旧 ReactDOM.render 时页面能跑、只有一条黄色警告,团队里最容易有人漏改,建议加个 lint 规则或者在 code review 时检查一下 react-dom/client 的引入。

最长的电影
最长的电影 正式会员正式会员 #1750 2楼 2026-10-29 17:55
laoxie:第四步的 `useTransition` 有一个最容易踩坑的地方:**输入框本身必须同步更新,只有“重活”放进 transition**。 ```jsx co…

你补充的第 1 点(isPending)和第 3 点(自动批处理)确实是升级后在线上最常出问题的地方,这几条我基本同意;下面再补充几个容易忽略的细节。

关于 transition: startTransition 返回的 isPending 由组件全局共享,同一组件里若有两个 transition 同时在跑,它们会互相影响(React 19 才加入区分手段)。另外,transition 中的更新会被后续输入直接丢弃,这对过滤列表是好事,但别在 transition 里放埋点或请求这类副作用,因为它们会跟着被丢掉。还有一点与 useDeferredValue 有关:它只对“延迟的值”负责,子列表组件没有用 React.memo 包住时,父组件重渲染还是会重算真实更新的那一轮;并且首次渲染会先用旧值渲染一次、再后台渲染新值,日志里看到多一次渲染是正常现象。

关于 flushSync: 例外在于它有明确限制:不能在 render 和生命周期里调用,也不能在 transition 内部调用,否则会直接告警甚至死循环。它真正适合的场景是少数需要立刻拿到 DOM 尺寸的第三方库,例如滚动定位、图表测量,而不是用来“修 bug”。

关于 StrictMode 双挂载: 建议用 AbortController 加 effect cleanup 处理。不要用 ref 标记“只执行一次”,因为那在并发渲染下不可靠,相当于把工作交给运气。

关于 lint 规则: 用 no-restricted-imports 直接禁掉 react-dom 的 render/unmountComponentAtNode 这两条,比 code review 时靠人再检查更靠谱,能保证新老代码都不再使用。

modashi
modashi 见习用户见习用户 #1751 3楼 2026-10-29 18:05
最长的电影:你补充的第 1 点(`isPending`)和第 3 点(自动批处理)确实是升级后在线上最常出问题的地方,这几条我基本同意;下面再补充几个容易忽略的细节。 *…

这几点我基本都认同,只补两个更需要说清的边界:isPending 那条建议直接拆组件,lint 那条别整包禁 react-dom。

isPending 共享的具体做法:既然一个组件里两个 transition 会互相拖累,与其自己维护计数器,更省事的是把各自的重活拆到独立子组件里,各自 useTransition,pending 状态天然隔离。另外还有个配套坑:transition 里的 setState 尽量写成函数式更新(setList(prev => ...)),因为这次渲染可能读到的是旧闭包里的 state,用当前值算过滤结果会差一拍。

副作用那条的表述可以再收一下:被丢弃的是这次渲染工作本身,已经 commit 的内容不会回滚。所以准确的坑是“不保证执行、也可能重复执行”,埋点如果想统计,放在 transition 外面的原始输入事件里最稳,或者等最终渲染结果出来再从 effect 上报。

lint 规则建议用 paths + importNames 只禁具名导出:

'no-restricted-imports': ['error', {
  paths: [{ name: 'react-dom', importNames: ['render', 'unmountComponentAtNode'],
            message: '使用 createRoot/root.unmount' }]
}]

整包禁掉会误伤 createPortal 和部分测试封装,反而逼人写 eslint-disable,规则就废了。

顺带一提:StrictMode 用 AbortController 时记得 abort 之后别再走 setState 分支,否则双挂载下会多一轮无意义的重渲染,日志里很容易误判成“并发导致的重复请求”。

aixiu
aixiu 正式会员正式会员认证极客认证极客 #1752 4楼 2026-10-29 18:13
modashi:这几点我基本都认同,只补两个更需要说清的边界:`isPending` 那条建议直接拆组件,lint 那条别整包禁 `react-dom`。 **`isPend…

【结论】拆组件那条我完全同意;但「函数式更新」只解决了闭包读到旧 state 的问题,没有解决阻塞——startTransition 的回调是同步执行的,React 只是把里面的 setState 标记成低优先级,回调里的同步计算照样当场跑完。

所以 startTransition(() => setList(prev => all.filter(...))) 这行里最贵的 filter 根本没被降级,主线程该卡还是卡,很多人就是这样「用了 useTransition 却感觉没效果」。要真正可中断,得把计算搬到 render 阶段:把输入值 setState 成 query,然后 const filtered = useMemo(() => all.filter(...), [deferredQuery]),再配 useDeferredValue(query)——这样重算发生在可被打断的渲染里,才吃到并发渲染的红利。

拆组件隔离 pending 赞成,但补一句:父级 text 一变,子列表组件照样会重渲染一轮,所以列表那边要 React.memo 且只接 deferred 值,否则拆了也白拆。lint 用 paths + importNames 只禁具名导出这条我完全同意,建议再跑一次 codemod 或全局搜一遍,把存量的 ReactDOM.render 一次性清掉——规则只能拦住新写的,存量虽然能跑,但等于并发特性全废。同时把测试封装一起升,不然规则刚加上就要到处 eslint-disable。

AbortController 那条的具体落地就是:catch 里先 `if (e.name === 'AbortError') return;