前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版

React 18 新特性与升级指南,createRoot 自动批处理与 Suspense SSR

首页2024-01-05 20:40:12Front-End
ReactReact 18前端开发Hooks升级迁移

把一个项目从 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。

fe
  • 一、先把入口换成 createRoot
    • 1.1 改法本身很简单
    • 1.2 为什么这一步不能跳过
  • 二、自动批处理
    • 2.1 批处理是什么
    • 2.2 React 18 之前的边界在哪
    • 2.3 React 18 统一了
    • 2.4 什么时候需要退出批处理
  • 三、Suspense 在 SSR 里终于能用了
    • 3.1 老 SSR 的两个瓶颈
    • 3.2 服务端 API 换了
    • 3.3 Suspense 和过渡更新配合的那个细节
  • 四、三个新 Hook,两个不是给你用的
    • 4.1 useId
    • 4.2 useSyncExternalStore
    • 4.3 useInsertionEffect
  • 五、严格模式的二次挂载
    • 5.1 它到底做了什么
    • 5.2 它炸出来的都是真问题
    • 5.3 一个例外
  • 六、升级 checklist
    • 6.1 装包
    • 6.2 改入口
    • 6.3 配测试环境
    • 6.4 废弃 API 对照
    • 6.5 其余行为变更
  • 七、2026 年这个时间点怎么选
  • 总结
  • 参考

← 微前端落地方案对比,qiankun 无界 Module Federation 怎么选RN 构建 iOS 包发布到 AppStore 全流程 证书描述文件与上架实战 →