21

React 16 → 19 版本演进

5,790 行约 325 分钟222 个代码块115 张表格更新于 2026-11-03

21 · React 16 → 19 版本演进(详解版)

一句话定位:React 16 到 19 不是「API 多了几个」,而是渲染模型(Fiber → 并发)、服务端边界(SSR → RSC)、交互模型(手写 loading → Actions)、优化方式(手写 memo → 编译器) 四次代际变化叠加的结果。

读完本篇你应该能做到

  1. 不背特性清单,也能说清 16 / 17 / 18 / 19 各自解决了什么真问题、留下了什么坑;
  2. 拿到一个 React 16 的老项目,能判断升级到 19 的收益、成本、风险,并给出分阶段路线;
  3. 被追问「并发到底并发什么」「RSC 和 SSR 什么关系」「为什么 effect 跑两次」时,能讲到调度、序列化与 Hydration 的机制层;
  4. 遇到一个陌生 API(比如 renderToString),能说清它从哪来、解决什么、现在该不该用——见第 10 章附录。

0. 领域导论

0.1 这个领域解决什么问题

假设你接手一个 2019 年上线的中台:React 16.14、类组件、ReactDOM.render、componentWillReceiveProps、react-router@4。现在业务要求接入 Next.js App Router,用 Server Components 降低首屏 JS,用 Actions 统一表单提交。

你会同时撞上三类断裂:

  1. API 断裂:ReactDOM.render / findDOMNode / 字符串 ref / 函数组件的 defaultProps 在 19 里被移除或忽略;forwardRef 不再是必需品。
  2. 心智断裂:从「生命周期编排」变成「渲染必须纯净 + 依赖必须诚实 + 副作用必须可重放」;StrictMode 会故意把 effect 跑两遍来抓你不纯的代码。
  3. 运行模型断裂:默认渲染从「同步一次性」变成「可中断、可排序、可丢弃」;服务端从「输出 HTML」变成「输出可序列化的组件树」;表单提交从「自己管 pending / error / 乐观更新」变成「交给 Actions 状态机」。

这个领域要解决的核心问题:让你在版本迁移时,能区分「语法变了」和「模型变了」。只改语法会写出「能跑但拿不到收益」的 19 代码——比如用了 createRoot 却到处 flushSync,或者上了 RSC 却把整棵树标成 "use client"。

0.2 一个类比

把 React 应用想成一家装配工厂:

版本工厂发生的事你能感知到的变化
16把整条产线改造成可暂停的流水线(Fiber),但订单仍然一单一单做完能返回数组/字符串、有 Error Boundary、Portal;大更新还是会卡
17换接口标准,让新旧产线能并存,不换发动机没有新特性;JSX 不用再 import React;事件挂到根容器
18装上调度系统:急单插队、长单可中断、半成品可先展示createRoot、自动批处理、Transition、流式 SSR
19把仓库(服务端)和车间连起来,并给表单/异步动作配标准工装RSC、Server Actions、useActionState、useOptimistic、Compiler

一句话记住:16 改的是「怎么算」,18 改的是「先算谁」,19 改的是「在哪算」和「算完怎么交付」。

0.3 学习地图

本篇(React 版本演进)
  ├─ 必须先会:06 框架与组件化(Hooks、协调、闭包陷阱)
  │             03 JavaScript(微任务、闭包、Proxy)
  ├─ 紧接着学:08 路由与渲染架构(SSR/SSG/流式/边缘)
  │             07 状态管理与数据流(服务端状态、乐观更新)
  ├─ 交叉领域:09 工程化(codemod、构建、Monorepo 升级)
  │             11 性能(INP、TBT、长任务)
  │             13 测试(升级回归、StrictMode 双执行下的测试)
  ├─ 随查随用:10 附录(核心 API 逐个拆解:来历 / 解决什么 / 版本 / 选型 / 坑)
  └─ 长期影响:17 架构(框架选型、全栈边界)、19 AI 原生(流式与 Actions 同源)

建议顺序:0 → 1(建立时间线)→ 2(逐版本细节)→ 立刻用第 5 章把自己的老项目跑一遍迁移评估 → 再读 3(机制)与 4(治理)。

0.4 分级标尺

级别能力特征典型产出自检标准
L1 基础知道每个大版本的主题与代表 API,能看懂 16 与 19 的代码差异能把类组件改写成函数组件 + Hooks能说清「这个 API 是哪一版引入/移除的」
L2 进阶理解每个变化背后的动机与代价,能判断该不该用能独立完成 16 → 18 的升级能解释「为什么需要它」而不是「它怎么用」
L3 高级能从调度、序列化、Hydration 层推理行为能定位并发下的撕裂、RSC 边界错误、Hydration 不匹配能给出可验证的因果链与量化数据
L4 专家能在组织层面做升级决策与长期演进规划迁移路线图、规范、codemod、灰度与回滚机制能对「升不升、什么时候升、升到什么程度」负责

1. L1 基础:一条时间线看完整演进

1.1 版本地图

先建立「哪一版解决了哪一类问题」的骨架,细节在第 2 章展开。

版本时间主题你直接能感知的变化
16.02017-09Fiber 落地可返回数组/字符串、Fragment、Portal、Error Boundary、renderToNodeStream
16.32018-03新 Context / Ref / StrictModecreateContext、createRef、forwardRef、生命周期改 UNSAFE_ 前缀
16.62018-10代码分割的声明式写法React.lazy、Suspense、React.memo、contextType
16.82019-02HooksuseState、useEffect、useContext、useReducer、useMemo、useCallback、useRef
16.92019-08可观测与清理<Profiler>、act 支持异步、移除 unstable_ConcurrentMode
16.142020-10兼容新 JSX支持自动运行时(为 17 铺路)
17.02020-10垫脚石无新特性;新 JSX 转换、事件委托到根、事件池移除、渐进升级
18.02022-03并发能力可用createRoot、自动批处理、startTransition、useDeferredValue、流式 SSR
19.02024-12Actions / RSC / 编译器时代useActionState、useFormStatus、useOptimistic、use、ref 作为 prop、文档元数据、RSC 稳定
19.22025-10后台渲染与 Effect Event<Activity>、useEffectEvent、cacheSignal、Performance Tracks、Partial Pre-rendering
19.32026-09视图过渡与浏览器专属渲染<ViewTransition>、addTransitionType、browser()、<Fragment ref>、onBrowserBailout

读法:16 是「把地基换成可中断结构」,17 是「让升级本身变便宜」,18 是「把并发能力交到业务手里」,19 是「把服务端与表单纳入 React 的正式模型」。19.x 是「把 19 的模型补完整」。

1.2 React 16.0:Fiber 落地,但你还感觉不到「并发」

React 16 最大的变化在底层:Fiber。它把「递归渲染不可中断」改成了「链表式遍历 + 可分段执行」,为后来的并发能力提供基础设施。

但 16 时代没有把并发开关交给业务,所以你感知到的是另外几件事:

// 1. 可以返回数组与字符串,不再必须包一层 div
function List({ items }) {
  return items.map((item) => <li key={item.id}>{item.name}</li>);
}

// 2. 可以返回多个兄弟节点
function Pair() {
  return [<Left key="l" />, <Right key="r" />];
}

// 3. Portal:把子树渲染到别处,但事件仍按 React 树冒泡
function Modal({ children }) {
  return ReactDOM.createPortal(children, document.body);
}

同一版本还带来 Error Boundary:

/**
 * 错误边界:只捕获子树「渲染、生命周期、构造函数」中的错误。
 * 事件回调、setTimeout、SSR 中的错误不会被它捕获。
 */
class Boundary extends React.Component {
  state = { error: null };

  static getDerivedStateFromError(error) {
    return { error };
  }

  componentDidCatch(error, info) {
    report(error, info.componentStack);
  }

  render() {
    return this.state.error ? <Fallback /> : this.props.children;
  }
}

面试级要点:Fiber 在 16 已经存在,但「并发渲染」是 18 才默认开启的能力。把「16 有 Fiber」说成「16 有并发」是最常见的错答。

1.3 React 16.3:新 Context、Ref、StrictMode

这一版的三件事都在解决「老 API 的结构性缺陷」:

  1. 新 Context:老 Context(contextTypes / getChildContext)是隐式的、且会被 shouldComponentUpdate 阻断传播;新 Context 用订阅机制,绕开 SCU。
  2. createRef + forwardRef:让 ref 能穿过 HOC 与函数组件边界,不再依赖字符串 ref。
  3. StrictMode:开发期主动暴露不安全用法(legacy 生命周期、legacy context、findDOMNode)。

同时,componentWillMount / componentWillReceiveProps / componentWillUpdate 被打上弃用警告,改名 UNSAFE_*。

1.4 React 16.6–16.8:Suspense 与 Hooks

16.6 给了「声明式代码分割」:

const Detail = React.lazy(() => import('./Detail'));

function Page() {
  return (
    <Suspense fallback={<Skeleton />}>
      <Detail />
    </Suspense>
  );
}

注意:此时的 Suspense 主要服务 lazy,不支持 SSR,也不支持数据获取。它是「异步 UI 组织方式」的雏形,不是完整能力。

16.8 是 React 历史上最大的 API 转折:Hooks。它把「逻辑复用」从 HOC / render props 的嵌套地狱里解放出来,也让函数组件第一次拥有状态与副作用。

1.5 React 16.9–16.14:Profiler、警告升级、为 17 铺路

  • <Profiler> 提供编程式的渲染耗时采集(onRender)。
  • 对 UNSAFE_ 生命周期的警告从「提示」升级为「明确迁移指引」。
  • 移除 unstable_ConcurrentMode、unstable_Profiler 等实验入口。
  • 16.14 支持新的 JSX 运行时,让 17 的新 JSX 转换可以平滑回移。

1.6 React 17:没有新特性的「垫脚石」

React 17 的定位非常特殊:它几乎不带来新能力,只降低「升级本身」的成本。官方称之为「stepping stone release」。

它做了四件对迁移极有价值的事:

  1. 新 JSX 转换:不再需要 import React from 'react',产物更小。
  2. 事件委托从 document 改到根容器:同一页面可以并存多个 React 版本,微前端与渐进升级成为可能。
  3. 移除事件池:e.persist() 变成 no-op,异步里读事件对象不再踩坑。
  4. effect 清理时机调整:卸载时的 cleanup 改为绘制后异步执行。

一句话理解:17 是「为了让 18 能装进来」而做的接口改造。它自己不加功能,但让 16 → 18 的跨度变成两小步。

1.7 React 18:并发能力正式可用

18 的关键词是**「可选启用」**:并发渲染不是全局开关,而是通过 createRoot + 并发特性(Transition / Suspense / useDeferredValue)按需使用。

import { createRoot } from 'react-dom/client';

const root = createRoot(document.getElementById('root'));
root.render(<App />);
// 高优先级:输入;低优先级:大列表
const [isPending, startTransition] = useTransition();

function onChange(e) {
  setQuery(e.target.value);           // 紧急更新
  startTransition(() => {
    setList(filter(e.target.value));  // 可中断的过渡更新
  });
}

同时,18 把「服务端流式」做成了正式能力(renderToPipeableStream / renderToReadableStream),并新增 useId、useSyncExternalStore、useInsertionEffect。

1.8 React 19:Actions、RSC、编译器

19 的关键词是**「把异步与服务端纳入模型」**:

  • Actions:<form action={fn}>、useActionState、useFormStatus、useOptimistic,把 pending / error / 乐观更新变成框架能力。
  • use:在渲染中读取 Promise 或 Context,且可以条件调用。
  • RSC 稳定:Server Components 与 Server Actions 进入正式版。
  • ref 作为 prop:函数组件不再必须 forwardRef。
  • 文档元数据:<title> / <meta> / <link> 可写在组件里,自动提升到 <head>。
  • 资源加载:preload / preinit / prefetchDNS / preconnect。
  • 错误可诊断性:Hydration diff、onCaughtError / onUncaughtError。
  • 移除旧 API:ReactDOM.render、findDOMNode、字符串 ref、函数组件 defaultProps 等。

1.9 React 19.x:把小版本也纳入视野

版本关键新增解决什么
19.1captureOwnerStack、更完整的 owner stack让「这个组件是谁渲染的」在错误里可追溯
19.2<Activity>、useEffectEvent、cacheSignal、Performance Tracks、Partial Pre-rendering后台预渲染隐藏页面、把「事件逻辑」从 effect 依赖里拆出来、SSR 静态壳 + 动态恢复
19.3<ViewTransition>、addTransitionType、browser()、Fragment ref用 React 管视图过渡;把「只能在浏览器渲染」的子树显式标记出来
Compiler 1.0(2025-10)编译期自动 memo 化让「手工 useMemo / memo」从默认写法变成兜底手段

1.10 L1 小结与自检

版本最该记住的一句
16.0Fiber 是可中断渲染的地基,但并发开关还没交出来
16.3新 Context 用订阅绕开 SCU,老 Context 会被阻断
16.6lazy + Suspense 只是代码分割,不是数据获取
16.8Hooks 解决逻辑复用与生命周期耦合,代价是依赖数组
17不加功能,只降低升级成本(新 JSX、事件挂根、渐进升级)
18并发是「可选启用」;自动批处理覆盖所有更新来源
19Actions 管异步状态,RSC 管服务端边界,use 管渲染期读取
19.2<Activity> 让隐藏页面可预渲染,useEffectEvent 拆非响应式逻辑
19.3视图过渡与浏览器专属子树成为一等 API

自检题

  1. React 16 已经有 Fiber,为什么不能说它已经有并发渲染?——Fiber 只是把渲染拆成可中断的单元;16 没有把优先级调度、Transition、可丢弃渲染暴露给业务,真正可用是 18 的 createRoot + 并发特性。
  2. React 17 为什么被称为「垫脚石」?——它不引入新能力,只做接口改造:新 JSX 转换、事件委托到根容器、移除事件池、清理时机调整,让 16 → 18 可以分两步走,并支持同页多版本。
  3. React.lazy 配 Suspense 在 16.6 能用于 SSR 吗?——不能;16 的 Suspense 主要服务客户端代码分割,SSR 场景需要 loadable-components 之类的方案,直到 18 才有流式 SSR。

2. L2 进阶:逐版本「变化详情 + 为什么 + 怎么用 + 坑」

这一章按版本顺序展开,每一节都按「是什么 → 为什么需要 → 怎么用 → 常见坑」组织。

2.1 React 16.0 详解:Fiber 与「返回值自由」

一句话理解:React 16 把「一口气爬完整棵树」改成了「可以随时停下、记住位置、下次接着爬」,顺便放开了组件返回值的限制。

2.1.1 Fiber 到底是什么

先看 15 的渲染方式:递归 + 栈。

render(<App />)
  → render(App) → render(Layout) → render(Sidebar) → ... 一路递归到底
  → 递归返回时才算完,整个过程中主线程被占住

问题在于:递归一旦开始,没有地方保存「我走到哪了」。函数调用栈是浏览器管理的,React 无法在中间插一句「先停一下,让用户点个按钮」。所以 15 时代一个大列表更新,输入框就会卡住。

Fiber 的做法是把「递归」换成「链表 + 循环」:

App
 ├─ child → Layout
 │            ├─ child → Sidebar
 │            │            └─ sibling → Content
 │            └─ sibling → Footer
 └─ sibling → ...

每个 Fiber 节点上有四个关键指针:

字段指向作用
child第一个子节点往下走
sibling下一个兄弟节点往右走
return父节点走完了往回退
alternate另一棵树上的对应节点双缓冲(见 3.4)

因为「走到哪」被显式存在节点上,React 可以在任意两个节点之间让出主线程,下一帧再从记住的位置继续。

通俗类比:递归像「一口气把整本书读完,中途不能停」;Fiber 像「夹了书签,读两页可以放下,回来接着读」。

2.1.2 为什么说 Fiber 不等于「并发可用」

16 的 Fiber 只完成了「结构改造」,还缺三样东西:

  1. 优先级:没有 lane,无法表达「输入比列表重要」。
  2. 开关:没有 createRoot,业务无法主动启用可中断渲染。
  3. 配套能力:没有 Transition、没有 Suspense 数据读取、没有流式 SSR。

所以 16 的 Fiber 更像「预留了接口的地基」:房子能盖,但电梯要等 18 才装。面试里说「16 有 Fiber 所以 16 有并发」是典型的概念混淆。

2.1.3 返回值自由:从「必须一个根节点」到「可以是数组」

15 的组件必须返回单个 React 元素,多一个兄弟节点就要包一层 <div>,于是 DOM 里多出一堆无语义的容器。16 放开了限制:

/**
 * 返回数组:每个元素必须有稳定的 key,否则会收到警告。
 * @param {{ items: Array<{ id: string, name: string }> }} props
 */
function List({ items }) {
  return items.map((item) => <li key={item.id}>{item.name}</li>);
}

/**
 * 返回字符串与 null 都是合法的。
 */
function Label({ text }) {
  return text ? text : null;
}

三个细节:

  • 数组必须有 key:React 靠 key 做同层比对,没有 key 会退化成「按索引复用」,插入/排序时状态会错位。
  • 返回 null 表示「什么都不渲染」,但仍然是一个合法组件,会参与父组件的 children 计算。
  • Fragment 是「不产生 DOM 的分组」:<React.Fragment> 或 16.2 起的 <>...</>,用于「需要多个兄弟但不想加容器」。

2.1.4 Error Boundary:只接住「渲染期」的错误

/**
 * 错误边界:捕获子树在「渲染、构造函数、生命周期」中抛出的错误。
 * 注意:事件回调、setTimeout、Promise、SSR 中的错误不会被捕获。
 */
class Boundary extends React.Component {
  state = { error: null };

  // 渲染阶段调用:返回新的 state 让 UI 降级
  static getDerivedStateFromError(error) {
    return { error };
  }

  // 提交阶段调用:适合上报日志,不要在这里 setState
  componentDidCatch(error, info) {
    report(error, info.componentStack);
  }

  render() {
    return this.state.error ? <Fallback error={this.state.error} /> : this.props.children;
  }
}

两个生命周期分工不同:

方法调用时机能做什么不能做什么
getDerivedStateFromErrorrender 阶段返回 state 触发降级 UI不能有副作用(不能发请求)
componentDidCatchcommit 阶段上报、打日志不能 setState 改变降级结果

边界之外的错误要靠别的手段:

window.addEventListener('error', onError);              // 资源加载、脚本错误
window.addEventListener('unhandledrejection', onReject); // 未处理的 Promise

2.1.5 Portal:DOM 位置与 React 树位置分离

/**
 * 抽屉:视觉上挂到 body 下(避免被父级 overflow/z-index 裁剪),
 * 逻辑上仍在原来的 React 树里,所以 context、事件冒泡都照常。
 */
function Drawer({ open, onClose, children }) {
  if (!open) return null;
  return ReactDOM.createPortal(
    <div role="dialog" aria-modal="true" onClick={onClose}>
      {children}
    </div>,
    document.body
  );
}

要点:

  • 解决什么:父容器有 overflow: hidden 或 transform 时,弹层会被裁剪或层级错乱;Portal 把它挪到 body 下。
  • 事件仍按 React 树冒泡:Portal 内的点击,父组件的 onClick 依然能收到——这是「React 树」与「DOM 树」分离的结果。
  • 19 的 <dialog> 更省事:原生 showModal() 自带顶层渲染,很多场景不再需要 Portal。

2.1.6 流式 SSR:renderToNodeStream 的真实能力

在讲流式之前先补一句:16 时代真正在跑的 SSR 主力是 ReactDOMServer.renderToString()——同步、一次性把整棵树转成 HTML 字符串,慢数据只能等。它的来历、内部流程、选型与坑在 10.1 有完整拆解;本节只讲 16 新增的流式变体。

import { renderToNodeStream } from 'react-dom/server';

// 边渲染边把 HTML 写进响应,首字节更早到达
const stream = renderToNodeStream(<App />);
stream.pipe(res);

它的收益是缩短 TTFB:不必等整棵树渲染完才开始发送。但它有两个局限:

  • 不能按 Suspense 分块:16 的 Suspense 不支持 SSR,遇到会退化。
  • 错误处理粗糙:流已经开始发送后再出错,只能中断连接,无法优雅降级。

真正的「按边界分块 + 错误兜底」要等 18 的 renderToPipeableStream。

2.1.7 常见坑

  • Error Boundary 不捕获事件回调、异步代码、SSR 中的错误;这些要靠 window.onerror / unhandledrejection / 框架级错误处理。
  • Portal 的 DOM 位置变了,但事件冒泡仍按 React 树,所以父组件的 onClick 仍会收到 Portal 内的事件;不想要就用 e.stopPropagation() 或调整结构。
  • 「16 有 Fiber」不等于「16 有并发」:并发要等 18 的 createRoot 与 lane 调度。
  • 返回数组时忘了 key,控制台会警告;用索引当 key 在「插入/排序」场景会让组件状态跟错行。

2.2 React 16.3 详解:新 Context、Ref、StrictMode

一句话理解:这一版把三样「靠约定、靠字符串、靠隐式传递」的老机制,换成了「显式、可组合、可检查」的新机制。

2.2.1 老 Context 为什么必须淘汰

15 时代用 contextTypes / getChildContext 传值:

// 老写法(16.3 起逐步废弃,19 移除)
class Parent extends React.Component {
  getChildContext() {
    return { theme: 'dark' };
  }
}
Parent.childContextTypes = { theme: PropTypes.string };

function Child(props, context) {
  return <div>{context.theme}</div>;
}
Child.contextTypes = { theme: PropTypes.string };

它有两个结构性缺陷:

  1. 隐式:值从哪来、有哪些字段,全靠运行时约定;TypeScript 也帮不上忙。
  2. 会被 shouldComponentUpdate 阻断:Context 的传播依赖「父组件重新渲染并往下传」,中间任何一层返回 false,下游就永远收不到新值。

第二个缺陷是致命的:你为了让性能更好加了 shouldComponentUpdate,结果 Context 失效——两个正确诉求互相打架。

2.2.2 新 Context 的订阅机制

新 Context 不再依赖父链传递,而是Provider 直接持有订阅者列表:

Provider value 变化
  → 遍历自己的 subscribers
  → 逐个标记需要更新(不经过中间组件)
  → 消费组件重渲染

中间层组件即使 shouldComponentUpdate 返回 false,也不影响订阅者
const ThemeContext = React.createContext('light');

function App() {
  return (
    <ThemeContext.Provider value="dark">
      <Toolbar />
    </ThemeContext.Provider>
  );
}

/**
 * 只有 context value 变化时才重渲染;
 * 与父组件是否重渲染、是否被 SCU 拦住无关。
 */
function Toolbar() {
  const theme = React.useContext(ThemeContext);
  return <div className={theme} />;
}

三个必须知道的细节:

  • 比较的是引用:value={{ theme: 'dark' }} 每次渲染都是新对象,会触发所有消费者重渲染。要么用常量,要么 useMemo。
  • useContext 的组件「读什么就更新什么」:value 一变,所有消费者都重渲染,没有「只订阅某个字段」的能力。需要细粒度就拆多个 Context。
  • 默认值只在「没有 Provider」时生效:嵌套 Provider 会覆盖外层,这正好用来做局部主题。

19 里 Provider 可以简写为 <ThemeContext value="dark">,但底层机制不变。

2.2.3 Ref 的三种写法

写法形态适用注意
回调 refref={(node) => ...}需要在挂载时做副作用每次渲染都会以新函数调用,旧实现会先 null 再 node
createRefref={this.inputRef}类组件函数组件里每次渲染都会新建,等于没用
forwardRefReact.forwardRef((props, ref) => ...)把 ref 透传给内部 DOM不自动继承 displayName
// 1. 回调 ref:挂载时聚焦,卸载时可清理(19 支持返回 cleanup)
<input
  ref={(node) => {
    if (!node) return;
    node.focus();
  }}
/>

// 2. createRef:类组件
class Form extends React.Component {
  inputRef = React.createRef();
  render() {
    return <input ref={this.inputRef} />;
  }
}

// 3. forwardRef:HOC / 组件库把 ref 交给使用方
const FancyInput = React.forwardRef(function FancyInput(props, ref) {
  return <input {...props} ref={ref} />;
});
FancyInput.displayName = 'FancyInput'; // 调试时能看到名字

为什么需要 forwardRef:函数组件没有实例,ref 默认无处可挂。要么用 useImperativeHandle 暴露一个自定义对象,要么用 forwardRef 把 ref 传给内部 DOM。

/**
 * 只暴露 focus 与 clear,不把整个 DOM 节点交给外部。
 * @param {Object} props
 * @param {React.Ref} ref
 */
const SearchInput = React.forwardRef(function SearchInput(props, ref) {
  const inputRef = React.useRef(null);

  React.useImperativeHandle(ref, () => ({
    focus: () => inputRef.current.focus(),
    clear: () => { inputRef.current.value = ''; },
  }), []);

  return <input ref={inputRef} {...props} />;
});

19 起函数组件可以直接把 ref 当普通 prop 接收,forwardRef 不再是必需品(见 2.8.4)。

2.2.4 StrictMode 的定位:从「检查」到「压力测试」

16.3 的 StrictMode 做的是静态检查 + 警告:

  • 用了 UNSAFE_ 生命周期;
  • 用了 legacy context;
  • 用了 findDOMNode;
  • 用了字符串 ref。

18 起它变成主动压力测试:开发环境故意双调用渲染、双执行 effect,用来暴露「不纯的渲染」与「没清理干净的副作用」。这不是 bug,而是把你未来在并发模式下会遇到的问题提前暴露。

// 开发环境:这段 effect 会执行两次
useEffect(() => {
  const sub = store.subscribe(handle);
  return () => sub.unsubscribe(); // 有 cleanup 才安全
}, []);

2.2.5 常见坑

  • 字符串 ref(ref="input")在这一版被标记为不推荐,19 彻底移除;它依赖 this.refs,与并发渲染不兼容。
  • forwardRef 包裹后,displayName 不会自动继承,DevTools 里会显示 ForwardRef,建议显式设置。
  • 老 Context 与新 Context 混用会出现「一半更新、一半不更新」:老的那半仍受 SCU 阻断。
  • Context.Provider 的 value 传对象字面量,会让所有消费者每次都重渲染——这是「用了 Context 反而更卡」的最常见原因。

2.3 React 16.6 详解:lazy + Suspense + memo

一句话理解:这一版第一次让「组件还没准备好」变成一个可以声明的状态,而不是一堆手写的 loading 变量。

2.3.1 React.lazy 在做什么

const Chart = React.lazy(() => import('./Chart'));

拆开看:

  1. import('./Chart') 返回一个 Promise,resolve 出模块对象 { default: Chart };
  2. React.lazy 接收这个函数(必须返回 Promise),产出一个「懒加载组件」;
  3. 首次渲染时,React 调用该函数拿到 Promise,然后「挂起」这棵子树,交给最近的 Suspense 显示 fallback;
  4. Promise resolve 后,React 用真正的组件重新渲染。

关键点:lazy 只负责「代码(chunk)什么时候到」,不负责数据。数据获取的 Suspense 要等 18 的框架能力。

/**
 * 路由级代码分割:首屏不加载 Chart 所在 chunk。
 */
function Dashboard() {
  return (
    <Suspense fallback={<ChartSkeleton />}>
      <Chart />
    </Suspense>
  );
}

2.3.2 Suspense 的「最近边界」规则

Suspense 不是全局的,它按最近祖先边界生效:

<Suspense fallback="A">
  <Header />
  <Suspense fallback="B">
    <Chart />   ← 挂起时显示 B,不是 A
  </Suspense>
</Suspense>

这条规则解释了两个常见现象:

  • 把 Suspense 放得太高,一处挂起会让整页闪 fallback;
  • 放得太低,fallback 区域太小,页面会「东一块西一块」地跳。

实用建议:按「内容块」放边界——列表、图表、评论区各一个,而不是整页一个。

2.3.3 React.memo:浅比较的边界

/**
 * 只有 props 浅比较不相等时才重渲染。
 * 对象/数组/函数类型的 props 每次新建,都会让 memo 失效。
 */
const Row = React.memo(function Row({ item, onSelect }) {
  return <div onClick={() => onSelect(item.id)}>{item.name}</div>;
});

它等价于函数组件的 PureComponent,比较规则是 Object.is 逐字段比较。

失效的三种典型写法:

// 1. 每次渲染都新建对象
<Row item={{ ...item }} />

// 2. 每次渲染都新建数组
<Row tags={['a', 'b']} />

// 3. 每次渲染都新建函数
<Row onSelect={(id) => select(id)} />

正确做法是把它们稳定下来(useMemo / useCallback),或者干脆不 memo:如果父组件本来就不常重渲染,memo 的比较成本可能比省下的渲染还贵。

2.3.4 contextType:类组件读 Context 的捷径

class ThemeButton extends React.Component {
  static contextType = ThemeContext;

  render() {
    return <button className={this.context}>{this.props.label}</button>;
  }
}

它等价于 useContext,但只能读一个 Context,且必须在类组件里用。

2.3.5 常见坑

  • lazy 必须被 Suspense 包裹,否则会抛错;错误边界也建议一起加上,chunk 加载失败时能降级。
  • 16.6 的 Suspense 不支持 SSR:服务端渲染 lazy 组件会失败,常见方案是 @loadable/component。
  • memo 只做浅比较;嵌套对象里的字段变了但引用没变时,它不会更新——这是「数据改了 UI 没变」的经典原因。
  • 把 Suspense 放在 lazy 组件内部是无效的:边界必须在外面才能接住挂起。

2.4 React 16.8 详解:Hooks

一句话理解:Hooks 让「一个功能相关的代码」待在一起,而不是被拆散到 componentDidMount、componentDidUpdate、componentWillUnmount 三个地方。

2.4.1 三个真实痛点

痛点 1 · 逻辑复用只能靠「包裹」

// HOC 层层嵌套:谁给谁传了什么,靠猜
withRouter(withTheme(withAuth(withI18n(Page))))

// render props:嵌套回调,缩进爆炸
<Auth>{user => (
  <Theme>{theme => (
    <I18n>{t => <Page user={user} theme={theme} t={t} />}</I18n>
  )}</Theme>
)}</Auth>

复用的是「结构」,不是「逻辑」,而且会产生额外的组件层级与 props 命名冲突。

痛点 2 · 生命周期把无关逻辑揉在一起

// 一个 componentDidMount 里干了三件互不相关的事
componentDidMount() {
  fetchUser();                              // 数据
  tracker.pageView();                       // 埋点
  this.sub = store.subscribe(this.onChange); // 订阅
}
componentWillUnmount() {
  this.sub.unsubscribe();                   // 清理和订阅隔了几十行
}

痛点 3 · this 与绑定

class Form extends React.Component {
  constructor(props) {
    super(props);
    this.handleChange = this.handleChange.bind(this); // 每次都写
  }
}

Hooks 的解法是把「订阅 + 清理」封装成一个函数,按功能组织。

2.4.2 自定义 Hook:逻辑复用的最小单位

/**
 * 订阅窗口宽度:把「订阅 / 清理」封装成可复用单元。
 * 注意:Hook 名字必须以 use 开头,lint 才能检查规则。
 * @returns {number} 当前视口宽度
 */
function useWindowWidth() {
  const [width, setWidth] = React.useState(window.innerWidth);

  React.useEffect(() => {
    const onResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', onResize);
    // 返回清理函数:组件卸载或依赖变化时执行
    return () => window.removeEventListener('resize', onResize);
  }, []); // 空依赖:只订阅一次

  return width;
}

function Layout() {
  const width = useWindowWidth();
  return <div>{width > 768 ? <Desktop /> : <Mobile />}</div>;
}

自定义 Hook 本质是「函数调用 + 状态复用」,不是「组件复用」:它不产生新的组件层级,也不共享状态——每个调用方都拿到独立的一份。

2.4.3 「只在顶层调用」到底在防什么

React 内部用一个链表按调用顺序保存每个 Hook 的状态:

第一次渲染:
  useState → 链表节点 1
  useEffect → 链表节点 2
  useRef → 链表节点 3

第二次渲染(顺序必须一致):
  useState → 取节点 1
  useEffect → 取节点 2
  useRef → 取节点 3

如果在条件里调用:

// 错误:第一次 isOpen 为 true 时有 3 个 Hook,
// 第二次为 false 时只有 2 个,后面的状态全部错位
if (isOpen) {
  useEffect(() => { ... });
}
const [x, setX] = useState(0);

React 不会「按名字」找状态,只能按顺序取,所以顺序必须稳定。这也是为什么 Hooks 不能写在循环、条件、嵌套函数里。

2.4.4 闭包陷阱:为什么 useEffect 读到旧值

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      // 这里捕获的是「本次渲染」的 count,永远停在 0
      console.log(count);
    }, 1000);
    return () => clearInterval(id);
  }, []); // 空依赖 → 只创建一次 → 闭包永远是第一次的
}

原理:每次渲染都是一次函数调用,函数里的变量属于那次调用。useEffect 的回调被保存下来时,捕获的是当时的 count。

三种解法:

// 1. 补齐依赖(每次 count 变化重建定时器)
useEffect(() => { ... }, [count]);

// 2. 用 ref 读最新值(不重建副作用)
const countRef = useRef(count);
countRef.current = count;
useEffect(() => {
  const id = setInterval(() => console.log(countRef.current), 1000);
  return () => clearInterval(id);
}, []);

// 3. 用函数式更新,避免读 state
setCount((c) => c + 1);

2.4.5 useEffect 与 useLayoutEffect 的时机

Hook执行时机适合
useLayoutEffectDOM 更新后、浏览器绘制前,同步测量布局、同步改样式,避免闪烁
useEffect浏览器绘制后,异步请求、订阅、日志,不阻塞绘制

经验法则:能用 useEffect 就不要用 useLayoutEffect;后者会阻塞绘制,滥用会让页面变卡。

2.4.6 常见坑

  • 闭包陷阱:useEffect 里读到的是「那次渲染的」props/state;要最新值用 ref 或补齐依赖。
  • 依赖数组撒谎:漏依赖会拿到旧值,多依赖会频繁执行;两者都会让 lint 失效。
  • useMemo / useCallback 不是免费:它们本身有比较成本,滥用会让代码更难读且不一定更快。
  • 在 render 里做副作用:请求、订阅、改 DOM 都应该放进 effect;render 必须可重复执行。
  • useState 的初始值只在首次生效:后续传入的新值会被忽略,需要同步时用 effect 或 key 重置。

2.5 React 16.9 详解:Profiler、异步 act 与「清理信号」

一句话理解:16.9 没给你新能力,它做的是两件事——让你第一次能用代码量出「哪个组件慢」,以及把实验性入口收回去,为 17/18 的正式发布腾地方。

2.5.1 Profiler 到底在测什么

React 的渲染分两段:render(算)和 commit(改 DOM)。Profiler 测的是这棵子树在一次提交里花了多久,并且按「提交」逐条上报。

import { Profiler, useEffect } from 'react';

/**
 * 包在需要观测的子树外面。
 * @param {string} id 标识符,同一个 id 的多次上报可以串成时间线
 * @param {(id: string, phase: 'mount' | 'update' | 'nested-update', actualDuration: number, baseDuration: number, startTime: number, commitTime: number) => void} onRender 每个 commit 后调用
 */
function Dashboard() {
  return (
    <Profiler id="Dashboard" onRender={onRender}>
      <HeavyChart />
    </Profiler>
  );
}

onRender 的核心字段:

字段含义怎么用
phasemount / update / nested-update区分首屏与更新;nested-update 说明是子树内部 setState 触发的
actualDuration本次 render 实际耗时(ms)定位「哪一次提交慢」
baseDuration假设完全不用 memo 时的估算耗时与 actualDuration 的差距 ≈ memo 省下的时间
startTime本次 render 开始的时间戳与长任务、输入延迟对齐
commitTime本次 commit 的时间戳算「render 完成到上屏」的间隔

读法:actualDuration 高、baseDuration 也高 → 这棵子树本身重,该虚拟化或拆分;actualDuration 远小于 baseDuration → memo 已经很有效,不必再折腾。

2.5.2 一个「慢组件排行榜」的最小实现

/**
 * 采样并聚合渲染耗时,用来决定「先优化谁」。
 * 注意:只在开发 / profiling 构建里有数据,生产构建会被剥离。
 */
const stats = new Map();

function onRender(id, phase, actualDuration) {
  const prev = stats.get(id) ?? { count: 0, total: 0, max: 0 };
  stats.set(id, {
    count: prev.count + 1,
    total: prev.total + actualDuration,
    max: Math.max(prev.max, actualDuration),
  });
}

/**
 * 打印平均耗时 Top 10。
 * @returns {Array<{ id: string, avg: number, max: number, count: number }>}
 */
function report() {
  return [...stats.entries()]
    .map(([id, s]) => ({ id, avg: s.total / s.count, max: s.max, count: s.count }))
    .sort((a, b) => b.avg - a.avg)
    .slice(0, 10);
}

三条使用纪律:

  • Profiler 本身有开销:每个边界都要多一层计时与闭包,别给列表的每一行都包一个。
  • 嵌套会重复计时:外层 Profiler 的 actualDuration 已经包含内层,汇总时别重复累加。
  • 生产要看真实用户数据:Profiler 只在 profiling 构建有效;线上应该看 RUM 的 INP / LCP / TBT(见 11 性能优化)。

2.5.3 act 支持异步:测试终于能「等」effect 了

16.8 的 act 只处理同步更新。组件里一旦有 await 或 Promise.then 触发的 setState,测试就会漏掉这些更新,出现「断言时 UI 还没变」的假失败。

16.9 起 act 支持 async 回调:

import { act } from 'react-dom/test-utils';

test('加载后展示数据', async () => {
  await act(async () => {
    // 回调里产生的微任务与由此触发的 effect 都会被排空
    render(<UserProfile id="1" />);
    await Promise.resolve();
  });
  expect(screen.getByText('Ada')).toBeInTheDocument();
});

要点:

  • await act(async () => {...}) 会把回调里的微任务、以及由它们触发的 effect 一起 flush;
  • 19 里 act 从 react-dom/test-utils 移到 React.act,测试代码要改 import;
  • 现代 Testing Library 大多已内部包了 act,手写 act 的场景在减少。

2.5.4 「移除 unstable_ConcurrentMode」是一个路线信号

16.6–16.8 你会看到这样的实验写法:

// 16.9 起失效
<React.unstable_ConcurrentMode>
  <App />
</React.unstable_ConcurrentMode>

它被删掉不代表「并发被砍」,而是并发不再以「模式开关」的形式发布。最终形态是 18 的 createRoot + 按特性启用(Transition / useDeferredValue / Suspense)。这能解释一个常见困惑:为什么 17 什么都不加,18 却一次性放出大量并发能力——中间是在换发布方式,不是在憋功能。

2.5.5 常见坑

  • 以为 Profiler 能上线采集:默认构建没有 onRender 数据,需要专门的 profiling 构建。
  • 把 Profiler 包在 list.map 的每一项上,测量开销本身超过了被测内容。
  • 直接横向对比不同页面的 actualDuration:它受设备、并发任务、React 版本影响,只能同环境同条件比。
  • 测试里忘了 await act(...),拿到的是「更新前」的 DOM,于是误判为「组件没响应」。

2.6 React 17 详解:四件「看不见但很值钱」的事

一句话理解:17 是「只改接口、不改发动机」的一年。它一个功能都没加,但每件改动都在为「18 能安全装进来」和「多版本能同页共存」铺路。

2.6.1 为什么官方要做「没有新特性」的版本

如果直接从 16 升 18,你会在一次发布里同时面对两类风险:兼容性变化(旧 API、事件、清理时机)和运行模型变化(并发渲染、批处理)。出问题时根本分不清是谁的锅。

17 把这两类风险拆开:先升到 17,只暴露「兼容性」问题;等 17 稳定运行一段时间,再升 18 去面对「并发」问题。这就是官方叫它 “stepping stone release”(垫脚石版本)的原因。

顺带还拿到一个能力:同一页面可以并存多个 React 版本,微前端与渐进升级第一次变得可行(见 2.6.6)。

2.6.2 新 JSX 转换:不再需要 import React

表面变化:文件里可以删掉 import React from 'react'。

// 17 之前:不 import React 就报 "React is not defined"
import React from 'react';
export default function App() {
  return <div>hi</div>;
}

// 17 之后(tsconfig 里 "jsx": "react-jsx"):这句 import 可以删
export default function App() {
  return <div>hi</div>;
}

底层变化:编译产物换了函数。

// 旧转换(classic):编译结果
import React from 'react';
function App() {
  return React.createElement('div', null, 'hi');
}

// 新转换(automatic):编译结果
import { jsx as _jsx } from 'react/jsx-runtime';
function App() {
  return _jsx('div', { children: 'hi' });
}

三个值得知道的细节:

  • key 不再混在 props 里:新转换把 key 作为第三个参数单独传,所以「key 不会出现在 props 里」这件事变得更彻底。
  • jsx 与 jsxs 的分工:单个 child 用 jsx,多个 child 用 jsxs(静态 children 走数组快路径);开发构建用 jsxDEV,附带源码位置,报错能定位到行。
  • 收益是产物体积:省掉 createElement 的运行时判断与 React 的隐式引用,同时不再因为「只用了 JSX」而被迫保留一个未使用的 React 变量。

配置方式:

// tsconfig.json
{ "compilerOptions": { "jsx": "react-jsx" } }
// babel.config.js
module.exports = {
  presets: [['@babel/preset-react', { runtime: 'automatic' }]],
};

什么情况下仍然要 import React:你要用 React.useState、React.Fragment、React.createElement 这类「命名空间成员」时,import 依然必须存在——新转换只免掉了「只写 JSX 时的隐式引用」。

注意版本门槛:新 JSX 运行时是 17 的默认值,但 16.14 已经回移支持。这意味着你可以先在 16.14 打开新转换、删掉多余的 import,再单独升 17,改动量更小。

2.6.3 事件委托从 document 移到根容器

先理解「事件委托」:React 并不给每个节点挂监听器,而是在一个祖先节点上挂一个监听器,靠「事件冒泡 + 内部映射表」反查是哪个组件触发的。这个祖先节点就是「委托点」。

React 16:               React 17:
  document                 div#root
    └ div#root               └ div#root   ← 监听器在这里
        └ <App />                └ <App />
// 监听器跟着这个容器走,不再跟着 document
const container = document.getElementById('root');
ReactDOM.render(<App />, container); // 16 也这么写,但监听器挂在 document

核心收益:每个 root 有独立的一套事件系统。

  • 微前端里 A 子应用(React 16)和 B 子应用(React 18)互不干扰:e.stopPropagation() 不会跨 root 误伤。
  • 渐进升级时可以「一个 root 一个版本」,见 2.6.6。
  • 局部渲染(比如只在某个弹窗里挂 React)不会影响页面其他部分的事件。

代价(最容易踩的坑):

  • 在 document 上「拦截」事件的代码会失效:16 的监听器在 document 上,你可以在 document 的捕获阶段 stopPropagation 拦住 React 的事件;17 之后 React 在 root 就处理完了,document 上的拦截已经来不及。
  • 依赖「React 在 document 上做委托」的工具会受影响:埋点若依赖在 document 上接收「React 合成事件」,要改成在 root 或组件层上报;部分测试工具、Simulate 类实现的假设也会变。
  • 注意区分:原生事件仍然会冒泡到 document,所以单纯的 document.addEventListener('click', fn) 不受影响;变的是「React 自己的监听器位置」。
  • onScroll 从 17 起不再冒泡。以前靠「外层 div 接住内部滚动事件」的写法会失效,要改成直接绑定滚动容器,或在捕获阶段处理。
  • onFocus / onBlur 内部改用 focusin / focusout(因为 focus 本身不冒泡),这让「委托处理焦点」变得可能,但事件名与触发顺序和你手写的原生监听可能对不上。

2.6.4 移除事件池:e.persist() 退出历史

16 的做法:合成事件对象是池化复用的。回调一结束,对象就被清空、放回池子给下一个事件用——这是为了减少 GC 压力。副作用是:异步代码里读 e.target 会拿到 null,必须先 e.persist() 把这个对象从池子里「摘出来」。

// 16 时代必须这么写
function onClick(e) {
  e.persist();
  setTimeout(() => console.log(e.target), 0);
}

// 17 之后:直接读就行,e.persist() 仍在但已是空操作
function onClick(e) {
  setTimeout(() => console.log(e.target), 0);
}

为什么能删:现代浏览器的合成事件分配成本已经不高,池化带来的「反直觉」代价超过了 GC 收益。

要注意:老代码里残留的 e.persist() 不会报错,也不会报警告,属于「可以慢慢清理」的噪音;但不要再基于「事件对象会被回收」去写任何逻辑。

2.6.5 effect 清理时机:卸载时改成异步

改动:组件卸载时,useEffect 的 cleanup 不再在 commit 阶段同步执行,而是绘制之后异步执行,与「更新时 cleanup 的时机」保持一致。

useEffect(() => {
  const sub = store.subscribe(handleChange);
  // 卸载时这个函数不会立刻同步执行,而是在本次绘制之后
  return () => sub.unsubscribe();
}, []);

为什么改:卸载一棵大树时,如果同步跑完所有 cleanup,会阻塞这次绘制,用户会看到明显掉帧。统一成异步后,卸载的视觉反馈先出来,清理随后跟上。

带来的细微差异:从「组件卸载」到「cleanup 执行」之间存在一个极短窗口,期间订阅仍然存在。依赖「卸载瞬间同步释放资源」的逻辑(比如立刻释放全局锁、立刻置空单例)要改成显式清理,或放到 ref 回调的 cleanup 里。

2.6.6 渐进升级:同一页面并存两个 React 版本

因为事件委托到了各自的 root,两个版本的 React 可以同时活在一个页面里。这对「30 个页面的老后台慢慢换框架」是决定性的能力。

/**
 * 在老应用(React 16)的一个挂载点里,启动一个新应用(React 18)。
 * 两个版本各自调度、各自绑定事件,靠 DOM 边界隔离。
 */
function LegacyHost() {
  const hostRef = useRef(null);

  useEffect(() => {
    const root = React18DOM.createRoot(hostRef.current);
    root.render(<NewWidget />);
    return () => root.unmount();
  }, []);

  return <div ref={hostRef} />;
}

官方明确的四条限制(一定要记住,否则会写出「看起来能跑」的诡异代码):

限制说明
事件不跨版本冒泡外层 16 的 root 收不到内层 18 的合成事件,需要在边界上用原生 DOM 事件通信
Context 不跨版本两个版本各有自己的 Context 实现,不能互相 useContext
组件不能跨版本渲染18 写的组件不能直接放进 16 的树里,反之亦然
全局状态要桥接共享数据靠 props、事件、或独立的 store(如 useSyncExternalStore 之外的普通订阅)

工程上的代价:两套 scheduler、两份 runtime、可能的样式与全局变量冲突。所以它适合「按子应用/按路由迁移」,不适合长期并存。

2.6.7 常见坑

  • 在 document 上「拦截 React 事件」的写法会失效(拦截时机变晚);依赖 React 委托位置的埋点要改到 root 或组件层。
  • onScroll 不再冒泡,靠冒泡做「滚动到底部加载」的外层委托写法会失灵。
  • 升级到 17 后 onFocus / onBlur 的触发顺序可能变化(内部改用 focusin / focusout),有依赖顺序的表单逻辑要回归。
  • 卸载时 cleanup 变异步:有「卸载即释放」假设的代码要重写。
  • 同页多版本时,别指望 e.stopPropagation() 能跨 root 生效;边界通信要用原生事件或显式的 props 回调。

2.7 React 18 详解:并发、批处理、流式

一句话理解:18 把「渲染」从「一次做完的同步任务」变成了「一组可以被排序、被打断、被丢弃的任务」,并给了你三个遥控器:入口(createRoot)、优先级(Transition)、交付方式(流式 SSR)。

2.7.1 createRoot:18 所有能力的总入口

为什么必须换入口:17 及以前的 ReactDOM.render 会进入 legacy 模式——渲染是同步的、不可中断的,批处理只覆盖 React 事件回调。18 的新能力全部建立在「可中断渲染」之上,而可中断需要有地方存「算到哪了」,这套机制不能强加给老代码,否则一行不改就可能行为改变。

所以 React 的做法是:换一个入口,按 root 启用。

// react-dom/client
import { createRoot, hydrateRoot } from 'react-dom/client';

/**
 * CSR:客户端渲染。
 * 同一个 root 可以多次 render,等价于「更新根节点」。
 */
const root = createRoot(document.getElementById('root'), {
  // 可恢复错误:React 自己恢复了(如水合不匹配后重建),只上报不打断
  onRecoverableError(error, errorInfo) {
    report(error, errorInfo.componentStack);
  },
});
root.render(<App />);

// 需要销毁时(例如微前端卸载子应用)
// root.unmount();
/**
 * SSR:水合已有 HTML。
 * 注意:水合要求「服务端与客户端首帧一致」,否则会退化成整树重建。
 */
hydrateRoot(document.getElementById('root'), <App />, {
  onRecoverableError: report,
});

两代 root 的差异:

维度ReactDOM.render(legacy)createRoot(concurrent)
渲染是否可中断否,同步一口气算完是,按 Fiber 单元让出主线程
自动批处理只在 React 事件里所有来源(见 2.7.2)
Transition / useDeferredValue无效(会退化为同步)生效
Suspense 与流式 SSR受限完整支持
卸载 APIunmountComponentAtNoderoot.unmount()

三个容易忽略的细节:

  • root.render 可以重复调用:它表示「把这个 root 的内容换成新的」,不是「再挂一个应用」。重复 createRoot 同一个容器会得到两个 root,导致事件与状态错乱(控制台会警告)。
  • 容器不能为 null:document.getElementById('root') 在脚本早于 DOM 时会返回 null,表现为「应用白屏但无报错」。
  • unmount() 之后再 render 无效:root 已经销毁,必须重新 createRoot。

为什么不能「平滑自动升级」:如果把 ReactDOM.render 直接换成并发实现,那些依赖「同步渲染」的老代码(比如在 setState 后立刻读 DOM 尺寸)会静默出错。用新入口等于让开发者显式签字:「我知道行为变了」。

2.7.2 自动批处理:合并的是「同一个 tick 里的更新」

先理解「批处理」:一次 setState 只是往队列里塞一个更新。如果每次塞完都立刻 render,三次 setState 就要 render 三次——既浪费,又会让用户看到「中间态」。

16/17 的规则:只有 React 自己管理的事件回调里才会合并。一旦出了这个范围(setTimeout、Promise、原生 addEventListener),每次 setState 各触发一次渲染。

// 16/17:会 render 三次(在 Promise 回调里)
async function load() {
  const data = await fetch('/api').then((r) => r.json());
  setLoading(false);   // render
  setData(data);       // render
  setError(null);      // render
}

18 的规则:同一个 tick(同一个微任务批次)内的所有更新都合并,不管来自哪里。

// 18:只会 render 一次
async function load() {
  const data = await fetch('/api').then((r) => r.json());

  // 这三次在同一个微任务里,合并成一次提交
  setLoading(false);
  setData(data);
  setError(null);
}

实现思路:setState 不再立刻调度渲染,而是把一个「flush 任务」放进微任务队列;同一 tick 里后续的 setState 只是往队列里加更新,微任务真正执行时才一次性 render。

批处理的边界(面试最爱问):

场景是否合并
同一个事件回调里的多次 setState合并
同一个 await 之后、同步段内的多次 setState合并
跨 await(两个不同的 tick)不合并
setTimeout 回调里的多次合并
原生事件监听里的多次合并
flushSync 内部不合并(强制同步刷)
// 跨 await:两次 setState 之间隔了一个 tick,会 render 两次
async function load() {
  setLoading(true);            // tick A
  const data = await fetch();  // ← 这里让出
  setData(data);               // tick B(新的一次)
}

需要强制同步时用 flushSync:

import { flushSync } from 'react-dom';

function openAndFocus() {
  // 如果这里依赖「DOM 已经更新」才能测量 / 聚焦
  flushSync(() => setOpen(true));
  ref.current.focus(); // 此时 DOM 已更新
}

为什么它很危险:flushSync 会把更新降级为同步不可中断,等于在并发模型上开了一个洞。用在事件回调里尚可,用在 render / 生命周期里会报警告并破坏批处理。绝大多数场景应该用 useEffect / useLayoutEffect 代替。

收益:减少重复 render,避免「先显示 loading 又立刻消失」的闪烁,也减少了「中间态」被观测到的机会。

代价:所有依赖「一次 setState = 一次渲染」的代码会失效。典型受害者是埋点——原来靠「渲染次数」统计曝光,现在合并后数字变少,应该改成「事件次数」上报。

2.7.3 Transition:把「这次更新不紧急」标出来

要解决的问题:搜索框里输入一个字符,会同时触发两件事——更新输入框的值(必须立刻响应)和重新过滤一个大列表(可能要 100ms)。两者同优先级时,输入会被大列表的计算卡住,表现为「打字掉帧」。

Transition 的做法:把「重新过滤」标记为可中断、可丢弃的低优先级更新。React 会先算完输入框,把控制权交还浏览器,等空闲时再算列表;如果期间用户又输入了,旧的那次列表计算直接丢弃。

两种写法,选哪个:

// 写法 1:startTransition(不需要 pending 状态时)
import { startTransition } from 'react';

function onChange(e) {
  const next = e.target.value;
  setQuery(next);                        // 紧急:输入必须即时
  startTransition(() => {
    setList(filterBy(next));             // 过渡:可以慢、可以被打断
  });
}
// 写法 2:useTransition(需要显示「正在计算」时)
import { useTransition } from 'react';

/**
 * 搜索框:输入走紧急更新,结果列表走过渡更新。
 * @returns {JSX.Element}
 */
function Search() {
  const [query, setQuery] = useState('');
  const [list, setList] = useState([]);
  const [isPending, startTransition] = useTransition();

  function onChange(e) {
    const next = e.target.value;
    setQuery(next);

    startTransition(() => {
      setList(filterBy(next));
    });
  }

  return (
    <>
      <input value={query} onChange={onChange} />
      {/* 过渡尚未完成时,列表可能还是旧数据,用透明度提示「正在追」 */}
      <ul style={{ opacity: isPending ? 0.6 : 1 }}>
        {list.map((item) => <li key={item.id}>{item.name}</li>)}
      </ul>
    </>
  );
}

isPending 的语义:表示「至少有一次过渡更新还没提交」,不是「正在发请求」。如果过渡里包含 await,19 起 isPending 会自动覆盖整个异步周期(18 只覆盖同步部分)。

什么绝对不能放进 Transition:

  • 受控输入框的值:值一旦被降级,输入会「回弹」或光标跳动。
  • 需要立刻读 DOM 的操作:过渡更新不保证在下一行代码前完成。
  • 错误提示、Toast:用户需要立刻看到反馈。

Transition 与 useDeferredValue 的区别:

维度startTransition / useTransitionuseDeferredValue
控制对象控制「这次 setState 的优先级」控制「某个值慢半拍」
是否改变更新来源是,你主动包裹 setState否,更新照旧,只是渲染时用旧值
适合场景你知道哪次更新可以慢你拿不到 setState(比如值来自 props)
典型用法列表过滤、Tab 切换传给昂贵子组件的搜索词
// useDeferredValue:不改变 query 的更新,只让昂贵子组件用「旧值」渲染
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery; // 提示「还在追」
<ExpensiveList query={deferredQuery} />

代价与陷阱:

  • 总耗时不变:Transition 优化的是「关键路径」,不是总计算量。组件本身慢且无法拆分时,它只能缓解,不能根治(要配合虚拟化)。
  • 可能被饿死:如果高优先级更新持续到来(比如无限滚动 + 每次滚动都 Transition),低优先级任务可能一直排不上。React 有过期与重试机制兜底,但业务上要限流。
  • 不要在 Transition 里再触发高优先级更新:会形成「降级又被提权」的抖动。

2.7.4 三个基础 Hook:useId、useSyncExternalStore、useInsertionEffect

这三个 Hook 不面向业务,而是给「库作者」补齐并发时代的三块短板。

useId:给 SSR 一个稳定且唯一的 ID

为什么不能自己生成:Math.random() 与自增计数器在服务端和客户端会产生不同的值,水合时 id 对不上,直接报 mismatch。

/**
 * 生成跨服务端/客户端一致的唯一 ID。
 * 规则:只能在组件顶层调用;同一组件内多次调用会得到带后缀的不同 ID。
 */
function Field({ label }) {
  const id = useId();
  return (
    <>
      <label htmlFor={id}>{label}</label>
      <input id={id} aria-describedby={`${id}-hint`} />
      <p id={`${id}-hint`}>必填</p>
    </>
  );
}

三条纪律:

  • 不要用于列表的 key:useId 的稳定前提是「同一组件在同一位置渲染」,列表项会被增删移动。
  • 不要在循环里调用:违反 Hooks 规则。
  • ID 里含 :(如 :r1:):在 querySelector 里要转义,需要拼选择器时用 CSS.escape。

useSyncExternalStore:安全订阅外部 store,避免「撕裂」

「撕裂」(tearing)是并发渲染带来的新问题:同一次提交里,组件 A 读到了 store 的新值,组件 B 因为渲染被中断、恢复后读到了另一个值——同一帧出现两个版本的数据。

import { useSyncExternalStore } from 'react';

/**
 * 订阅一个最小 store(Redux / Zustand / 浏览器 API 都适用)。
 * @param {(cb: () => void) => () => void} subscribe 注册监听,返回取消函数
 * @param {() => T} getSnapshot 读取当前值;必须返回缓存过的引用
 * @param {() => T} getServerSnapshot 服务端渲染时的初始值(可选)
 */
function useOnline() {
  return useSyncExternalStore(
    (cb) => {
      window.addEventListener('online', cb);
      window.addEventListener('offline', cb);
      return () => {
        window.removeEventListener('online', cb);
        window.removeEventListener('offline', cb);
      };
    },
    () => navigator.onLine,
    () => true // 服务端默认按「在线」渲染,避免水合不一致
  );
}

最容易踩的坑:getSnapshot 每次返回新对象会导致无限重渲染。

// 错误:每次调用都新建对象 → React 认为「值一直在变」→ 无限循环
useSyncExternalStore(subscribe, () => ({ ...store.getState() }));

// 正确:返回 store 里已有的引用,或缓存结果
useSyncExternalStore(subscribe, store.getState);

useInsertionEffect:在布局 effect 之前插入样式

时机(在 commit 阶段内,比 useLayoutEffect 更早):

render → commit(useInsertionEffect → 布局与 DOM 变更生效 → useLayoutEffect)→ 绘制 → useEffect

它是给 CSS-in-JS 库用的:如果样式等到 useLayoutEffect 才注入,那么同一次 commit 里做的布局测量会基于「旧样式」,导致闪烁或尺寸算错。提前到 useInsertionEffect,就能保证「样式先就位,再测量」。

/**
 * 仅用于样式库注入 <style>;不要在这里做业务逻辑。
 * 限制:不能读 DOM 布局、不能 setState、不能订阅。
 */
useInsertionEffect(() => {
  const style = document.createElement('style');
  style.textContent = `.btn { color: ${color} }`;
  document.head.appendChild(style);
  return () => style.remove();
}, [color]);

业务代码基本用不到它;如果你在业务里想用它,通常说明你在手写一个样式库,或者把 useLayoutEffect 用错了地方。

2.7.5 流式 SSR:先发壳,慢内容后补

16 的流式为什么不够:renderToNodeStream 只是「边渲染边写字节」,遇到慢数据整棵树都得等,Suspense 边界也不参与——它省的是传输,不是等待。

18 的流式是「按边界交付」,一次请求分三段:

① Shell(壳):Suspense 边界之外的部分,先渲染并立刻发送
     → 用户马上看到布局与骨架
② 补流:某个边界的数据就绪后,把 HTML 片段 + 一段内联脚本一起发下去
     → 脚本把 fallback 替换成真内容
③ 完成:所有边界就绪,关闭流
// server.js
import { renderToPipeableStream } from 'react-dom/server';

/**
 * @param {import('http').ServerResponse} res
 */
function render(res) {
  const { pipe, abort } = renderToPipeableStream(<App />, {
    // 客户端水合所需的脚本
    bootstrapScripts: ['/static/main.js'],

    // 壳(边界之外)渲染完成:此时可以开始发送,状态码也定下来了
    onShellReady() {
      res.statusCode = 200;
      res.setHeader('Content-Type', 'text/html');
      pipe(res);
    },

    // 壳本身出错:还没发送任何内容,可以换成 500 页面
    onShellError(error) {
      res.statusCode = 500;
      res.end('<!doctype html><p>服务暂时不可用</p>');
    },

    // 边界内部出错:已经发送了部分内容,只能降级这个边界
    onError(error) {
      console.error(error);
    },

    // 所有边界都完成(适合爬虫 / 需要完整 HTML 的场景,但会牺牲 TTFB)
    // onAllReady() { pipe(res); },
  });

  // 兜底:超时后放弃未完成的边界,避免连接一直挂着
  setTimeout(abort, 10000);
}

客户端要配合:

// 客户端必须用 hydrateRoot,且第一帧结构要与服务端一致
hydrateRoot(document.getElementById('root'), <App />);

两个「就绪」回调的区别(选错会毁掉流式的意义):

回调时机适合
onShellReady边界之外渲染完面向用户的页面(TTFB 最优)
onAllReady所有边界都完成爬虫、静态生成、需要完整 HTML 的场景

流式带来的新坑:

  • 壳里不能有会挂起的组件:如果最外层就 await,onShellReady 永远不触发,退化成一个更慢的同步渲染。
  • 状态码只能定一次:pipe 开始后再出错,只能改内容,不能改状态码——所以数据校验要尽量放在壳之前。
  • abort 要设超时:数据源挂了而你没 abort,连接会一直挂着。
  • 水合仍要求一致:流式只改变「HTML 怎么到」,不改变「客户端首帧必须匹配」这条铁律。

2.7.6 常见坑

  • createRoot 之后 ReactDOM.unmountComponentAtNode 不可用,要用 root.unmount();忘了卸载会在微前端切换时泄漏。
  • 同一个容器重复 createRoot 会得到两个 root,事件与状态互相干扰;入口要收敛到一个地方。
  • StrictMode 在开发环境会双调用渲染与 effect,请求发两次是刻意行为,不是 bug;用 AbortController / 幂等接口解决,不要关 StrictMode。
  • 受控输入的值放进 Transition:输入会回弹、光标跳动。值必须走紧急更新,只有派生结果走 Transition。
  • 以为「批处理跨 await 合并」:每个 await 之后是新的 tick,会各自 render。
  • flushSync 用多了等于放弃并发:它把更新降级为同步不可中断,只在「必须立刻读 DOM」时用。
  • 在 Transition 里做「必须立刻看到」的反馈(错误提示、按钮禁用态),用户会觉得点了没反应;用 isPending 做视觉提示。
  • useSyncExternalStore 的 getSnapshot 返回新对象 → 无限重渲染;返回 store.getState 这样的稳定引用。

2.8 React 19 详解:Actions、use、RSC 与「删除清单」

2.8.1 Actions:把异步提交变成状态机

19 之前,一次表单提交要自己维护四件事:pending、error、乐观值、成功后重置。

// 19 之前:手写状态机
const [pending, setPending] = useState(false);
const [error, setError] = useState(null);

async function onSubmit(e) {
  e.preventDefault();
  setPending(true);
  const err = await updateName(name);
  setPending(false);
  if (err) setError(err);
}

19 之后:

import { useActionState } from 'react';

const [error, submitAction, isPending] = useActionState(
  async (prevState, formData) => {
    const err = await updateName(formData.get('name'));
    return err ?? null;
  },
  null
);

return (
  <form action={submitAction}>
    <input name="name" />
    <button disabled={isPending}>保存</button>
    {error && <p role="alert">{error}</p>}
  </form>
);

<form action> 成功后会自动重置非受控表单;需要手动重置时用 requestFormReset。

配套的两个 Hook:

// 读取最近父级 <form> 的提交状态,适合设计系统里的按钮
import { useFormStatus } from 'react-dom';

function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>提交</button>;
}
// 乐观更新:先显示结果,失败自动回滚
const [optimisticName, setOptimisticName] = useOptimistic(currentName);

async function action(formData) {
  setOptimisticName(formData.get('name'));
  const updated = await updateName(formData.get('name'));
  onUpdate(updated);
}

2.8.2 use:渲染期读取资源

一句话理解:use 不是 Hook,而是一把「读取器」——它读 Promise 时会「挂起」(suspend),读 Context 时和 useContext 一样,但它可以写在条件分支里。

先分清它和 Hook 的关系:Hook 之所以不能条件调用,是因为 React 靠「调用顺序」匹配内部状态(见 2.4.3)。use 不占状态槽位,它只是「读一个已经存在的东西」,所以不受顺序约束。

use 接受两种资源:

资源行为备注
Promise未完成时抛给最近的 Suspense 边界;完成后返回结果Promise 必须「可缓存、稳定」
Context等价于 useContext可以条件调用
import { use, Suspense } from 'react';

/**
 * 读取评论列表。
 * @param {{ commentsPromise: Promise<Array<{ id: string, text: string }>> }} props
 */
function Comments({ commentsPromise }) {
  // 未完成时会挂起这棵子树,由外层 Suspense 显示骨架
  const comments = use(commentsPromise);
  return comments.map((c) => <p key={c.id}>{c.text}</p>);
}

/**
 * 关键:Promise 在父组件(或模块作用域)创建,而不是在 Comments 里创建。
 * 这样同一次渲染里 Promise 是稳定的,不会「每次渲染都挂起」。
 */
function Page() {
  const commentsPromise = useMemo(() => fetchComments(), []);

  return (
    <Suspense fallback={<Skeleton />}>
      <Comments commentsPromise={commentsPromise} />
    </Suspense>
  );
}

最重要的一条规则:use 不能读「本次 render 里新建的 Promise」。

// 错误:每次渲染都新建 Promise → 每次渲染都挂起 → 永远出不来
function Bad() {
  const data = use(fetch('/api').then((r) => r.json()));
}

// 正确:Promise 在组件外创建(框架的 loader / cache)
function Good({ dataPromise }) {
  const data = use(dataPromise);
}

原因是:use 依赖「同一个 Promise 引用」,React 才能判断「上次挂起的那个是否已经完成」。每次新建就是一个全新的、永远未完成的 Promise。

与 useContext 的对比:

维度useContextuse(Context)
能否条件调用不能能
能否读 Promise不能能
是否触发 Suspense否读 Promise 时会
本质Hook(占状态槽位)读取器(不占槽)

工程上必须注意:

  • 别用 try/catch 包 use:挂起是通过「抛出一个特殊对象」实现的,你的 catch 会把它吞掉,表现为「组件不渲染也没报错」。
  • 错误要用 Error Boundary 接:Promise reject 会交给最近的错误边界,而不是 use 的返回值。
  • 客户端数据获取仍要靠框架:裸 use(fetch(...)) 没有去重与缓存,容易出现「同一请求发多次」。Next.js / React Router 的 loader 会帮你缓存。
  • 服务端组件里用 await 更直接:RSC 支持 async 组件,不必绕 use;use 主要用在「拿到的是 Promise 引用」的客户端组件里。
// RSC 里直接 await,语义更清晰
export default async function Page() {
  const post = await getPost();
  return <h1>{post.title}</h1>;
}

2.8.3 RSC 与 Server Actions

一句话理解:RSC 把「组件」的默认执行地点从浏览器搬到了服务端——不是「先渲染成 HTML 再水合」,而是在服务端把组件跑完,只把结果(一棵可序列化的树)和必要的客户端代码发给浏览器。

它解决三个具体问题:

  1. JS 体积:只有需要交互的组件('use client')才下发代码。一个「读数据库 → 展示列表」的页面可以做到 0 业务 JS。
  2. 数据靠近源头:服务端组件可以直接 await db.query(),不用先暴露一个 API 再在客户端请求,少一次网络往返。
  3. 消除请求瀑布:父组件拿到数据后再渲染子组件,天然是「服务端串行、客户端不等待」。
// app/page.jsx —— 服务端组件:默认在服务端执行,不发 JS
import { db } from './db';
import LikeButton from './LikeButton';

export default async function Page() {
  const post = await db.post.findFirst();
  return (
    <article>
      <h1>{post.title}</h1>
      <LikeButton id={post.id} initialLikes={post.likes} />
    </article>
  );
}
// app/LikeButton.jsx —— 客户端组件:需要交互,必须标注边界
'use client';

import { useOptimistic } from 'react';
import { likePost } from './actions';

export default function LikeButton({ id, initialLikes }) {
  const [likes, addLike] = useOptimistic(initialLikes, (n) => n + 1);
  return (
    <form action={async () => { addLike(); await likePost(id); }}>
      <button>赞 {likes}</button>
    </form>
  );
}
// app/actions.js —— Server Action:'use server' 标记的是「动作」,不是「组件」
'use server';

import { revalidatePath } from 'next/cache';

export async function likePost(id) {
  await db.post.update({ where: { id }, data: { likes: { increment: 1 } } });
  revalidatePath('/');
}

三个必须分清的概念:

概念指令运行位置产出
Server Component无指令(默认)服务端 / 构建期RSC payload(可序列化的树)
Client Component'use client'浏览器(也会 SSR)HTML + JS
Server Action'use server'服务端可被客户端调用的函数引用

① 一次请求的完整流水线

浏览器请求 /post/1
  → 服务端执行 Server Components(可以 await 数据库)
  → 产出 RSC payload(这棵树里「客户端组件」只留下占位与 props)
  → SSR 把 payload 渲染成 HTML 流,发给浏览器
  → 浏览器先看到内容(还没交互)
  → 下载客户端组件对应的 JS
  → hydrateRoot 把事件与状态挂上去(此时才可交互)

所以 RSC 和 SSR 不是替代关系,而是流水线上的两站:RSC 决定「树长什么样、哪些代码要下发」,SSR 决定「HTML 怎么最快到达」,Hydration 决定「什么时候能点」。

② payload 里到底装了什么

它不是 HTML,而是一份「可序列化的元素描述」,大致长这样:

// RSC payload 的概念化示意(真实格式是分块的文本流)
{
  type: 'article',
  props: {
    children: [
      { type: 'h1', props: { children: '标题' } },
      // 客户端组件只留一个引用 + props,代码在浏览器里再取
      { type: 'client', id: './LikeButton.js#default', props: { id: 1, initialLikes: 3 } },
    ],
  },
}

③ 'use client' 是模块边界,不是组件边界

这是最容易搞错的一点。指令写在文件顶部,标了之后:

  • 这个模块里导出的所有组件都变成客户端组件;
  • 这个模块导入的模块也进入客户端包(除非它们自己也标了服务端边界)。
// 错误:在页面顶层加 'use client',整棵树(含它 import 的所有东西)都下沉到客户端
'use client';
import { HeavyChart } from './HeavyChart';
export default function Page() { /* ... */ }

// 正确:边界尽量下沉到叶子交互组件
// Page.jsx(服务端)
import { LikeButton } from './LikeButton'; // 只有这个文件标了 'use client'

④ children 技巧:把服务端内容「运输」进客户端组件

客户端组件不能 import 服务端组件(那会把服务端代码拉进客户端包),但可以接收它作为 children:

// ClientShell.jsx
'use client';
/** 客户端外壳:只负责交互(折叠、tab),内容由外面传进来。 */
export function Collapsible({ children }) {
  const [open, setOpen] = useState(true);
  return (
    <section>
      <button onClick={() => setOpen((v) => !v)}>{open ? '收起' : '展开'}</button>
      {open && children}
    </section>
  );
}
// Page.jsx(服务端):children 在服务端渲染,客户端只拿到 payload
import { Collapsible } from './ClientShell';

export default async function Page() {
  const rows = await db.query();
  return (
    <Collapsible>
      <ul>{rows.map((r) => <li key={r.id}>{r.name}</li>)}</ul>
    </Collapsible>
  );
}

这样 Collapsible 是客户端组件,而 ul 及其数据完全留在服务端——交互在客户端、数据在服务端,两边都不浪费。

⑤ props 的序列化规则

RSC payload 要跨网络传输,props 必须可序列化:

可以传不能传
字符串、数字、布尔、null、undefined普通函数(箭头函数、方法)
数组、纯对象、Date、Map、Set、BigInt、Promise类实例、DOM 节点、Symbol
React 元素(作为 children)循环引用(部分实现不支持)
Server Action 的引用客户端函数(不能从服务端往下传)

⑥ 常见坑

  • 在 Server Component 里用 useState / useEffect:没有客户端运行时,直接报错。需要状态就把那一小块拆成客户端组件。
  • 把 'use client' 写在页面顶层:整页下沉,RSC 的收益基本归零,JS 体积反而更大。
  • 在 RSC 里读 window / localStorage:服务端没有这些全局对象。
  • 用 Date.now() / Math.random() 渲染:服务端和客户端值不同,直接水合不匹配。要放到 useEffect 里,或由服务端算好作为 props 传入。
  • 在客户端组件里 import 服务端专属模块(数据库、密钥):会打包失败或泄露密钥;用 server-only 包加一层保险。
  • 以为 Server Action 是「服务端组件」:'use server' 标记的是「可被客户端调用的函数」,不是组件。

2.8.4 其他重要变化:六个「小而实用」的改动

① ref 作为普通 prop:forwardRef 不再是必需品

以前函数组件不能直接收 ref:ref 是 React 的特殊字段,不会出现在 props 里。所以要把 ref 透传给内部 DOM,必须用 forwardRef 包一层。

// 18 及以前
const MyInput = React.forwardRef(function MyInput(props, ref) {
  return <input {...props} ref={ref} />;
});

// 19:ref 就是普通 prop,可以直接解构
/**
 * @param {{ ref?: React.Ref<HTMLInputElement> } & React.InputHTMLAttributes<HTMLInputElement>} props
 */
function MyInput({ ref, ...props }) {
  return <input ref={ref} {...props} />;
}

三个要点:

  • forwardRef 仍然可用,不会报错,所以可以渐进迁移;新代码不必再写。
  • HOC / 组件库要检查:有些库把 ref 从 props 里手动剥离,19 下会丢掉这个 prop。
  • 类型写法变了:以前是 forwardRef<HTMLInputElement, Props>,现在是 props 上带一个 ref?: React.Ref<T>。

② ref 回调支持返回清理函数

/**
 * 返回的函数会在「ref 变化或组件卸载」时执行。
 * 一旦返回了 cleanup,React 就不再额外用 null 调用一次 ref。
 */
<input
  ref={(node) => {
    const observer = new ResizeObserver(() => measure(node));
    observer.observe(node);
    return () => observer.disconnect();
  }}
/>

对比旧写法(需要自己判断 node === null):

// 18 及以前:挂载与卸载挤在同一个回调里,容易漏分支
ref={(node) => {
  if (node) {
    observer.observe(node);
  } else {
    observer.disconnect();
  }
}}

两个坑:

  • 箭头函数隐式返回:ref={(node) => observer.observe(node)} 会返回 undefined(其实 observe 返回 void),但如果你写成 ref={(node) => (node.foo = 1)} 就会返回数字,TypeScript 会报「ref 回调不能返回值」。改成块级函数体 { node => { ... } }。
  • 不要写成 async:async 函数返回 Promise,会被当成清理函数,卸载时报错。

③ <Context> 直接当 Provider

// 19:少一层嵌套
<ThemeContext value="dark">
  <Toolbar />
</ThemeContext>

// 旧写法仍然有效
<ThemeContext.Provider value="dark">
  <Toolbar />
</ThemeContext.Provider>

注意:这是19 的语法。在 18 下写 <ThemeContext value="dark"> 会被当成「渲染一个 Context 对象」,直接报错,所以降级时不能混用。

④ 文档元数据:<title> / <meta> / <link> 可以写在组件里

/**
 * 组件级的文档元数据:React 会自动提升到 <head>,
 * 流式 SSR 下会尽量在内容之前插入,避免「先出内容后改标题」。
 */
function BlogPost({ post }) {
  return (
    <article>
      <title>{post.title}</title>
      <meta name="author" content={post.author} />
      <link rel="canonical" href={post.url} />
      <h1>{post.title}</h1>
    </article>
  );
}

规则与限制:

  • 只支持特定标签:<title>、<meta>、<link>;其它标签不会被提升(<script> 用 preinit)。
  • 自动去重:同样的 <title> 渲染多次,React 只保留一个;<meta> / <link> 按属性去重(同 name/link 的后者覆盖前者)。
  • 深层组件也能生效:不必把标题一路 props 传到页面顶部,组件自己声明即可。
  • 和框架的 metadata API 二选一:Next.js 有自己的 export const metadata,混用容易出现「两套系统各写一次」。

⑤ 资源提示:preload / preinit / prefetchDNS / preconnect

它们把「浏览器资源提示」变成了 API,避免手写 <link> 字符串、也避免提升到 <head> 时漏掉。

import { preload, preinit, prefetchDNS, preconnect } from 'react-dom';

// 提前下载「马上就要用、但发现得晚」的资源(字体、大图、样式)
preload('/fonts/inter.woff2', {
  as: 'font',
  type: 'font/woff2',
  crossOrigin: 'anonymous', // 必须与真实请求一致,否则会下载两次
});

// 提前加载并立即执行(脚本)或插入(样式)
preinit('/analytics.js', { as: 'script' });
preinit('/theme.css', { as: 'style' });

// 只做 DNS 解析(成本最低,适合「可能会连」的第三方域)
prefetchDNS('https://cdn.example.com');

// DNS + TCP + TLS 握手(适合「一定会连」的 API 域)
preconnect('https://api.example.com');
API浏览器行为什么时候用代价
prefetchDNS只解析域名可能访问的第三方域极低
preconnect建立连接(含 TLS)确定会请求的 API / CDN占用一个连接,闲置会被回收
preload以高优先级下载,但不执行字体、首屏大图、关键 CSS下载了不用 = 浪费带宽
preinit下载并执行 / 插入必须尽早生效的脚本、样式会阻塞,滥用拖慢首屏

⑥ 样式表 precedence:让样式先于内容到达

// precedence 决定插入顺序;数字/字符串越大越靠后
<link rel="stylesheet" href="/base.css" precedence="default" />
<link rel="stylesheet" href="/theme.css" precedence="high" />

它解决的是流式 SSR 下的一个具体问题:组件级样式通常在渲染到该组件时才「被发现」,如果直接发送,浏览器可能先画出没样式的 HTML(FOUC)。加上 precedence 后,React 会:

  • 按优先级排序插入,保证「基础样式在前、覆盖样式在后」;
  • 阻塞该 Suspense 边界的揭示,等这段样式加载完成再显示内容。

坑:precedence 只解决「顺序与等待」,不解决「关键 CSS 太大」;首屏关键样式仍然应该内联。

2.8.5 错误处理与静态 API

三层错误,三种归宿:

错误类型例子谁处理
被边界捕获子组件 render 抛错,被 Error Boundary 接住onCaughtError
未被捕获根组件抛错、边界自身抛错onUncaughtError
可恢复水合不匹配、React 自动重建后恢复onRecoverableError
/**
 * root 级错误回调:比 window.onerror 更早、带组件栈信息。
 * @param {Element} container
 */
createRoot(container, {
  onCaughtError(error, errorInfo) {
    // 已经被 Error Boundary 降级,用户能看到兜底 UI
    report(error, { componentStack: errorInfo.componentStack });
  },
  onUncaughtError(error, errorInfo) {
    // 没有边界接住,整棵树可能已经崩溃
    reportFatal(error, { componentStack: errorInfo.componentStack });
  },
  onRecoverableError(error, errorInfo) {
    // React 自己恢复了(例如水合不匹配后重建),但这次「重建」本身很贵
    report(error, { componentStack: errorInfo.componentStack });
  },
});

为什么不用 window.onerror:它拿不到组件栈,也无法区分「已降级」和「整树崩溃」。三个回调让你能分别设置告警级别——onRecoverableError 该报警(说明有水合问题),但不该触发 P0。

react-dom/static:等待数据完成后再输出完整 HTML

import { prerenderToNodeStream } from 'react-dom/static';

/**
 * 与流式 SSR 的区别:会等所有 Suspense 边界就绪,
 * 再一次性输出「完整、可静态托管」的 HTML。
 */
const { prelude } = await prerenderToNodeStream(<App />);
prelude.pipe(res);
API输出时机适合
renderToString(旧)同步、不支持 Suspense 等待简单页面、老代码
renderToPipeableStream壳好了就发,边界后补面向用户的动态页面(TTFB 优先)
prerender / prerenderToNodeStream全部就绪后一次性输出SSG、CDN 缓存、爬虫(完整性优先)

这五个 SSR API 的来历、内部流程、参数与选型,见附录 10.1–10.3。

坑:prerender 会等到所有边界完成,一旦某个数据源很慢,构建时间(或响应时间)会被它拖住。静态生成时建议给每个数据请求设超时。


2.9 React 19 的移除清单(迁移必读)

19 被叫作「清理版」:一批在 16–17 时代就被标记为「不安全」的 API 在这一版正式删除。原因不是「代码太旧想清理」,而是它们在并发模型下无法正确实现:

  • findDOMNode 返回的 DOM 可能在渲染被中断后已经失效,React 无法保证它的有效性;
  • 字符串 ref 依赖一个全局命名空间,在「渲染可重放」的世界里无法保证指向;
  • defaultProps 在函数组件上没有「实例」可挂,语义本身就不成立。
被移除 / 忽略替代方案备注
ReactDOM.rendercreateRoot().render()18 起弃用,19 移除
ReactDOM.hydratehydrateRoot()同上
unmountComponentAtNoderoot.unmount()同上
findDOMNoderef破坏 StrictMode 与并发
字符串 ref(ref="x")createRef / 回调 ref16.3 起不推荐
React.createFactoryJSX基本无人使用
函数组件 defaultProps默认参数类组件仍可用
propTypesTypeScript / 运行时校验函数组件上被忽略
legacy context(contextTypes)createContext19 移除
react-dom/test-utils 的 actReact.act测试代码需改 import
UMD 构建ESM / bundlerCDN 场景需换包
react-test-renderer/shallowTesting Library官方已不推荐

迁移判断法:先在代码里搜这张表的左列;命中越多,升级成本越高。大部分项目 80% 的改动集中在前 4 行。

2.9.1 四个高频改动的「改前 / 改后」

① root API 全家桶

// 改前(16/17/18 的早期写法)
import ReactDOM from 'react-dom';
ReactDOM.render(<App />, container);
ReactDOM.hydrate(<App />, container);
ReactDOM.unmountComponentAtNode(container);

// 改后
import { createRoot, hydrateRoot } from 'react-dom/client';
const root = createRoot(container);
root.render(<App />);
root.unmount();

const ssrRoot = hydrateRoot(container, <App />);

注意:不要把 createRoot 写在渲染函数或事件回调里,否则每次都会新建一个 root。入口只创建一次。

② findDOMNode → ref

// 改前:拿「组件实例」再取 DOM,HOC 包裹后会返回意外的节点
const node = ReactDOM.findDOMNode(this);

// 改后:把 ref 显式传到你真正想控制的节点上
function Input(props) {
  const ref = useRef(null);
  useEffect(() => {
    ref.current.focus();
  }, []);
  return <input ref={ref} {...props} />;
}

如果是为了「父组件控制子组件行为」,用 useImperativeHandle 暴露一个最小接口,而不是把整个 DOM 交出去。

③ 字符串 ref → 回调 ref / createRef

// 改前:靠全局名字找节点,重名与复用都会出错
<input ref="nameInput" />;
this.refs.nameInput.focus();

// 改后(类组件)
this.nameInput = React.createRef();
<input ref={this.nameInput} />;
this.nameInput.current.focus();

// 改后(函数组件)
const nameInput = useRef(null);
<input ref={nameInput} />;
nameInput.current.focus();

④ 函数组件 defaultProps → 默认参数

// 改前:19 起函数组件上的 defaultProps 被忽略,props 直接是 undefined
function Button({ size }) { /* size 可能是 undefined */ }
Button.defaultProps = { size: 'md' };

// 改后:解构默认值
function Button({ size = 'md' }) { /* ... */ }

类组件的 defaultProps 仍然有效;propTypes 在函数组件上也被忽略,运行时校验请交给 TypeScript 或 zod 之类的库。

2.9.2 一条命令 + 一份自查脚本

# 官方 codemod:按升级指南批量替换已知模式
npx codemod@latest react/19/migration-recipe
# 自查:命中越多,升级成本越高
rg -n "ReactDOM\.(render|hydrate|unmountComponentAtNode|findDOMNode)" src
rg -n 'ref=("|\{?["\x27])|this\.refs\.' src
rg -n "defaultProps|propTypes|contextTypes|getChildContext|createFactory" src
rg -n "react-dom/test-utils|react-test-renderer/shallow" src test

把输出整理成「文件 → 责任人 → 替换方式 → 验证用例」四列,就是一份可执行的迁移清单。

2.9.3 常见坑

  • codemod 只覆盖「模式明确的替换」,findDOMNode 这类需要理解意图的改动必须人工。
  • 删掉 defaultProps 后忘了给默认值,undefined 会一路传到样式或请求参数里,表现为「线上某个字段凭空消失」。
  • act 改了 import 但没改调用方式:React.act 同样是 async 的,测试里仍要 await。
  • UMD 构建被移除:通过 <script src="...react.production.min.js"> 直接引用的老页面必须换成 ESM + bundler,或者锁在 18。
  • 用了 react-test-renderer/shallow 的快照测试在 19 下不可用,要迁移到 Testing Library(渲染真实 DOM)。

2.10 L2 小结与自检

主题关键结论
Fiber提供可中断结构,不等于并发可用
Context新 Context 用订阅绕开 SCU;19 可简写 Provider
Suspense16 只服务 lazy;18 起参与 SSR 与数据;19 配合 use 与 RSC
Hooks按逻辑组织;代价是依赖数组与闭包
17升级垫脚石:新 JSX、事件挂根、无事件池
18createRoot 启用并发;批处理覆盖所有来源;流式 SSR
19Actions 管异步状态;use 条件读取;RSC 稳定;旧 API 移除

自检题

  1. 为什么 createRoot 是 18 的「总开关」?——只有它创建的 root 才启用并发渲染、自动批处理与 Transition;ReactDOM.render 会退回 legacy 模式,行为接近 17。
  2. 19 的 use 和 useContext 有什么本质区别?——use 可以条件调用(early return 之后也能用),且能读取 Promise 并触发 Suspense;useContext 必须无条件调用,只能读 Context。
  3. 为什么说「上了 RSC 不等于不用 SSR」?——RSC 输出的是可序列化的组件树(payload),首屏仍需 SSR 把树渲染成 HTML;两者是流水线上的不同阶段,不是替代关系。

3. L3 高级:机制层

3.1 从 setState 到像素:一次更新的完整旅程

一句话理解:一次 setState 会经过「排队 → 调度 → 计算(可中断)→ 提交(不可中断)→ 绘制 → 副作用」六站。记住每一站的约束,就能解释你遇到过的绝大多数「玄学现象」。

以 18/19 的实现为参照:

setState
  → 创建 Update,入队 updateQueue,标记 lane
  → scheduleUpdateOnFiber
  → ensureRootIsScheduled(按 lane 映射调度优先级)
  → Scheduler.scheduleCallback(MessageChannel,5ms 时间切片)
  → workLoop:performUnitOfWork(beginWork / completeWork)
        ↑ 可中断:shouldYield() 为真就让出主线程
  → commitRoot(不可中断)
        ├─ BeforeMutation
        ├─ Mutation(改 DOM)
        └─ Layout(useLayoutEffect、ref 挂载)
  → 浏览器绘制
  → Passive Effects(useEffect,异步调度)

六个阶段的职责与约束:

阶段在干什么可中断允许副作用对应 API
排队生成 Update 对象,挂到 updateQueue,打上 lane——setState、dispatch
调度按 lane 映射到调度优先级,决定「什么时候算」——Scheduler
Render在 workInProgress 树上构建新树是否组件函数、useMemo
Commit一次性把变更写到 DOM否是(受控)Mutation、useLayoutEffect、ref
绘制浏览器 paint———
Passive跑 useEffect可延后是请求、订阅、日志

lane:优先级的表达方式

lane典型来源优先级
SyncLane离散输入(click、keydown)、flushSync最高,同步执行
InputContinuousLane连续输入(mousemove、scroll、drag)高
DefaultLane普通 setState、首次渲染中
TransitionLanesstartTransition / useDeferredValue低,可中断、可丢弃
IdleLane空闲任务最低
OffscreenLane预渲染、隐藏内容(<Activity>)最低

用一个场景串起来:搜索框输入一个字符,同时触发「更新输入」和「过滤大列表」。

用户输入 "a"
 ├─ onChange 里 setQuery('a')            → SyncLane(最高)
 ├─ startTransition 里 setList(filter()) → TransitionLane(低)
 ├─ React 先算 query:输入框立即更新,浏览器可以绘制
 ├─ 开始算 list:算到一半,用户又输入 "ab"
 │    ├─ 新的 SyncLane 插入 → shouldYield 为真,让出主线程
 │    └─ list 的中间结果作废(可丢弃),query 先提交
 └─ 空闲后重算 list('ab')

这些约束能直接解释的现象:

现象原因
setState 后立刻读 DOM,拿到旧值commit 还没发生(批处理 + 异步调度);要读用 flushSync 或 useLayoutEffect
console.log 在 render 里打两次StrictMode 双调用;并发下 render 本来就可能重放
useEffect 里读 DOM 是最新的Passive 阶段在 commit 之后
useLayoutEffect 里测量不会闪,但会卡它同步执行在绘制之前,属于 commit 的一部分
同一个事件里 setState 调三次,只渲染一次同一 tick 批处理(见 3.3)

为什么 render 必须纯:render 可能被中断、重来、甚至被丢弃。如果你在 render 里发请求、改全局变量、改 props,这些副作用会随着「重放」执行多次,而 commit 只发生一次——数据和 UI 就会对不上。

3.2 「并发」到底并发什么

一句话理解:并发不是「同时算多件事」,而是「把一件长任务切成可暂停的小段,让紧急的事先做,并允许把过期的小段直接扔掉」。

先破除一个误解:JS 仍然是单线程,React 没有开新线程,也没有真正的并行计算。它做的是三件事。

① 可中断:时间切片

浏览器一帧大约 16.6ms,预算大致这样分:

一帧 16.6ms
 ├─ 输入处理      ~2ms
 ├─ React 渲染    ~5ms   ← 到点就让出(shouldYield)
 ├─ 样式 / 布局   ~3ms
 ├─ 绘制          ~4ms
 └─ 余量          ~2ms

如果 React 一次占满整帧,输入事件就只能排队,表现为「打字有延迟」。切片长度是折中:切太长会卡输入,切太短则调度本身的成本占比变高(这也是官方大致取 5ms 的原因)。

关键限制:只有 render 阶段可中断,commit 阶段不可中断。所以「让 commit 变短」比「让 render 变快」更影响交互——优化方向是减少 DOM 变更数量(虚拟化、批量插入、避免一次性挂几千个节点),而不是在 commit 里做计算。

② 可排序:lane 决定谁先算

// 同一次交互里产生两个不同优先级的更新
function onChange(e) {
  const next = e.target.value;
  setQuery(next);                                  // SyncLane:输入,最高
  startTransition(() => setList(filter(next)));    // TransitionLane:列表,最低
}

React 先算 query 并 commit,让输入框立刻响应;列表的渲染在浏览器空闲时继续。期间用户可以继续输入,输入永远优先。

③ 可丢弃:过期结果不浪费

低优先级任务算到一半时,如果新的输入已经让它的结果「不符合当前条件」,React 直接丢掉这次计算结果,从头算新的。省下的是无效计算,而不是总计算量。

并发 vs 并行,别搞混:

维度并发(React 的并发渲染)并行(Web Worker / 多线程)
线程模型单线程,交替执行多线程,同时执行
主要收益响应性(INP、输入不卡)吞吐量(总耗时变短)
实现手段切片、优先级、丢弃分片计算、共享内存 / 消息传递
主要代价调度开销、render 必须纯通信与序列化成本

所以「用了并发,页面会更快」是错的。准确的表述是:总耗时可能不变,但关键路径变短,用户感知更快。

代价一:饥饿

// 反例:滚动时不断产生新的 Transition
<div onScroll={() => startTransition(() => setVisibleRange(compute()))} />

滚动本身是连续输入(高优先级),每次滚动又会触发新的更新。如果低优先级任务一直排不上队,用户会看到「列表永远在追」。缓解手段:节流、限制计算范围、把能提前算的挪到 useMemo 或服务端。

代价二:render 必须纯

因为 render 可能被中断、重放、丢弃,任何「在 render 里改外部状态」的代码都会执行多次。

什么时候并发帮不上忙:

症状并发能做什么真正该做什么
一次 render 就超过一帧(10 万行未虚拟化)切成小段,但每段仍很重虚拟化、分页
慢在 commit(一次插入几千个 DOM)几乎无能为力(commit 不可中断)减少节点、分批插入、content-visibility
慢在等接口无帮助预取、缓存、骨架屏
慢在第三方库的同步计算只能让出,不能加速Web Worker、算法优化

3.3 批处理的边界与 flushSync

一句话理解:批处理不是「攒够一批再渲染」,而是「同一个微任务里,只允许调度一次 flush」。

机制:18 的 setState 不再算完就立刻调度渲染,而是先判断「当前是否已在批量上下文里」:

  • 在 → 只把 Update 入队,等这次 flush 一起处理;
  • 不在 → 打标记,把 flush 任务丢进微任务队列,等当前同步代码跑完再统一 render。
// 概念性伪代码,帮助理解「为什么同一 tick 只渲染一次」
let isBatching = false;

function batchedUpdates(fn) {
  const prev = isBatching;
  isBatching = true;
  try {
    fn(); // 这里面调 N 次 setState,都只是入队
  } finally {
    isBatching = prev;
    if (!isBatching) scheduleFlushInMicrotask(); // 统一渲染一次
  }
}

用微任务而不是宏任务,是为了「尽早 flush、别让用户看到旧值」;代价是跨 await 不会被合并——每个 await 之后已经是新的微任务。

场景16/1718/19原因
React 事件回调批处理批处理事件系统主动包了批量上下文
setTimeout不批处理批处理18 不再区分更新来源
Promise / async 同一段同步代码不批处理批处理同上(微任务内)
原生 addEventListener不批处理批处理同上
flushSync 内部同步同步 + 强制刷新显式要求「马上提交」

三种「看起来该合并却没合并」的情况:

// 1. 跨 await:两个不同的 tick
async function a() {
  setLoading(true);
  await fetchData();   // ← 让出,微任务结束
  setData(x);          // 新的一次渲染
}

// 2. flushSync 内部:强制同步
flushSync(() => setA(1));
setB(2); // 这是另一次渲染

// 3. 两个不同的事件回调
<button onClick={() => setA(1)} />
<button onClick={() => setB(2)} />  // 两次独立交互,当然不合并

flushSync 的正确用法与替代方案:

import { flushSync } from 'react-dom';

/**
 * 只在「必须立刻拿到更新后的 DOM」时使用。
 * 典型场景:打开弹层后立刻聚焦、测量新插入节点的高度。
 */
function openAndFocus() {
  flushSync(() => setOpen(true));
  inputRef.current.focus(); // 此时 DOM 已更新
}
你的需求更好的做法
打开弹层后聚焦autoFocus,或在 useEffect 里聚焦
测量新元素的尺寸useLayoutEffect 里测量
新元素出现后滚动过去useEffect 里 scrollIntoView
第三方库需要「DOM 就绪」ref 回调 / useLayoutEffect

三个坑:

  • 不要在 render / 生命周期里调用:会报警告,并破坏批处理语义。
  • 不要在循环里调用:每次都是一次同步提交,等于把并发优化全部作废。
  • 测试里一般不需要它:act 已经帮你排空了更新队列。

3.4 Render / Commit 与双缓冲

一句话理解:React 先在「草稿树」上算完,再一次性把差异刷到 DOM;用户永远看不到「算到一半」的界面。

current 树(屏幕上正在显示的)
   ↕ alternate(互相指向)
workInProgress 树(正在计算的草稿)

render 阶段:在 workInProgress 上增删改 Fiber
commit 阶段:把 workInProgress 的变更应用到 DOM
             → root.current = workInProgress

为什么必须有两棵树:

  • 可中断:中断时草稿树可以原地保留,下次接着算,屏幕上的 current 不受影响。
  • 不闪烁:只有整棵树算完才提交,不会出现「一半新一半旧」。
  • 可丢弃:低优先级草稿直接扔掉,current 毫发无伤。
  • 可复用:下次更新时两棵树角色互换,节点对象复用,减少分配。

commit 的三个子阶段:

子阶段时机能做什么不能做什么
BeforeMutation改 DOM 之前读 DOM 快照(getSnapshotBeforeUpdate)改 DOM
Mutation改 DOM插入 / 更新 / 删除节点、ref 的 detach触发新的渲染
LayoutDOM 改完、绘制之前useLayoutEffect、ref attach、测量耗时操作(会阻塞绘制)
Passive绘制之后useEffect、请求、订阅假设 DOM 还是当时的样子(可能已再次更新)

一个必须建立的直觉:JSX 不是 DOM。render 阶段只产出「应该长什么样」的描述(Fiber 树),真正的 DOM 操作全部发生在 Mutation 子阶段。所以:

  • 在 render 里读 DOM,读到的是上一次 commit 的结果,而且这次 render 还可能被丢弃;
  • 在 render 里写 DOM,属于「不可重放的副作用」,并发下会执行多次;
  • 在 commit 里做重计算,会直接拖长「不可中断」的阶段,掉帧最明显。

StrictMode 下的双执行顺序(开发环境):

mount:  effect → cleanup → effect

这不是「effect 跑了两次」这么简单:它模拟的是「组件被卸载再挂载」。所以任何「只初始化一次」的假设(比如给全局对象加一次监听)都会被暴露成重复监听——这正是它想抓的问题。

3.5 Suspense 的三种语义

一句话理解:Suspense 表达的是「这棵子树还没准备好」,而不是「正在加载」。它把「加载态」从一堆手写的 loading 变量,提升为一个声明式的边界。

语义触发方式行为引入版本
代码分割React.lazy / 动态 importchunk 未加载完时显示 fallback16.6
数据读取框架 loader / use(promise) / RSC依赖未就绪时挂起,边界内显示 fallback18 / 19
流式 SSRrenderToPipeableStream先发壳,边界内容就绪后追加替换18

语义一:代码分割

const Chart = React.lazy(() => import('./Chart'));

<Suspense fallback={<ChartSkeleton />}>
  <Chart />
</Suspense>

这里的「没准备好」是「代码还没下载完」。它和状态无关,也与数据无关。

语义二:数据读取

function Comments({ commentsPromise }) {
  // Promise 未完成 → 抛给最近边界 → 显示 fallback
  const comments = use(commentsPromise);
  return comments.map((c) => <p key={c.id}>{c.text}</p>);
}

这里的「没准备好」是「数据还没到」。React 不关心你在等什么,只关心「你抛出了一个 Promise」。

语义三:流式 SSR

服务端渲染到某个边界时发现资源未就绪,就先把这个边界输出成 fallback,并在稍后用内联脚本把真内容「补」进去。用户在服务端就能看到骨架,而不是白屏。

一个容易误解的细节:更新时不一定会闪 fallback

场景行为
首次挂载时挂起显示 fallback(没有旧内容可留)
普通更新时挂起隐藏已有内容,显示 fallback(可能闪烁)
Transition 更新时挂起保留已有内容,等新内容就绪再切换(不闪)

这是 18 的一个重要设计:把「切页 / 切 Tab」这类更新包进 startTransition,可以避免「点一下就白屏再回来」。

关键约束:边界内的依赖是「一起等」的

<Suspense fallback={<Skeleton />}>
  <User />      {/* 等 100ms */}
  <Orders />    {/* 等 300ms */}
  <Chart />     {/* 等 200ms */}
</Suspense>

这个边界要等最慢的那个(300ms)才一起揭示。如果三个组件各自在渲染时才发请求,就会形成「瀑布」:父组件等完子组件才开始等。

三种解法:

// 1. 边界外并行发起(推荐)
const [user, orders, chart] = await Promise.all([fetchUser(), fetchOrders(), fetchChart()]);

// 2. 拆成多个边界,让快的先出来(但要考虑布局跳动)
<Suspense fallback={<UserSkeleton />}><User /></Suspense>
<Suspense fallback={<OrdersSkeleton />}><Orders /></Suspense>

// 3. 用框架的 loader / 预取,把请求提前到渲染之前

边界放多高是个产品问题:放太高,一处慢拖累整页;放太低,页面会「东一块西一块」地跳。经验是按内容块放——列表、图表、评论区各一个。

3.6 Hydration 的三代演进

一句话理解:Hydration 是「给服务端已经画好的静态 HTML 装上神经系统」——不重新创建 DOM,而是遍历现有节点、对齐 Fiber 树、绑上事件与状态。

服务端:组件树 → HTML 字符串(纯静态,不能点)
客户端:下载 JS → 遍历现有 DOM,与 Fiber 对齐 → 绑事件、恢复状态
        ↑ 这一步叫 hydrate;要求「首帧结构完全一致」

为什么必须一致:React 要在不重建 DOM 的前提下复用服务端节点。如果文本或结构对不上,它只能丢弃服务端 HTML、从零渲染,代价是白屏、CLS 和一次额外的完整渲染。

代际做法解决什么遗留问题
16ReactDOM.hydrate 全量、一次性首屏有内容JS 未到前不可交互;一处不匹配整树重建
18hydrateRoot + 选择性 / 并发 hydrationSuspense 边界可独立水合;可优先水合用户正在交互的区域仍要求首帧一致
19增量 diff、第三方标签容忍、prerender / PPR报错可读;扩展注入的标签不再误判「首帧必须一致」的铁律没变

选择性与渐进式 hydration 的差别:

  • 选择性 hydration(18):如果某个边界还在加载,而用户点了另一个边界,React 会优先水合被点击的那个,让交互尽快可用。
  • 渐进式 hydration(19 + PPR):静态壳先由 CDN 提供,动态边界稍后由服务端 resume 续渲染再水合。

不匹配的常见来源(按踩坑频率排序):

来源例子处理
时间 / 随机数Date.now()、Math.random()、uuid()挪到 useEffect,或服务端算好传参
浏览器环境判断typeof window !== 'undefined' 分支渲染不同内容用 useSyncExternalStore 的 getServerSnapshot,或两阶段渲染
国际化 / 时区服务端 UTC、客户端本地时区统一时区,或客户端挂载后再格式化
鉴权分支服务端读到 cookie 已登录,客户端首帧读不到首帧渲染中立态,登录态放到 effect 后
浏览器扩展扩展往 DOM 里插 <div> / 属性19 会跳过这些意外标签;更早版本会报错
无效的 HTML 嵌套<p><div> 被浏览器自动纠正修正结构;别依赖浏览器的容错

19 的改进:把「同一处不匹配报几十条错」变成一条带 diff 的错误,直接告诉你「服务端是 A、客户端是 B」,并跳过第三方脚本/扩展插入的意外节点,避免误判。

一个必须接受的结论:hydration 不匹配是「正确性问题」,不是「性能问题」。修法永远是让首帧一致,而不是关掉警告或 suppressHydrationWarning 掩盖(那个属性只适合 <time> 这类确实会不同的文本)。

3.7 RSC 的边界与序列化

一句话理解:RSC 不是「在服务端跑 SSR」,而是在服务端执行组件,输出一份可序列化的树描述(Flight payload);浏览器拿到描述后自己长出这棵树,只有需要交互的部分才下载 JS。

和 SSR 的本质区别:

维度SSRRSC
产出HTML 字符串可序列化的组件树(payload)
客户端拿到什么已经画好的 DOM结构描述 + 少量客户端组件
是否下发组件代码全部要下发(否则无法水合)只有 'use client' 的部分下发
能否直接读数据库能(但代码仍会进客户端包)能,且代码不进客户端包
解决的核心问题首屏可见首屏 JS 体积与数据获取位置
请求 → 服务端执行 Server Components(可 await 数据库)
     → 产出 RSC payload(元素类型、props、客户端组件引用)
     → 客户端 React 消费 payload,构建树
     → 只有 'use client' 的模块会下发 JS

payload 的结构:它是一份「分块传输的指令流」,不是 JSON 文件。大致包含四类信息:

  1. 元素:类型(div、article)、props、children;
  2. 客户端组件占位:模块 id + props(客户端据此 import 真正的组件);
  3. 文本与 Suspense 边界:哪些片段先到、哪些后补;
  4. Server Action 引用:一个可被客户端调用的「函数句柄」,不是函数体。

四条边界规则,以及它们为什么存在:

  1. 'use client' 是模块边界:因为 bundler 需要一条静态可分析的线来决定「哪些模块进客户端包」。它只能画在文件粒度上,所以标了之后,整个模块及其导入都会下沉。
  2. props 必须可序列化:payload 要跨网络传输,还要跨「服务端运行时 → 浏览器」的语言边界。函数、类实例、Symbol、DOM 节点都无法表达;Server Action 例外,因为它传的是引用(一个 id),不是函数体。
  3. Server Component 不能用状态与副作用:它只执行一次(或在缓存周期内执行),没有「重新渲染」这回事,所以 useState、useEffect、DOM API、window 全都不存在。
  4. 'use server' 只标记 Action:Server Component 不需要指令——它本来就是默认。指令是给「要被客户端调用」的东西用的。

把边界画错会怎样:

// 灾难写法:整页标 'use client'
'use client';
import { db } from './db';       // ← 数据库代码被打进客户端包(或直接构建失败)
import { HeavyChart } from './HeavyChart';
export default function Page() { /* 页面逻辑 */ }

正确做法是「边界下沉到叶子」:

Page(服务端,读数据)
 ├─ Header(服务端,纯展示)
 ├─ <Collapsible>(客户端,交互外壳)
 │    └─ children 由服务端传入(数据留在服务端)
 └─ ResultList(服务端)

两道保险:

// 在只能跑在服务端的模块顶部
import 'server-only';

// 在只能跑在客户端的模块顶部
import 'client-only';

它们不是运行时检查,而是构建期守卫:一旦被错误的一侧 import,直接构建失败,比上线后泄露密钥强得多。

与 SSR 的组合,以及 PPR:

① RSC 执行 → payload
② SSR 把 payload 渲染成 HTML 流 → 浏览器可见
③ 客户端下载客户端组件代码 → hydrate → 可交互

19.2 的 Partial Pre-rendering(PPR)把①拆成两半:静态壳在构建期预渲染好(可放 CDN),动态边界在请求时用 resume 续渲染。这样既保住 TTFB,又能给每个用户不同的内容。

常见坑:

  • 在客户端组件里 import 服务端模块:轻则打包失败,重则把密钥打进 bundle。
  • 以为「不用写 API」就没有安全问题:Server Action 是一个公开端点,必须自己做鉴权与参数校验,别把 db.delete 直接暴露。
  • 在 RSC 里用 Date.now() 渲染:payload 与服务端 HTML、客户端首帧会对不上,水合报错。
  • 缓存语义要单独设计:RSC 默认不缓存,框架(如 Next.js)有自己的缓存层,revalidatePath 只是其中一环。

3.8 Actions 的状态机

一句话理解:Action 把「一次异步提交」变成 React 管理的 transition——pending、错误、表单重置、乐观更新都由状态机接管,而不是你自己维护五个 useState。

idle ──提交──> pending ──成功──> success(表单重置 / 数据刷新)
                    └──失败──> error(抛给 Error Boundary,乐观值回滚)

为什么 Action 天然带 pending:<form action={fn}> 触发时会自动包进一个 transition。所以在 Action 执行期间:

  • 更新是低优先级的,可被打断;
  • isPending 自动为真;
  • 未完成的 UI 不会阻塞输入。
import { useActionState } from 'react';

/**
 * @param {null | { message: string }} prevState 上一次的返回值(初始为 null)
 * @param {FormData} formData 表单数据
 * @returns {Promise<null | { message: string }>} 新 state
 */
async function updateName(prevState, formData) {
  const name = formData.get('name');
  if (!name) return { message: '名字不能为空' }; // 返回 → 变成 state
  await api.update(name);                        // 抛错 → 交给 Error Boundary
  return null;
}

function NameForm() {
  const [state, formAction, isPending] = useActionState(updateName, null);

  return (
    <form action={formAction}>
      <input name="name" />
      <button disabled={isPending}>{isPending ? '保存中…' : '保存'}</button>
      {state?.message && <p role="alert">{state.message}</p>}
    </form>
  );
}

三个 Hook 的分工:

Hook管什么谁用
useActionState提交结果(state)+ pending提交表单的组件
useFormStatus读取最近父级 <form> 的 pending设计系统里的按钮 / 输入
useOptimistic乐观值 + 自动回滚需要即时反馈的交互(点赞、收藏)

「返回」和「抛错」是两种不同的错误处理:

写法结果适合
return { message }成为 state,UI 原地展示可预期的校验错误
throw error冒泡到最近的 Error Boundary意外错误(接口 500、代码 bug)

表单重置的规则:

  • 非受控表单(defaultValue)在 Action 成功后自动 reset;
  • 受控表单(value + onChange)需要自己清空,或调用 requestFormReset;
  • 想「失败时保留用户输入」,就别用自动 reset,改成手动控制。

乐观值为什么能自动回滚:useOptimistic 的值只在 transition 期间有效。Action 结束(无论成功还是失败),React 都会回到真实的 props / state,所以乐观值不会残留。

工程要点:Action 必须幂等

用户双击、网络重试、并发提交都会让同一个 Action 执行多次:

  • 客户端:isPending 期间禁用按钮(只改善体验,不是保证——用户可以直接调接口);
  • 服务端:幂等键 / 唯一约束 / 去重表(这才是真正的保证);
  • 传输层:给请求带 requestId,重复的直接返回上次结果。

常见坑:

  • 在 Action 里先 await,之后又去读 formData:可能已经被回收,先把需要的字段取出来存到变量。
  • 把「用户填错了」这种可预期错误 throw 出去:整个表单会被 Error Boundary 替换,用户输入全没了。
  • useFormStatus 读不到 pending:它只读最近父级 <form>,按钮被 Portal 或挪到表单外就失效。

3.9 React Compiler:从手工 memo 到编译期

一句话理解:Compiler 在构建期做数据流分析,把「跨渲染可以复用」的计算、回调、元素自动缓存起来,让「默认写法」就接近「手写优化过的写法」。

它在做什么(原理层):

  1. 分析组件 / Hook 里每个表达式依赖了哪些值(props、state、模块常量、闭包变量);
  2. 给「可复用」的结果分配缓存槽(相当于自动生成 useMemo / useCallback / memo);
  3. 只有依赖变化时才重算,否则复用上次结果;
  4. 生成的代码只依赖一个极小的运行时辅助函数,不需要你改写法。
// 你写的
const sorted = items.slice().sort(cmp);
const onSelect = (id) => select(id);

// 概念上的输出(缓存槽由编译器分配)
const sorted = useMemoSlot(0, () => items.slice().sort(cmp), [items, cmp]);
const onSelect = useCallbackSlot(1, (id) => select(id), [select]);

能优化 / 不能优化:

能优化不能优化
纯计算、对象 / 数组字面量原地修改 props / state(push、直接赋值)
内联回调、JSX 元素在 render 里发请求、订阅、改 DOM
组件内派生数据依赖外部可变状态(随机数、全局变量)
传给子组件的稳定引用违反 Hooks 规则的代码

前提:Rules of React(编译器不会「猜」,它只会保守放弃):

  • 组件与 Hook 是纯函数;
  • 数据不可变;
  • render 中无副作用;
  • Hooks 只在顶层调用。

所以「上了 Compiler」不等于「代码自动变快」——它只会优化符合规则的部分,其余部分静默跳过。

和手写 memo 的关系:

  • 不是「删掉所有 useMemo」:有明显语义价值的要保留(比如「必须引用稳定」才能避免 useEffect 反复执行的配置对象)。
  • 编译器按组件实例缓存,跨组件共享数据仍要靠 useMemo、状态管理或服务端缓存。
  • 手写与编译器不冲突,编译器会尽量复用你已有的缓存。

落地顺序:

  1. 升级 eslint-plugin-react-hooks(v6 起包含编译器规则),先修违规;
  2. CI 里跑静态检查,只报告不开启编译;
  3. 选 1–2 个纯展示目录开启,观察构建时间与运行时表现;
  4. 全量开启后,再逐步删掉「纯性能目的」的 useMemo / useCallback;
  5. 用 Profiler 对比 commit 次数与 actualDuration,确认收益真实存在。

常见坑:

  • 以为「不用再管性能」:慢在 commit、慢在数据、慢在算法,编译器都帮不上。
  • 不看 lint:违规代码会被静默跳过,你以为优化了,其实没有。
  • 调试时遇到「缓存了旧闭包」:先查依赖是否被正确分析(用了 ref、可变对象、外部变量)。
  • 构建时间上升:编译期分析有成本,大项目要评估 CI 时长与增量编译表现。

3.10 L3 小结与自检

  1. 为什么 Transition 能改善输入卡顿,却不一定减少总渲染时间?——它把低优先级更新变成可中断、可丢弃的渲染,让高优先级输入先提交;总工作量可能不变,但关键路径变短。
  2. 为什么 Suspense 边界内的多个请求容易形成瀑布?——边界会等待其内部所有挂起资源就绪后才揭示;请求若在边界内串行发起,就会一层等一层,需要提前并行预取。
  3. RSC 的 props 为什么不能传函数?——RSC payload 需要跨网络序列化,函数无法序列化;Server Action 传的是服务端函数引用,不是函数本身。

4. L4 专家:迁移与治理

4.1 迁移路线图:不要把 16 直接跳到 19

推荐四阶段,每阶段都可独立发布、独立回滚:

阶段 0:摸底
  统计 ReactDOM.render / findDOMNode / 字符串 ref / defaultProps / legacy context 用量
  统计第三方库对 react 的 peerDependencies 范围

阶段 1:16.x → 17
  收益:新 JSX、事件挂根、渐进升级能力
  动作:升级 react/react-dom、开 jsx: react-jsx、修 onScroll 委托、验证微前端隔离

阶段 2:17 → 18
  收益:createRoot、自动批处理、Transition、流式 SSR
  动作:替换 root API、开 StrictMode、修双执行暴露的副作用、验证批处理对埋点的影响

阶段 3:18 → 19
  收益:Actions、RSC、ref 简化、资源预加载、错误可诊断
  动作:跑官方 codemod、删旧 API、改 forwardRef、改 defaultProps、升级测试工具

阶段 4:按需启用新模型
  Actions / RSC / Compiler 分别评估,不必同时上

为什么先到 17:17 的接口改造让「同页多版本」成为可能,微前端与灰度可以按子应用逐个升级,而不是一次性全量。

每个阶段的「退出条件」(不满足就不要往下走):

阶段退出条件验收指标常见卡点
0 摸底旧 API 清单、依赖矩阵、核心流程清单三份文档齐备命中文件数、阻断型依赖数「没人知道哪些页面在用 findDOMNode」
1 → 17无 onScroll / 事件委托相关回归;子应用可独立升级核心流程回归通过率 100%埋点与快捷键库依赖 document
2 → 18入口全部换成 createRoot;StrictMode 下无重复请求INP 基线建立、错误率不升双执行暴露的隐式单例
3 → 19codemod 后无旧 API;测试全绿构建产物、测试通过率defaultProps 遗漏导致字段消失
4 新能力至少一个场景有量化收益表单代码行数、INP、首屏 JS为了「用新特性」而用

为什么不能一步到位:一次升级同时改变「兼容性」与「渲染模型」,出问题时无法归因。分段的本质是把不可归因的问题拆成可归因的小问题——每一段都能回答「这次变慢/变错,是因为哪一类变化」。

一个现实中的取舍:如果项目里微前端、多版本并存的需求不存在,17 这一段的收益会偏小,可以考虑 16 → 18 直接跳,但要接受「并发 + 兼容性」两类问题一起排查的成本。反之,如果团队有大量第三方组件、自研库,17 这段几乎必做。

4.2 升级中的高频破坏点

现象根因处理
页面白屏,控制台提示 root 已存在同时调用了 ReactDOM.render 与 createRoot统一 root 入口
请求发了两次StrictMode 开发期双执行 effect用 AbortController / 幂等,不要删 StrictMode
输入框光标跳动受控值走了 Transition输入值保持紧急更新,只把派生结果放 Transition
埋点数量变少自动批处理合并了多次更新埋点改为在事件回调里直接上报,不依赖渲染次数
ref 回调报类型错误19 起 ref 回调可返回 cleanup,TS 拒绝其他返回值改成块级函数体 { node => { ... } },避免隐式 return
defaultProps 不生效函数组件 defaultProps 在 19 被忽略改用默认参数
SSR 水合报错服务端/客户端首帧不一致(时间、随机数、鉴权分支)把差异移到 effect,或使用 useSyncExternalStore

这些坑为什么集中在升级时爆发:它们都依赖「旧模型里的隐含假设」,而升级改的恰好是这些假设:

隐含假设在新模型下为什么不成立
「一次 setState = 一次渲染」自动批处理合并了同一 tick 的更新
「render 只会执行一次」并发下可中断、可重放;StrictMode 主动双调用
「事件一定经过 document」17 起委托到 root 容器
「组件卸载就立刻清理」cleanup 改为绘制后异步执行
「组件实例永远能拿到 DOM」findDOMNode 在并发下失效,被移除

排查顺序(按成本从低到高):

  1. 先看控制台:root 重复、ref 类型、水合 diff、UNSAFE_ 警告,这些都有明确提示;
  2. 再看请求数:双执行导致的重复请求最容易被误判为「后端变慢」;
  3. 对比渲染次数:用 Profiler 统计 commit 次数,确认是「批处理让次数变少」还是「并发让次数变多」;
  4. 最后查时序:只有前三条都排除,才去怀疑「cleanup 异步」「事件顺序」这类细微差异。

4.3 性能治理:从「手写 memo」到「度量驱动」

三代优化手段的定位:

时代主要手段局限
16PureComponent、shouldComponentUpdate、memo依赖人工判断,容易漏
18Transition、useDeferredValue、流式 SSR、useId只解决「优先级」,不解决「重复计算」
19Compiler 自动 memo、Actions 减少手写状态、资源预加载依赖代码符合规则;RSC 需要框架配合

度量三步法(顺序不能颠倒):

① RUM(真实用户):INP / LCP / TBT 有没有问题?影响多少用户?
      ↓ 有明确问题才继续
② Profiler / Performance Tracks:慢在哪棵子树、哪个阶段(render 还是 commit)?
      ↓ 定位到具体组件与阶段
③ 代码与数据:是重复计算、DOM 太多、还是数据太大?
      ↓ 决定手段

「症状 → 手段 → 验证」对照表:

症状多半是该用什么怎么验证有效
输入卡、点击延迟(INP 差)render 抢占主线程Transition、useDeferredValue、虚拟化INP P75 下降,输入延迟的 long task 减少
首屏白屏久(FCP/LCP 差)JS 太大、请求链太长代码分割、RSC、流式 SSR、预加载首屏 JS 体积、LCP 时间
切换 Tab 卡一次性渲染太多Transition + 骨架屏交互到下一帧的时间
列表滚动掉帧DOM 节点过多 / 每行都重渲染虚拟化、Compiler / memo掉帧率、commit 次数
点一下要等接口数据获取方式预取、乐观更新、Actions感知等待时间、pending 时长
卸载后内存涨订阅 / 定时器没清cleanup、AbortController堆快照、监听器数量

三种常见的「假优化」:

// 1. 给不常重渲染的组件加 memo:比较成本 > 省下的渲染
const Row = React.memo(RowImpl); // 父组件一年才更新一次,纯属噪音

// 2. 用 useMemo 包裹极廉价的运算
const total = useMemo(() => a + b, [a, b]); // 比较依赖比加法还贵

// 3. 把整页塞进 startTransition
startTransition(() => setPage(next)); // 用户点了没反馈,只觉得「卡住了」

没有度量就上 Compiler / Transition,通常只是「感觉快了」。可复现的结论应该长这样:「某列表页 INP P75 从 320ms 降到 210ms,commit 次数从 14 降到 3,首屏 JS 从 1.8MB 降到 1.2MB」。

4.4 与框架、生态的耦合

生态与 19 的关系注意点
Next.js App RouterRSC / Server Actions 的主要落地形态RSC 的 bundler API 不遵循 semver,需锁版本
React Router / Remix数据路由 + Actions 思路一致升级时注意 loader/action 与 React Actions 的边界
Vite + @vitejs/plugin-rsc非 Next 的 RSC 方案生态成熟度低于 Next,评估维护成本
组件库(Ant Design 等)依赖 findDOMNode / 旧 Context 的库会受影响先查 peerDependencies 与 issue
微前端(qiankun 等)17 起支持同页多版本沙箱与事件委托要一起验证

4.5 组织级决策:什么时候「不该升」

升级不是默认正确。用四个问题过滤:

  1. 收益是否可量化:INP 能否从「差」到「良」?首屏 JS 能否降 30%?表单代码能否减少一半?
  2. 成本是否可控:测试矩阵、第三方库、SSR 基础设施、团队熟悉度。
  3. 风险是否可回滚:能否按子应用/路由灰度?能否在一天内回退?
  4. 是否被生态倒逼:新依赖只支持 18+、框架要求 19、安全修复只在新版本。

结论模板:「在 Q3 完成 16 → 18,覆盖 3 个核心子应用,目标 INP P75 从 320ms 降到 200ms;19 的 RSC 作为 Q4 的 POC,不进入主链路。」

4.6 L4 自检

  1. 一条 16 → 19 的迁移路线,你会怎么分段并设置验收点?——先摸底旧 API 与依赖,再 16→17(新 JSX、事件挂根)→18(createRoot、StrictMode、批处理验证)→19(codemod、删旧 API、按需 Actions/RSC);每段以「核心流程回归 + 错误率 + INP」为验收,且可独立回滚。
  2. 团队说「升级后感觉变慢了」,你怎么定位?——先分清是「渲染次数变化」「批处理导致的观测差异」还是「并发下的重复 render」;用 Profiler 对比 commit 次数与 actualDuration,用 RUM 看 INP/LCP,再判断是 Transition 用错、StrictMode 双执行、还是 Compiler 未生效。
  3. 什么时候应该拒绝上 RSC?——当团队没有框架级支持(自建 bundler 成本高)、页面强依赖客户端状态、或 SEO/首屏收益不足以覆盖运维复杂度时;RSC 是架构选择,不是版本升级的必选项。

5. 综合实战:把一个 React 16 后台升级到 19

背景:30 个页面的运营后台,React 16.14 + class 组件 + ReactDOM.render + react-router@4,首屏 JS 1.8MB,INP P75 320ms。

目标:升到 19,INP < 200ms,首屏 JS 降低 30%,不停止业务迭代。

步骤 1 · 摸底(1 天)

# 统计旧 API 用量
rg -n "ReactDOM\.render|ReactDOM\.hydrate|findDOMNode|unmountComponentAtNode" src
rg -n "ref=\"|defaultProps|contextTypes|getChildContext" src
rg -n "react-dom/test-utils" src test

产出:一张「命中文件 / 责任人 / 替换方案」的清单。多数项目会发现 80% 的改动集中在 root 入口与 3–5 个公共组件。

摸底要回答的五个问题:

问题为什么重要怎么查
旧 API 分布在哪些文件?决定工作量与责任人上面的 rg 命令
哪些第三方库卡版本?有的库 peerDependencies 只到 16,是硬阻断npm ls react、看 lock 文件
有没有 SSR / 水合?升级要额外验证一致性搜 hydrate、renderToString
有没有微前端 / 多实例?17 的事件改造直接影响它搜 qiankun、single-spa、多次 render
性能基线是多少?没有基线就无法证明升级收益RUM 或 Lighthouse 先跑一遍

产出物模板:

| 文件 | 命中项 | 责任人 | 替换方式 | 验证用例 |
| src/index.jsx | ReactDOM.render | 张三 | createRoot | 登录 → 首页 |
| src/components/Modal.jsx | findDOMNode | 李四 | ref 透传 | 打开/关闭弹窗 |

步骤 2 · 16 → 17(1 周)

  • 升级 react / react-dom 到 17。
  • 打开新 JSX:"jsx": "react-jsx",删除不再需要的 import React。
  • 检查事件委托:把依赖 document 的埋点/快捷键库改为监听根容器。
  • 验收:核心 10 条流程回归;微前端子应用可独立升级。

这一阶段的具体检查清单:

# 1. 锁版本,避免其它包被连带升级
npm i react@17 react-dom@17 --save-exact

# 2. 找出所有「只为了 JSX 而 import React」的文件(可批量清理)
rg -l "^import React from 'react';" src | xargs rg -L "React\." 

# 3. 找出依赖 document 级别事件的代码
rg -n "document.addEventListener\(['\"](click|keydown|scroll|focus)" src

# 4. 找出依赖事件池的写法
rg -n "\.persist\(\)" src

为什么先开新 JSX 再升版本:16.14 就支持 jsx: react-jsx,先做这一步可以把「编译配置」与「运行时升级」两个变量分开;一旦出问题,很容易判断是谁引起的。

步骤 3 · 17 → 18(2 周)

// 入口改造
import { createRoot } from 'react-dom/client';

const root = createRoot(document.getElementById('root'), {
  onRecoverableError(error) {
    report(error);
  },
});
root.render(<App />);
  • 开 StrictMode(先在开发环境),修双执行暴露的问题:重复请求、未清理订阅、在 render 里改状态。
  • 校验批处理对埋点的影响:把「渲染次数」埋点改成「事件次数」。
  • 选 1–2 个重列表页试 useTransition / useDeferredValue,对比 INP。

双执行问题的标准修法:

/**
 * 请求型 effect:用 AbortController 保证「重放」不会留下副作用。
 * StrictMode 下会执行两次,第一次的请求会被 abort,不会重复写状态。
 */
useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/user/${id}`, { signal: controller.signal })
    .then((r) => r.json())
    .then(setUser)
    .catch((err) => {
      if (err.name !== 'AbortError') setError(err);
    });

  return () => controller.abort();
}, [id]);
/**
 * 订阅型 effect:cleanup 必须与 setup 严格配对。
 * 判断标准:连续执行 setup → cleanup → setup 后,系统状态与只执行一次相同。
 */
useEffect(() => {
  const sub = store.subscribe(handleChange);
  return () => sub.unsubscribe();
}, []);

这一阶段的验收:

检查项通过标准
入口全仓库只有一处 createRoot,无 ReactDOM.render 残留
StrictMode开发环境无重复请求、无重复订阅告警
埋点曝光数与升级前差异在合理范围(并确认是「事件口径」)
性能至少一个页面 INP 有可测量的改善

步骤 4 · 18 → 19(2 周)

# 官方 codemod(按官方升级指南执行)
npx codemod@latest react/19/migration-recipe
  • 替换 forwardRef 为 ref prop(可保留 forwardRef 过渡)。
  • 函数组件 defaultProps 改默认参数。
  • 测试里 react-dom/test-utils 的 act 改为 React.act。
  • 移除 findDOMNode 与字符串 ref。
  • 升级 @testing-library/react 与 ESLint 插件。

codemod 之后必须人工过一遍的四类:

类型为什么机器改不了人工检查点
findDOMNode需要理解「你想控制哪个 DOM」改成把 ref 放到真正需要的节点上
defaultProps需要决定默认值语义(undefined vs null)改成解构默认值,并确认「显式传 undefined」的行为
字符串 ref需要决定用 createRef 还是回调 ref类组件用 createRef,函数组件用 useRef
legacy context需要重画 Provider 边界换成 createContext,并检查 SCU 是否阻断

这一阶段的验收:

# 应该没有输出
rg -n "ReactDOM\.(render|hydrate|findDOMNode)|defaultProps|contextTypes" src
检查项通过标准
构建无 UMD / 旧入口相关报错
测试单测 + E2E 全绿(act 已迁移)
类型tsc --noEmit 通过,ref 回调无类型报错
行为表单默认值、弹窗、路由切换三条主链路人工回归

步骤 5 · 按需启用新能力(持续)

  • 表单:把 3 个高频表单改成 <form action> + useActionState,对比代码行数与错误率。
  • 乐观更新:点赞/收藏类交互用 useOptimistic。
  • RSC:只对「内容型页面」做 POC(如帮助中心),不碰强交互后台。
  • Compiler:先在 2 个纯展示模块开启,观察构建时间与运行时表现。

每项能力的「拿收益前提」:

能力前提如果前提不成立
Actions表单提交已有明确的服务端接口,且能接受非受控表单保留受控表单,只借 useTransition 管 pending
useOptimistic失败可回滚、可接受短暂「假成功」用禁用 + loading,不要乐观
RSC页面是「读数据为主」,交互可下沉到叶子强交互后台收益有限,优先做代码分割
Compiler代码符合 Rules of React,有 lint 兜底先修违规,不要直接开

一个最小的 Actions 落地样例(用来估工时):

// 改前:约 40 行状态管理
const [pending, setPending] = useState(false);
const [error, setError] = useState(null);

async function onSubmit(e) {
  e.preventDefault();
  setPending(true);
  try {
    await api.save(new FormData(e.currentTarget));
    toast.success('已保存');
  } catch (err) {
    setError(err.message);
  } finally {
    setPending(false);
  }
}

// 改后:约 15 行,pending / 重置由 React 管
const [error, submit, isPending] = useActionState(async (_, formData) => {
  try {
    await api.save(formData);
    return null;
  } catch (err) {
    return err.message; // 可预期错误 → 原地展示
  }
}, null);

<form action={submit}>
  <input name="title" />
  <button disabled={isPending}>保存</button>
  {error && <p role="alert">{error}</p>}
</form>

步骤 6 · 验收与回滚

维度指标目标
稳定性JS 错误率不高于升级前
性能INP P75< 200ms
体积首屏 JS降低 30%
体验关键流程完成率不下降
可回滚回滚耗时< 30 分钟

回滚方案:保留旧入口与旧依赖版本,按子应用/路由灰度;数据层保证新旧代码都能读(新增字段双写或兼容读)。

步骤 7 · 灰度与观测(持续 2–4 周)

升级上线不是终点,第一周才是问题高发期——很多差异只在真实设备和真实数据下暴露。

灰度节奏:

批次范围观察时长关注指标
内部团队 + 测试账号1–2 天控制台报错、核心流程
小流量5%–10% 用户3 天JS 错误率、INP、接口错误率
中流量30%–50%3–5 天按页面拆分看,找出退化页面
全量100%持续与基线对比,形成结项报告

必须盯住的四个信号:

// 1. 未捕获错误:19 的 onUncaughtError 比 window.onerror 更早、更准
createRoot(container, {
  onUncaughtError(error, info) {
    reportFatal(error, { componentStack: info.componentStack });
  },
  onRecoverableError(error, info) {
    // 出现量突增 = 水合不一致,通常意味着 SSR 分支没对齐
    report(error, { componentStack: info.componentStack });
  },
});

// 2. 重复请求:StrictMode 只在开发生效,线上重复要看接口维度
trackDuplicateRequests(['/api/save', '/api/submit']);

// 3. 渲染次数:与基线对比,突然翻倍通常是「Transition 用错」或「effect 里循环 setState」
// 4. INP:按页面拆分看 P75,不要只看全站平均

结项报告应该包含:升级前后 INP / LCP / 首屏 JS 的对比、错误率的对比、改动文件数与工时、遗留问题清单(哪些页面还没升、哪些库还锁在旧版本)。这份报告是下一次升级的输入,也是「升级到底值不值」的唯一证据。


6. 常见误区对照表

误区事实
「React 19 才有并发渲染」18 就已提供,19 没有新增并发原语;19 新增的是 Actions / RSC / use 等
「并发渲染 = 多线程」仍是单线程;并发指可中断、可排序、可丢弃
「RSC 就是 SSR」RSC 输出可序列化的树,SSR 输出 HTML;两者是流水线不同阶段
「'use server' 标记服务端组件」它标记的是 Server Action;Server Component 没有指令
「StrictMode 双执行是 bug」开发期故意为之,用来暴露不纯的副作用
「自动批处理会合并所有 setState」只合并同一 tick / 同一微任务内的更新,跨 await 不合并
「19 里 forwardRef 不能用了」仍可用,只是不再必要;官方计划未来才弃用
「Compiler 能替代所有 useMemo」只对符合 Rules of React 的代码生效;有副作用/可变数据时会失效
「升级 19 必须上 RSC」RSC 是可选的架构选择,与版本升级解耦
「useMemo 一定提升性能」有比较与内存成本;依赖频繁变化时反而更慢

6.1 三条最容易被追问的纠偏

「并发渲染 = 多线程」

这句话错在两个地方:JS 主线程仍只有一个;React 也没有把渲染搬到 Worker。它做的是任务调度——把一次渲染切成小段,在段与段之间检查「有没有更紧急的事」。

这解释了一个常见困惑:为什么加了 Transition 之后「总耗时没变」。因为并发优化的是关键路径(让输入先提交),不是总计算量。真要减少总耗时,得靠虚拟化、减少 DOM、Web Worker 或服务端计算。

「RSC 就是 SSR」

两者解决的是不同问题,而且可以组合:

  • SSR 解决「首屏可见」:把树变成 HTML,用户不用等 JS 就能看到内容;
  • RSC 解决「首屏 JS 与数据位置」:在服务端执行组件,只把「需要交互的代码」发给浏览器。

一个只有 SSR 的项目,业务代码仍然全部打包进客户端;一个只有 RSC 的项目,首屏依然需要 SSR 才能出 HTML。所以「上了 RSC 就不用 SSR」是典型的混淆。

「Compiler 能替代所有 memo」

Compiler 的优化对象是符合规则的纯计算与稳定引用,它有明确的能力边界:

它能做它不做
自动缓存派生数据、内联回调、JSX 元素优化 commit 阶段的 DOM 操作
在依赖不变时复用上次结果帮你修「数据太大」「接口太慢」
让「默认写法」接近手写优化猜测违反规则的代码的意图(会直接跳过)

一个简单判断法:问自己「这个说法改变的是『怎么算』『先算谁』还是『在哪算 / 怎么交付』」——16 改的是怎么算,18 改的是先算谁,19 改的是在哪算与怎么交付。把这三层分清楚,大多数「听起来对」的说法都能立刻验证。


7. 面试高频题(附答题思路)

Q1:React 16 到 19 最重要的三个变化是什么?

  • 30 秒骨架:按「渲染模型 → 服务端边界 → 交互模型」三层答,每层给一句「解决了什么现象」。
  • 展开:
    1. 渲染模型:16 的 Fiber 把递归换成链表,让渲染可中断;18 用 createRoot + lane 把「可中断」变成业务可用,解决输入卡顿(INP)。注意 16 只是地基,不能说 16 有并发。
    2. 服务端边界:从「SSR 输出 HTML」到「RSC 输出可序列化的树」,只有交互组件下发 JS,解决首屏 JS 体积与数据获取瀑布。
    3. 交互模型:从「每个表单手写 pending / error / 乐观值」到 Actions 状态机,减少样板代码与状态不一致。
  • 常见追问:「19 新增了并发原语吗?」——没有;19 的新增集中在 Actions、use、RSC、ref 简化与 Compiler。

Q2:并发渲染解决了什么问题,代价是什么?

  • 30 秒骨架:解决「响应性」,不解决「总耗时」;代价是「render 必须纯」和「可能饥饿」。
  • 展开:并发做三件事——可中断(5ms 切片让出主线程)、可排序(lane 决定谁先算)、可丢弃(过期结果直接扔)。所以大列表放进 Transition 后,总计算量可能不变,但输入框不再被阻塞。
  • 常见追问:「为什么 5ms?」——一帧 16.6ms 要分给输入、布局、绘制,切片太长会卡输入,太短则调度开销占比高。「会饿死吗?」——会,持续的高优先级更新会让低优先级一直排队,React 用过期与重试缓解,业务上要节流。

Q3:RSC 和 SSR 的区别?

  • 30 秒骨架:RSC 输出「树」,SSR 输出「HTML」,两者是流水线上下游,不是替代关系。
  • 展开:RSC 在服务端执行组件,产出可序列化的 Flight payload;客户端据此建树,只有 'use client' 的模块下发 JS。SSR 把树渲染成 HTML 流,让首屏可见。典型链路是 RSC → SSR → Hydration。'use client' 是模块边界,'use server' 标记的是 Action。
  • 常见追问:「props 为什么不能传函数?」——payload 要跨网络序列化;Server Action 传的是引用(id),不是函数体。「'use client' 加在哪?」——加在文件顶部,标了之后整个模块及其导入都进客户端包,所以边界要下沉到叶子交互组件。

Q4:Actions 解决了什么问题?

  • 30 秒骨架:把「一次异步提交」变成 React 管理的 transition,pending / 错误 / 重置 / 乐观值都有标准位置。
  • 展开:<form action={fn}> 会自动把提交包进 transition,因此 useActionState 能拿到 isPending;useFormStatus 让子组件(按钮)读取最近的 form 状态;useOptimistic 在 transition 期间显示乐观值,结束后自动回落到真实值。返回值和抛错是两种错误处理:可预期错误 return,意外错误 throw 给 Error Boundary。
  • 常见追问:「双击提交怎么办?」——UI 禁用只是体验,服务端必须幂等(幂等键 / 唯一约束 / 去重表)。

Q5:为什么 StrictMode 下 effect 会执行两次?

  • 30 秒骨架:开发期故意「挂载 → 卸载 → 再挂载」,用来暴露未来并发下会出问题的副作用。
  • 展开:顺序是 setup → cleanup → setup。它抓三类问题:没清理的订阅、重复发送的请求、在 render / effect 里修改外部状态。正确修法是补 cleanup、用 AbortController、保证幂等,而不是删掉 StrictMode。
  • 常见追问:「生产会执行两次吗?」——不会,只在开发环境。「为什么我的请求还是发两次?」——检查是不是 effect 依赖写错导致重复执行,或者组件本身被挂载了两次(例如路由 key 变化)。

Q6:自动批处理的边界在哪?

  • 30 秒骨架:合并的是「同一个 tick / 微任务内」的更新,跨 await 不合并。
  • 展开:18 之前只合并 React 事件回调里的更新;18 起 setTimeout、Promise、原生事件也会合并,因为 flush 被放进微任务队列。flushSync 可以强制同步刷新,但会放弃并发、破坏批处理。
  • 常见追问:「为什么跨 await 不合并?」——每个 await 之后已经是新的微任务,flush 已经发生过。「批处理会影响埋点吗?」——会,依赖「渲染次数」的埋点会变少,要改成按事件上报。

Q7:React 19 有哪些破坏性变更?

  • 30 秒骨架:一次「清理」——把并发下无法正确实现的旧 API 删掉。
  • 展开:ReactDOM.render / hydrate / unmountComponentAtNode → createRoot / hydrateRoot / root.unmount;findDOMNode → ref;字符串 ref → createRef / 回调 ref;legacy context → createContext;函数组件 defaultProps / propTypes 被忽略;react-dom/test-utils 的 act → React.act;UMD 构建移除。
  • 常见追问:「升级必须重写类组件吗?」——不需要,类组件仍可用;只是新特性以 Hooks 为主。「forwardRef 还能用吗?」——能,只是不再必要。

Q8:如何评估一次大版本升级?

  • 30 秒骨架:四问——收益能否量化、成本是否可控、风险能否回滚、是否被生态倒逼。
  • 展开:收益看 INP / 首屏 JS / 表单代码量;成本看测试矩阵、依赖兼容、SSR 基础设施;风险看能否按子应用灰度、能否一天内回退;生态看新依赖是否只支持 18+。结论要给「分阶段路线 + 验收指标」,而不是「一次性升到最新」。
  • 常见追问:「老板问为什么要升,怎么答?」——给一组基线数据与目标值,加一份「不升的代价」(依赖无法升级、安全修复拿不到、招聘与维护成本)。

8. 面试深挖

8.1 面试官在考察什么

维度看什么加分回答减分回答
版本脉络能否把 API 放回它解决的问题里「Transition 是 18 为响应性引入的」「19 才有并发」
机制理解能否讲到调度 / 序列化 / Hydration「lane 决定优先级,commit 不可中断」只背 API 名
边界意识是否知道 RSC / Actions 的约束「'use client' 是模块边界,props 必须可序列化」「RSC 就是 SSR」
迁移能力是否有分阶段与回滚方案「16→17→18→19,每段可独立发布」「直接升最新版」
量化意识是否用数据说话「INP P75 从 320ms 降到 200ms」只说「感觉快了」

8.2 追问链

链 1 · 并发渲染 → 时间切片 → 优先级饥饿

  • 面试官:并发渲染到底「并发」了什么?
    • 要点:不是多线程,而是可中断(Fiber 单元 + 5ms 让出)、可排序(lane)、可丢弃(过期结果);收益是响应性,不是总耗时。
    • 追问:时间切片为什么是 5ms 左右?
      • 要点:一帧约 16.6ms,渲染只占其中一部分,还要留给输入、布局、绘制;切片过长会让交互延迟,过短则调度开销占比升高。
      • 再追问:低优先级任务会不会饿死?
        • 要点:会。持续到来的高优先级更新会不断抢占;React 用过期与重试缓解,但业务上要限制「无限 Transition」与超大列表。
    • 追问:Transition 和 useDeferredValue 有什么区别?
      • 要点:Transition 标记「这次更新不紧急」,可被打断;useDeferredValue 不改变更新来源,只让某个值滞后一帧,适合「输入即时、结果慢半拍」。

链 2 · RSC → 序列化边界 → 与 SSR 的组合

  • 面试官:RSC 和 SSR 是什么关系?
    • 要点:RSC 在服务端执行组件,输出可序列化的树;SSR 把树渲染成 HTML;典型链路是 RSC → SSR → Hydration。
    • 追问:为什么 RSC 的 props 不能传函数?
      • 要点:payload 要跨网络序列化;函数、类实例、Symbol 都不可序列化;Server Action 传的是引用,不是函数本身。
      • 再追问:'use client' 加在组件上还是模块上?
        • 要点:模块级指令;标了之后该模块及其导入的整棵子树进入客户端包,所以边界要尽量下沉到叶子交互组件。
    • 追问:Partial Pre-rendering 解决什么?
      • 要点:把静态壳预渲染到 CDN,动态部分用 resume 续渲染;兼顾 TTFB 与个性化内容,避免整页动态化。

链 3 · Actions → 乐观更新 → 幂等与回滚

  • 面试官:Actions 帮我们省了什么?
    • 要点:pending、error、表单重置、乐观更新的标准状态机;useActionState / useFormStatus / useOptimistic 分工明确。
    • 追问:乐观更新失败后怎么回滚?
      • 要点:useOptimistic 在 Action 结束(成功或失败)后自动回落到真实值;失败时同时把错误交给 Error Boundary 或返回 state。
      • 再追问:用户双击提交怎么办?
        • 要点:pending 期间禁用按钮只是 UI 层;服务端必须用幂等键/唯一约束兜底,客户端可用请求去重。
    • 追问:Actions 和普通 onSubmit 的本质区别?
      • 要点:Actions 运行在 transition 中,可被打断、可管理 pending,并能与服务端 Action 直接对接;普通 onSubmit 是同步事件回调,状态要自己管。

8.3 代码题

题 1 · 用 Transition 做搜索框,并处理竞态

import { useTransition, useRef } from 'react';

/**
 * 搜索框:输入走紧急更新,请求与结果走过渡更新。
 * 用请求序号丢弃过期响应,避免「后发先至」。
 * @param {{ onSearch: (q: string) => Promise<Array> }} props
 */
function Search({ onSearch }) {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();
  const seq = useRef(0);

  function onChange(e) {
    const next = e.target.value;
    setQuery(next); // 紧急:输入必须即时

    // 19 支持异步 transition:pending 自动覆盖整个请求周期
    startTransition(async () => {
      const id = ++seq.current;
      const list = await onSearch(next);
      if (id === seq.current) setResults(list); // 丢弃过期响应
    });
  }

  return (
    <>
      <input value={query} onChange={onChange} />
      {isPending && <Spinner />}
      <List items={results} />
    </>
  );
}

考察点:输入不能用 Transition;请求与结果更新可以;竞态要用序号或 AbortController;异步 Transition 的 isPending 会一直持续到 Action 结束。

题 2 · 用 useOptimistic 做点赞

'use client';

import { useOptimistic } from 'react';

/**
 * 点赞按钮:先加一,失败自动回滚。
 * @param {{ id: string, likes: number, like: (id: string) => Promise<void> }} props
 */
export default function LikeButton({ id, likes, like }) {
  const [optimisticLikes, addOptimistic] = useOptimistic(likes, (n) => n + 1);

  return (
    <form
      action={async () => {
        addOptimistic();
        await like(id);
      }}
    >
      <button>赞 {optimisticLikes}</button>
    </form>
  );
}

考察点:乐观值只在 transition 期间有效;失败回滚是自动的;服务端仍需防重复。

题 3 · 标出 RSC 的客户端边界

给定:Page(读数据库)→ Header(纯展示)→ SearchBox(有输入框)→ ResultList(纯展示)。

答案:Page / Header / ResultList 保持 Server Component;只有 SearchBox 标 'use client'。如果 ResultList 需要接收客户端状态,则把它作为 children 从服务端传入,避免整棵树下沉到客户端。

8.4 场景设计题

题:一个 16 的运营后台(30 页面、class 组件为主)要升级到 19,同时把 INP 从 320ms 降到 200ms。你如何规划两个月?

评估点:是否先量化、是否分阶段、是否考虑回滚、是否区分「版本升级」与「新特性启用」。

参考方案:

  1. 第 1 周摸底:统计旧 API、依赖 peerDependencies、核心流程清单;建立 INP/LCP/错误率基线。
  2. 第 2–3 周升 17:新 JSX、事件挂根;先让微前端能按子应用灰度。
  3. 第 4–5 周升 18:createRoot、StrictMode;修双执行问题;用 Profiler 找最贵的 5 个页面。
  4. 第 6–7 周升 19:codemod、删旧 API、改 forwardRef/defaultProps;升级测试工具。
  5. 第 8 周按需启用:3 个重列表页用 Transition/虚拟化;3 个高频表单用 Actions;Compiler 先在纯展示模块开启。
  6. 验收:INP P75 < 200ms、错误率不升、回滚 < 30 分钟;每周灰度 10% → 50% → 100%。

加分:指出「先升 17 再升 18」能降低一次性风险;指出 StrictMode 双执行会暴露重复请求,必须用幂等与 AbortController 修,而不是关掉 StrictMode。

减分:直接全量升 19 且不开灰度;把「升级」与「上 RSC」绑在一起;没有基线就开始优化。

8.5 最容易答错的点

说法纠正
「React 19 引入了并发渲染」18 引入并可选用;19 没有新增并发原语
「Fiber 让渲染变快了」Fiber 让渲染可中断;单次渲染不一定更快
「StrictMode 双执行是 bug」开发期刻意行为,用于暴露不纯副作用
「RSC 就是服务端渲染」RSC 输出树,SSR 输出 HTML,两者可组合
「'use server' 声明服务端组件」它声明 Server Action
「自动批处理跨 await 合并」只在同一 tick / 微任务内合并
「升级 19 必须重写为函数组件」类组件仍可用;只是新特性以 Hooks 为主
「Compiler 上线后可以删掉所有 memo」仅对符合规则的代码有效,且需逐步验证

9. 延伸资源

  • React 官方博客:React 16、16.3、16.6、16.8、16.9、17、18、19、19.2、19.3 的发布说明(版本差异的第一手依据)。
  • React 官方文档:createRoot、useTransition、useDeferredValue、useActionState、useOptimistic、use、Server Components、Server Actions。
  • React 19 升级指南:破坏性变更与 codemod 清单。
  • React Compiler 文档与 eslint-plugin-react-hooks v6 规则说明。
  • Next.js App Router 文档:RSC 与 Server Actions 的工程化落地。
  • 配套阅读:本库 06 框架与组件化(Hooks 与协调)、08 路由与渲染架构(SSR/流式)、11 性能优化(INP 与长任务)、09 工程化与构建(codemod 与 Monorepo 升级)。

10. 附录:核心 API 逐个拆解

正文按「版本演进」讲,很多 API 只来得及提一句「19 新增」「18 弃用」。这个附录换一个视角:一个 API 一个 API 地拆,每个都回答同样八个问题——

  1. 它是什么:输入什么、输出什么、在哪运行、返回什么东西;
  2. 它长什么样:先跑一个最小例子,看清产物;
  3. 它从哪来:在它之前人们怎么写,为什么那样写不下去;
  4. 哪个版本:引入、变更、弃用、移除,每一版为什么变;
  5. 内部大致怎么工作:不需要读源码,但要能解释现象;
  6. 主要用在哪:真实场景 + 它在主流框架里扮演什么角色;
  7. 坑:现象 → 原因 → 修法;
  8. 现在该不该用:不用它用什么。

读法建议:先读 10.1(renderToString),它是本章的样板——后面每个 API 都按同样的层次拆,只是篇幅短一些。如果你只想知道「某个 API 还能不能用」,直接看每节最后的选型表和索引表。

10.0 先给 API 分层:它们解决的不是同一类问题

初学者最容易把「SSR 的 API」和「渲染的 API」混在一起背,结果记了一堆名字却不知道什么时候该用谁。先按它作用在哪一层分类,记忆量立刻减半:

层这一层要解决的问题代表 API一句话
交付层(服务端 → 浏览器)怎么把 React 树变成 HTML / 流,什么时候发出去renderToString、renderToStaticMarkup、renderToPipeableStream、renderToReadableStream、prerender决定「用户多久看到内容」
入口层(React 接管哪个 DOM)从哪开始渲染、是新建还是接管已有 HTMLReactDOM.render、createRoot、ReactDOM.hydrate、hydrateRoot决定「页面能不能点」
渲染控制层(这次更新急不急)谁先算、能不能中断、能不能丢弃startTransition、useTransition、useDeferredValue、flushSync、lazy + Suspense决定「卡不卡」
数据与状态层跨层传值、缓存、订阅、表单状态createContext、useContext、useSyncExternalStore、useActionState、useOptimistic决定「数据怎么流动」
边界与逃生舱跳出 React 树、拿到真实 DOM、读最新值createPortal、ref 家族、useInsertionEffect、useEffectEvent决定「怎么和 React 之外的世界打交道」
工程与诊断性能归因、稳定 ID、资源提示Profiler、useId、preload / preinit / preconnect / prefetchDNS、captureOwnerStack决定「怎么量、怎么调」

记住这张表,比记住 API 名字有用:遇到一个陌生 API,先问它在哪一层,再问它替代了谁。

10.0.1 索引表(随查随用)

API引入现状一句话定位详见
renderToString0.4(当时叫 renderComponentToString)仍在,但不再是首选同步把 React 树转成 HTML 字符串10.1
renderToStaticMarkup0.4仍在转成「纯静态」HTML,不能水合10.2
renderToNodeStream1618 弃用、19 移除传输层流式输出10.3
renderToPipeableStream18推荐(Node)按 Suspense 边界流式输出10.3
renderToReadableStream18推荐(Edge / Web Streams)同上,Web Streams 版本10.3
prerender / prerenderToNodeStream19新增等所有边界就绪,输出完整静态 HTML10.3
ReactDOM.render0.1419 移除legacy 同步入口10.4
createRoot18推荐并发入口10.4
ReactDOM.hydrate1619 移除旧版水合10.4
hydrateRoot18推荐并发水合10.4
createPortal16仍在DOM 换位置,React 树不变10.5
createContext / useContext16.3 / 16.8仍在跨层传值,绕开 SCU10.6
createRef / forwardRef16.3仍可用(19 非必需)把 ref 透传到内部节点10.7
useRef / useImperativeHandle16.8仍在保存可变值 / 暴露命令式接口10.7
lazy + Suspense16.6能力持续扩展代码分割与异步边界10.8
memo16.6仍在props 浅比较缓存10.9
useMemo / useCallback16.8仍在手动缓存值与函数10.9
startTransition / useTransition1819 支持异步标记「不紧急」的更新10.10
useDeferredValue18仍在让某个值慢半拍10.10
flushSync18仍在强制同步刷新10.10
useId18仍在SSR 稳定的唯一 ID10.11
useSyncExternalStore18仍在安全订阅外部 store10.11
useInsertionEffect18仍在布局前注入样式10.11
useActionState19新增(原名 useFormState)表单提交状态机10.12
useFormStatus19新增读取父级 form 的 pending10.12
useOptimistic19新增乐观更新 + 自动回滚10.12
use19新增渲染期读取 Promise / Context10.12
preload / preinit / prefetchDNS / preconnect19新增资源提示 API10.13
Profiler16.9仍在采集子树渲染耗时10.14
<Activity>19.2新增后台预渲染 / 隐藏页面保活10.15
useEffectEvent19.2新增把「事件逻辑」从依赖数组里拆出来10.15
captureOwnerStack19.1新增追溯「这个组件是谁渲染的」10.15
<ViewTransition> / addTransitionType19.3新增用 React 编排视图过渡动画10.15
browser()19.3新增声明「只在浏览器渲染」的子树10.15

本章代码默认按 React 19 写,涉及版本差异的地方会单独标注。文中 renderToString 的输出都是在 React 19.2 里实际跑出来的,不是手写示意。

10.1 ReactDOMServer.renderToString() 全解

10.1.1 一句话定位:它是「交付层」的起点

renderToString 只做一件事:在服务端把一棵 React 树同步地变成一段 HTML 字符串。

它不启动服务、不发请求、不管路由、不管数据;它就是一个纯函数式的转换器:进去是组件树,出来是字符串。

import { renderToString } from 'react-dom/server';

/** @type {string} */
const html = renderToString(<App />);

注意它所在的包是 react-dom/server,不是 react,也不是 react-dom。这个位置本身就说明了它的定位:它是「DOM 渲染器」的一个变体,把「操作 DOM」换成了「拼字符串」。

10.1.2 先看它到底产出什么

写一个带状态、带事件、带样式的组件,实际跑一遍:

import { useState } from 'react';

/**
 * 一个最小的待办项:有状态、有事件、有 className、有内联样式。
 * @returns {JSX.Element}
 */
function Todo() {
  const [done, setDone] = useState(false);

  return (
    <li className="todo" data-done={done} style={{ color: done ? 'gray' : 'black' }}>
      <span>买牛奶</span>
      <button onClick={() => setDone((v) => !v)}>完成</button>
    </li>
  );
}

服务端执行 renderToString(<Todo />),得到的是(React 19 实测):

<li class="todo" data-done="false" style="color:black"><span>买牛奶</span><button>完成</button></li>

把这行 HTML 和源码逐项对照,你会发现四件事,这四件事就是理解 SSR 的全部起点:

源码里的东西HTML 里变成什么为什么
className="todo"class="todo"React 的 prop 名和 HTML 属性名不是一套,序列化时要翻译
style={{ color: 'black' }}style="color:black"对象要拍平成 CSS 文本
onClick={...}消失了函数没法写进 HTML 字符串;事件只能等 JS 加载后由 React 绑
useState(false)只剩 data-done="false"state 只活在内存里,HTML 不保存它

第三条最关键:这段 HTML 是「照片」,不是「程序」。按钮长得像按钮,但没有行为。

<!-- 用户看到的是这个:能看,不能点 -->
<button>完成</button>

<!-- 要能点,必须在浏览器里再跑一次 React,把事件"焊"上去 -->
<!-- 这一步叫 hydration(水合) -->
// 客户端入口:把服务端发来的静态 HTML「接活」
import { hydrateRoot } from 'react-dom/client';

/** 容器必须是 DOM 元素(传选择器字符串会报错),且要与服务端渲染时的容器一致。 */
const container = document.getElementById('root');
hydrateRoot(container, <App />);

顺便看一个历史细节。同样的组件在 React 16 / 17 下输出的是:

<li data-reactroot="" class="todo">…</li>

那个 data-reactroot 是给旧版水合用的「暗号」,18 起不再输出(实测 18.2 / 19.2 都没有)。所以你在老项目 HTML 里看到 data-reactroot,说明它跑在 16/17 上——这是排查线上 React 版本的一个小技巧。

10.1.3 它解决的问题:2013 年的 Web 到底缺什么

要理解一个 API 为什么存在,得回到它出生的场景。React 2013 年开源时,口号是「MVC 里的 V」——只负责视图。而当时 Web 的主流是服务端渲染:PHP、JSP、Rails ERB 把 HTML 拼好发下来,浏览器拿到就能看。

问题是,用 JS 写视图带来了一个副作用:浏览器不执行 JS 就什么都看不到。

<!-- 纯客户端渲染(CSR)的页面源码 -->
<div id="root"></div>
<script src="/static/js/main.8f3a2c.js"></script>

这段 HTML 发到浏览器,用户会经历三段空白:

① 网络:下载 2MB 的 JS            → 白屏
② 解析执行:React 初始化、路由匹配  → 白屏
③ 首屏渲染:组件树算出来、DOM 插入  → 终于有内容

renderToString 要解决的就是这三段空白,具体拆成四个真实痛点:

痛点一:SEO。 2013 年的爬虫基本不执行 JS。一个电商详情页如果只有 <div id="root"></div>,在搜索引擎眼里就是一张白纸——商品标题、价格、评价全都索引不到。服务端先把内容渲染成 HTML,爬虫才能读到。

痛点二:首屏时间(FCP/LCP)。 用户不需要等 2MB JS 下载完才看到页面。HTML 一到就能画,JS 慢慢下载、慢慢水合。这在高延迟、低端机上差别是「1 秒」和「5 秒」的差别。

痛点三:分享与预览。 微信、Slack、Twitter 抓取链接时读的是 HTML 的 <meta> 和正文。CSR 页面分享出去往往是一张空白卡片——因为抓取器拿不到内容。

痛点四:同一套代码写两遍。 在它之前,服务端用模板引擎(PHP/JSP),客户端用 JS 管交互,同一个列表要写两套逻辑,改一处忘一处。

所以 renderToString 的价值可以浓缩成一句:用同一套组件代码,在服务端先渲染出「有内容的 HTML」,同时保留客户端接管的能力。 这就是「写一遍,跑两次」。

10.1.4 它从哪来:一个 2013 年就存在的 API

它比大多数人以为的古老得多:

时间事件说明
2013-06React 首次公开,React.renderComponentToString名字里的 Component 是当时 renderComponent 家族的一部分
2013 前后0.4 / 0.5服务端渲染已经是官方卖点之一,用来解决上面的四个痛点
0.12(2014-10)改名 React.renderToStringrenderComponent 统一简化为 render
0.14(2015-10)拆包:react / react-dom / react-dom/server视图层拆出 react-dom,服务端渲染独立成 ReactDOMServer
0.13–15(2015–2016)用 data-react-checksum 校验服务端 HTML校验和对不上就丢掉整段 HTML 重新渲染
16(2017-09)去掉校验和,改用 data-reactroot;新增 renderToNodeStream从「整段校验」变成「逐节点对齐」;服务端渲染从字符串扩展到流
16.6(2018-10)Suspense 出现但 renderToString 不支持等 Suspense 数据
18(2022-03)新流式渲染器;renderToString 保住但被「劝退」挂起时不再报错,改为输出 fallback + 客户端重试
19(2024-12)renderToNodeStream 移除;renderToString 仍在定位彻底变成「简单场景的兜底方案」

这里面有两个转折点值得讲清楚,因为它们解释了「为什么它还活着,但没人推荐」。

转折点一:18 之前,SSR 里用 Suspense 会直接报错。 16.6 引入 Suspense 后,很多人在组件里写「数据没好就挂起」,结果服务端渲染时收到一句:

ReactDOMServer does not yet support Suspense.

这意味着「用 Suspense 做数据获取」在 SSR 上完全走不通——你必须保证服务端渲染时所有数据都已就绪,否则整页 500。

转折点二:18 改了行为,但没给它流式能力。 React 18 的发布说明写得很直白:

renderToString will no longer error when suspending on the server. Instead, it will emit the fallback HTML for the closest <Suspense> boundary and then retry rendering the same content on the client.

翻译成人话:不再报错了,但也不等数据。它把 fallback 发下去,让浏览器先看到骨架,等客户端 JS 到了再重新渲染那块内容。你可以在 React 19 里实测到这段产物:

<!-- renderToString 遇到挂起组件时的真实输出(已省略报错详情) -->
<!--$!--><template data-msg="..."></template><i>loading</i><!--/$-->

<!--$!--> 和 <!--/$--> 是给客户端看的边界标记:「这段是服务端没搞定的,你接管重试」。所以 18 之后的 renderToString 是「能跑完,但内容可能是残缺的」。

同一次发布里,官方还加了一句很关键的定性:

The existing renderToString method keeps working but is discouraged.

「还能用,但不推荐」——这就是它今天的准确状态。

10.1.5 一次 SSR 请求的完整生命周期

光看 API 容易把它当成一个孤立函数。把它放回真实链路里,你才能理解为什么「同步」是个问题。

浏览器                      Node 服务                       数据库 / 下游
   |  GET /                    |                                |
   |-------------------------> |                                |
   |                           | ① 路由匹配                      |
   |                           | ② 预取数据 ------------------> |
   |                           | <------------------------------ |
   |                           | ③ renderToString(<App/>)        |
   |                           |    └─ 同步遍历整棵树(阻塞)      |
   |                           | ④ 拼 HTML 文档 + 序列化数据       |
   | <-------------------------|                                |
   | ⑤ 浏览器解析 HTML,立刻绘制(FCP)                            |
   | ⑥ 下载 main.js,执行                                  |
   | ⑦ hydrateRoot 认领 DOM,绑事件(此时才可交互)             |

对应的服务端代码:

// server.js
import express from 'express';
import { renderToString } from 'react-dom/server';
import App from './App.jsx';

const app = express();
app.use('/static', express.static('dist'));

app.get('/', async (req, res) => {
  // ① 数据必须先就绪:renderToString 不会替你等
  const user = await getUser(req);

  // ② 同步渲染,得到「有内容」的 HTML
  const html = renderToString(<App user={user} />);

  // ③ 把首屏数据塞进页面,避免客户端重复请求
  const initialState = JSON.stringify({ user })
    // 关键:防止数据里的 </script> 提前闭合标签(XSS)
    .replace(/</g, '\\u003c');

  // ④ 拼完整文档:renderToString 只给片段,不给 <html>
  res.send(`<!doctype html>
<html lang="zh-CN">
  <head><meta charset="utf-8"><title>我的应用</title></head>
  <body>
    <div id="root">${html}</div>
    <script>window.__INITIAL_STATE__ = ${initialState};</script>
    <script src="/static/main.js" defer></script>
  </body>
</html>`);
});

这条链路里有三个「必须自己扛」的责任,框架帮你做了,所以很多人没意识到:

  1. 数据预取:renderToString 不等任何异步数据,读不到就是 undefined 或骨架;
  2. 文档拼接:它只输出组件对应的片段,<!doctype>、<html>、<head> 全归你;
  3. 状态传递:服务端算出来的数据不会自动带到客户端,必须自己序列化。

TTFB 是怎么被拖慢的:

TTFB = 路由匹配 + 数据请求 + renderToString + 网络
                        ↑
              页面越大越慢,且期间 Node 主线程被占住

这就是为什么「高并发 + 大页面」必须上流式:renderToString 是同步的,一个请求渲染 200ms,单进程就少掉 200ms 的处理能力,QPS 直接掉一个量级。

10.1.6 内部大致怎么工作:五步,解释所有现象

不用读源码,但要知道它内部发生了什么。可以想象成一个「没有 DOM 的渲染器」:

① 建一棵服务端专用的 Fiber 树
     宿主环境的「创建元素」被换成了「输出字符串」
     —— 没有 document,没有真实节点

② 深度优先同步遍历整棵树
     遇到函数组件 → 调用它(Hooks 按顺序执行)
     遇到类组件   → 走 render / 构造
     遇到宿主元素 → 输出 <tag ...>

③ 序列化 props → HTML 属性
     className → class,htmlFor → for,tabIndex → tabindex
     事件 props(onClick 等)→ 直接丢弃
     style 对象 → "color:black;font-size:12px"
     布尔属性 → true 输出 name="",false 直接不输出

④ 转义文本与属性
     & → &amp;,< → &lt;,> → &gt;,引号 → &quot;
     —— 这是 renderToString 自带的一层 XSS 防护

⑤ 拼接字符串返回
     相邻的两个文本节点之间插入 <!-- -->(水合边界)
     Suspense 边界插入 <!--$--> / <!--$!--> / <!--/$--> 等标记

四个现象,全部能从这五步推出来:

现象一:为什么它是同步的、会阻塞? 第②步是普通的递归遍历,中间没有 await、没有让出事件循环的机会。页面多大,就占多久。

现象二:为什么它不能中断、不能等数据? 因为它要返回一个完整字符串。字符串不能「先返回一半」,所以只能一次算完;遇到挂起只能降级成 fallback。

现象三:为什么 HTML 里有 <!-- -->? 实测一下:

<p>a{'b'}<b>x</b>c</p>
// renderToString → <p>a<!-- -->b<b>x</b>c</p>

浏览器会把 a 和 b 合并成一个文本节点,React 水合时就分不清「这里原本是两个文本」。插入一个空注释当分隔符,相当于给水合留了个「刻度线」。renderToStaticMarkup 没有这个需求,所以输出是 <p>ab<b>x</b>c</p>。

现象四:为什么 window 会报错? 服务端没有 DOM,也没有 window / document / localStorage。组件函数体在 Node 里执行,访问这些全局量就是 ReferenceError: window is not defined。

10.1.7 参数、返回值与几个容易忽略的细节

renderToString(
  element: ReactNode,
  options?: { identifierPrefix?: string }
): string
项说明容易踩的点
elementReact 元素,如 <App />传组件函数 App 会报「Element type is invalid」
identifierPrefix给 useId 生成的 ID 加前缀一个页面挂多个 React 实例(微前端)时必须区分
返回值HTML 片段字符串不含 <!doctype>、<html>、<head>
副作用不执行 useEffect、不调 ref、不改 state不要在组件体里放「只在浏览器做」的逻辑

关于 useId,19 有一个小变化值得知道:ID 格式从带冒号的 :r0: 改成了下划线形式的 _R_0_(实测输出)。原因是冒号在 CSS 选择器和 querySelector 里要转义,给使用者添麻烦。

// 实测:React 19 的 useId 输出
<div id="_R_0_"></div>

还要记住一个「不返回」:它不返回状态。服务端的 useState 只是初始值,客户端水合时组件会重新执行一遍、重新算出初始值。所以:

// 服务端算出 10 条数据,客户端不会自动拥有
function List({ items }) { ... }   // ✅ props 从服务端传下来
function List() { const [items] = useState(fetchSync()); ... } // ❌ 客户端会重新算,且可能算不出

10.1.8 主要用在哪:五个真实场景

场景具体做法为什么合适代价
传统 SSR(Express / Koa 自建)请求进来 → 取数 → renderToString → 拼文档架构最简单,调试直观,一个函数就能懂同步阻塞,流量大要加机器
静态站点生成(SSG)构建时跑一遍,把 HTML 写成文件零运行时、可直接上 CDN、天然抗流量内容更新要重新构建
邮件 / 报表 / PDF 的 HTML用 React 组件写模板,服务端渲染成字符串复用组件与样式能力,比手拼字符串可维护不能用交互,样式受邮件客户端限制
单元测试 / 快照测试expect(renderToString(<Row/>)).toMatchSnapshot()不需要 DOM 环境,比 @testing-library 更快测不到交互与副作用
服务端拼页面片段(微前端)各子应用各出一段 HTML,主服务拼装各团队技术栈独立,服务端聚合首屏片段间样式/状态隔离要自己设计

再补一个「它在大框架里的位置」,这能帮你把知识接到现实:

  • Next.js 的 Pages Router(12 及更早):服务端渲染的核心就是「取数 + renderToString + 拼 _document」,你写的 getServerSideProps 就是那个「取数」步骤;
  • Gatsby / Astro 的静态输出:构建期渲染成 HTML 文件,本质是同一件事;
  • React Email:把邮件模板写成组件,服务端渲染成 HTML 字符串发出去;
  • 各类「SSR 原理课」:几乎都是用 30 行 Express + renderToString 手写一遍,因为它把 SSR 的最小闭环暴露得最清楚。

反过来,三种情况不要用它:

情况为什么不行用什么
高并发动态页(每个用户内容不同)同步渲染阻塞事件循环,QPS 随页面复杂度线性下降renderToPipeableStream
首屏依赖慢接口它不等数据,HTML 里只有骨架,用户看到的和 CSR 差不多renderToPipeableStream / prerender
纯后台 SPA没有 SEO 需求,首屏也不敏感,SSR 只增加运维成本纯 CSR(createRoot)

10.1.9 和它长得像的几个 API:差在哪

API输出能水合吗会等数据吗典型用途
renderToStringHTML 字符串能不等(输出 fallback)简单 SSR、SSG、测试
renderToStaticMarkupHTML 字符串不能不等邮件、PDF、静态片段
renderToPipeableStreamNode 可写流能按边界等面向用户的动态页
renderToReadableStreamWeb ReadableStream能按边界等Edge / Deno / 云函数
prerender完整 HTML能全等SSG、爬虫、CDN 缓存

同一个组件、四个 API 的实测输出差异(React 19):

function B() {
  return (
    <Suspense fallback={<i>loading</i>}>
      <Slow />   {/* 内部 throw 一个未完成的 Promise */}
    </Suspense>
  );
}
API产物
renderToString<!--$!--><template data-msg="…不支持 Suspense…"></template><i>loading</i><!--/$-->
renderToStaticMarkup<i>loading</i>
renderToPipeableStream先发壳,Slow 好了以后用 <script> 把 loading 替换成真实内容
prerender等 Slow 完成,直接输出 loading 被替换后的完整 HTML

这张表值得记:「等不等数据」和「是不是流式」是两个独立维度。prerender 不流式,但它等所有数据;renderToNodeStream 流式,但它不等数据。

10.1.10 九个高频坑:现象 → 原因 → 修法

#现象原因修法
1页面能看,点了没反应客户端没执行 hydrateRoot,或 bundle 404查 Network 里 main.js 是否 200;查入口是否在 #root 上水合
2控制台刷「Hydration failed」服务端与客户端首帧不一致见下面第 2 条展开
3window is not defined组件体里直接访问浏览器全局移到 useEffect,或用 typeof window !== 'undefined' 守卫
4首屏闪一下、内容重排水合失败后 React 丢弃服务端 HTML 重建定位不一致来源(时间/随机数/登录态),不要靠 suppressHydrationWarning 掩盖
5数据要请求两次忘了把服务端数据序列化进 HTML用 __INITIAL_STATE__ 或框架的 loader 传下去
6XSS(页面被注入脚本)JSON.stringify 结果直接内联,数据里有 </script>至少把 < 转成 \u003c;富文本必须走白名单净化
7大页面 TTFB 几百毫秒、QPS 掉同步渲染阻塞事件循环换流式;或把重组件 lazy 掉、把重计算移到构建期
8服务端渲染出的骨架一直不消失Suspense 边界在服务端挂起,客户端又没接管用 renderToPipeableStream,或确保数据在渲染前就绪
9多实例页面 useId 冲突多个 React root 各自从 0 开始编号给每个实例传不同的 identifierPrefix

第 2 条(水合不匹配)值得单独展开,因为它是 SSR 最高频的线上问题:

// ❌ 服务端和客户端算出来必然不一样
function Header() {
  return <span>{new Date().toLocaleString()}</span>;
}

// ✅ 把「只在浏览器成立」的东西放到 effect 里
function Header() {
  const [now, setNow] = useState(null);
  useEffect(() => setNow(new Date().toLocaleString()), []);
  return <span>{now ?? '--'}</span>;
}

同类来源还有:Math.random()、window.innerWidth、localStorage 里的主题、navigator.language、登录态分支。判断标准只有一条:这段输出在 Node 和浏览器里会不会算出不同结果。会,就必须推迟到 useEffect 或改用 useSyncExternalStore 的 getServerSnapshot。

10.1.11 选型决策:三句话

① 一次成型、数据已就绪、不需要流式   → renderToString
② 慢数据、首屏要快、用户面向 C 端      → renderToPipeableStream
③ 完全静态、可以构建期生成            → prerender(或 SSG 框架)

再加一句判断:如果你正在手写 renderToString 且页面有异步数据,先别优化写法,先考虑换流式 API——因为你已经站在了它的能力边界上。

10.1.12 自测:三个问题

  1. 为什么 renderToString 的输出里没有 onClick?如果一定要让按钮能点,缺的是哪一步?
  2. 为什么它遇到 Suspense 只输出 fallback,而 renderToPipeableStream 能等到数据?差别在「返回值形态」还是「调度能力」?
  3. 线上报「Hydration failed」,你会按什么顺序排查?至少说出三个常见来源和对应修法。

相关阅读:流式三阶段与回调见 2.7.5;Hydration 为什么必须一致见 3.6;hydrateRoot 与 createRoot 的差异见 10.4。

10.2 renderToStaticMarkup()

10.2.1 它是什么:一句话的差别,决定一个能力的有无

它和 renderToString 的代码形状完全一样,只差一件事:不生成任何给 React 水合用的内部标记。

import { renderToString, renderToStaticMarkup } from 'react-dom/server';

renderToString(<App />);        // '<div data-reactroot="">…</div>'  (16/17)
renderToStaticMarkup(<App />);  // '<div>…</div>'

「内部标记」是客户端 React 用来对齐的暗号。没有暗号,HTML 依然能显示,但客户端无法「认领」它——只能推倒重建,等于白做 SSR。

实测对照(React 19,同一棵树):

<p>a{'b'}<b>x</b>c</p>

renderToString(<A />)        // <p>a<!-- -->b<b>x</b>c</p>   ← 有分隔标记
renderToStaticMarkup(<A />)  // <p>ab<b>x</b>c</p>           ← 没有
<Suspense fallback={<i>loading</i>}><Slow /></Suspense>

renderToString(<B />)        // <!--$!--><template …></template><i>loading</i><!--/$-->
renderToStaticMarkup(<B />)  // <i>loading</i>

所以两者的差别不只是「几个属性」,而是产物里有没有「给 React 自己看的信息」。

10.2.2 它从哪来:有些 HTML 天生不需要交互

它和 renderToString 同龄(0.4 就有,0.14 起在 react-dom/server)。存在的理由非常朴素:不是所有 HTML 都需要变成应用。

场景为什么它更合适
邮件模板邮件客户端普遍不执行 JS,交互不存在,标记纯属浪费字节
PDF / 图片 / 海报导出只需要「画好的一帧」,导出后再也不会被 React 接管
静态内容块、帮助文档生成一次、CDN 缓存,客户端不需要 hydrate
测试快照断言结构时,不希望被 <!-- -->、data-reactroot 干扰

一个容易忽略的点:体积收益其实很小。少的是几个属性和注释,压缩后往往只有百分之几。它真正省下的是客户端的水合成本——你本来就不打算水合,那连 hydrateRoot 都不用调。

10.2.3 内部怎么工作:少了一个「记账」步骤

renderToString 在遍历时要做两件额外的事:

  1. 给每个可能参与水合的边界打标记(相邻文本分隔、Suspense 边界、根节点);
  2. 维护一张「这棵树长什么样」的隐式账本,供客户端对齐。

renderToStaticMarkup 关掉这两件事,只做「遍历 + 拼字符串」。所以它:

  • 产物更小、更干净;
  • 不能水合——硬要 hydrateRoot,React 找不到匹配结构,会整树重建或直接报错;
  • 渲染耗时几乎一样(省的是标记,不是计算)。

10.2.4 版本

版本变化
0.4 起与 renderToString 同时存在
0.14迁到 react-dom/server,命名 ReactDOMServer.renderToStaticMarkup
16–19行为稳定,没有破坏性变化
19仍是 react-dom/server 的导出之一(与 renderToString 并列)

10.2.5 坑

现象原因修法
页面显示正常但点击无效,且控制台报水合错误服务端用了静态渲染,客户端却 hydrateRoot二者必须配套:静态就用 createRoot 重建,或服务端换 renderToString
邮件里样式丢失邮件客户端会剥离 <style>、不支持部分 CSS用内联样式;用表格布局;测主流客户端
以为它更快渲染耗时几乎一致性能收益来自「不水合」,不是来自渲染本身
富文本被当静态 HTML 输出内容来自用户输入必须做 HTML 净化,静态渲染不做额外保护

10.2.6 选型

「这段 HTML 一辈子不需要变成应用」→ renderToStaticMarkup;「需要用户点、需要状态」→ renderToString 起步,动态页再上流式。

10.3 流式四兄弟:renderToNodeStream / renderToPipeableStream / renderToReadableStream / prerender

这四个 API 常被笼统地叫「流式渲染」,但它们其实回答了两个不同的问题:

  1. 什么时候把第一块字节发出去?(传输层)
  2. 慢的那部分内容,能不能晚点补上?(数据层)

先记住这个二维坐标,后面每个 API 都能对上号:

                   不等数据               等数据
                ┌────────────────────┬──────────────────────┐
   整段字符串    │ renderToString      │ prerender             │
                ├────────────────────┼──────────────────────┤
   流            │ renderToNodeStream  │ renderToPipeableStream │
                │ (16 时代)          │ renderToReadableStream │
                └────────────────────┴──────────────────────┘

「不等数据」不等于「不渲染」:renderToString 遇到挂起会输出 fallback;renderToNodeStream 遇到慢数据会卡在那一块,等它算完再继续写。

10.3.1 为什么需要「流」:TTFB 到底被谁拖住了

用 renderToString 时,服务端的动作是串行的:

请求到达
  → 取数据(数据库 50ms + 下游接口 80ms)
  → renderToString(整棵树 60ms)
  → 拼文档
  → 第一次写出响应            ← TTFB = 190ms
  → 浏览器开始解析、绘制

哪怕页面顶部(导航、标题)在 10ms 时就能算出来,用户也要等 190ms 才收到第一个字节。「壳」(shell)的概念就是从这里来的:把页面分成两部分——

  • 壳:不依赖慢数据的那部分(布局、导航、标题、骨架);
  • 边界:依赖慢数据的部分(推荐列表、评论区、个性化模块)。

流式渲染的目标:壳一好就发,边界好了再补。

10.3.2 ① renderToNodeStream(16 引入,18 弃用,19 移除)

import { renderToNodeStream } from 'react-dom/server';

const stream = renderToNodeStream(<App />);
stream.pipe(res);
stream.on('error', () => res.destroy());

它解决的问题(16 时代):不必等整棵树渲染完才开始传输。React 16 的新服务端渲染器边遍历边往流里写字节,所以首字节可以更早到达,浏览器能更早开始解析 HTML、更早发现 <link> / <script> 并开始下载。

它没解决的问题:

  • 不认 Suspense 边界:16/17 的流只是「字节分块」,慢数据照样卡住整棵树;
  • 出错只能断连接:流已经开始,HTTP 状态码已经发出去了,没法改成 500,只能 res.destroy();
  • 没有「补发」能力:发出去的 fallback 无法在服务端替换成真内容。

18 之后它名存实亡。 React 18 的官方文档写得很明确:

As of React 18, this method buffers all of its output, so it doesn't actually provide any streaming benefits.

也就是说,18 里它虽然还叫 NodeStream,内部已经把输出全部缓冲再给出——因为 18 的服务端渲染器换成了新的,旧的流式接口只是个兼容壳。18.0 的 changelog 里也写了 Deprecate renderToNodeStream。

19 直接删除:react-dom/server 的导出里不再有 renderToNodeStream,renderToStaticNodeStream 也一起删了(实测 19.2 的 react-dom/server 只剩 renderToString、renderToStaticMarkup、renderToPipeableStream、renderToReadableStream、resume、resumeToPipeableStream)。

// 升级到 19 后,这行会直接报错:does not provide an export named 'renderToNodeStream'
import { renderToNodeStream } from 'react-dom/server';

迁移:换成 renderToPipeableStream(Node)或 renderToReadableStream(Web Streams)。注意这不是「改个名字」——回调模型完全不同,错误处理要重写。

10.3.3 ② renderToPipeableStream(18,Node 端的推荐方案)

它把「流」从传输层提升到了边界层:每个 <Suspense> 是可以独立补发的块。

import { renderToPipeableStream } from 'react-dom/server';

/**
 * 流式 SSR 的请求处理:壳好了先发,Suspense 边界好了再补。
 * 注意所有响应头/状态码都在 onShellReady 里定,pipe 之后就不能改了。
 */
app.get('/', (req, res) => {
  const { pipe, abort } = renderToPipeableStream(<App />, {
    // 浏览器要加载的入口脚本:React 会自动插入 <script> 并做 preload
    bootstrapScripts: ['/static/main.js'],

    onShellReady() {
      // 壳(边界之外)渲染完:此刻才能确定状态码,所以状态码写在这里
      res.statusCode = 200;
      res.setHeader('Content-Type', 'text/html; charset=utf-8');
      pipe(res);
    },

    onShellError(error) {
      // 壳自己就挂了:还什么都没发出去,可以体面地返回 500 页面
      res.statusCode = 500;
      res.end('服务暂时不可用');
    },

    onError(error) {
      // 某个边界内部出错:已经发出去的内容收不回,只能让该边界降级
      console.error(error);
    },
  });

  // 兜底:10 秒还没好就放弃未完成的边界(已发内容保留)
  setTimeout(abort, 10_000);
});

它到底往浏览器发了什么? 实跑一遍最有说服力。下面是一个 <Suspense> 包着慢组件的页面,壳就绪时收到的第一块:

<link rel="preload" as="script" fetchPriority="low" href="/main.js"/>
<div id="app">
  <h1>标题</h1>
  <!--$?--><template id="B:0"></template><i>loading</i><!--/$-->
</div>
<script>requestAnimationFrame(function(){$RT=performance.now()});</script>
<script src="/main.js" id="_R_" async=""></script>

慢数据好了之后,同一个响应里追加:

<div hidden id="S:0"><b>真实内容</b></div>
<script>
  $RC("B:0","S:0")   // React 的内联运行时:把 S:0 的内容搬进 B:0 的位置
</script>

看懂这两块,流式 SSR 就通了:

片段作用
<!--$?--> … <!--/$-->Suspense 边界标记,? 表示「还没完成」
<template id="B:0">占位锚点,告诉后续脚本「内容要插到这」
<i>loading</i>服务端先给出的 fallback
<div hidden id="S:0">后补的真实内容,先隐藏
$RC("B:0","S:0")把 S:0 搬到 B:0,并触发这段的水合

两个「就绪」回调,选错就是两套性能:

回调时机选它的后果适合
onShellReady边界之外渲染完TTFB 最优,HTML 里可能还是骨架面向用户的动态页
onAllReady所有边界完成HTML 完整,但 TTFB 退化到接近 renderToString爬虫、需要完整 HTML 的场景

真实项目里常见的做法是按 UA 分流:普通用户走 onShellReady,搜索引擎爬虫走 onAllReady。

const isBot = /Googlebot|Bingbot|baiduspider/i.test(req.headers['user-agent'] ?? '');

错误处理的三层结构(这是它比旧 API 强的地方):

onShellError  壳就挂了 → 还没发任何字节 → 换 500 页面
onError       边界出错 → 已发内容保留   → 该边界降级为 fallback(或错误 UI)
abort()       超时/主动放弃 → 未完成的边界不再等待,已发内容保留

版本:18.0 引入;19 仍是 Node 端推荐;19.2 增加了配套的 resumeToPipeableStream(用于 Partial Prerendering:静态壳 + 动态恢复)。

坑:

现象原因修法
明明用了流式,TTFB 还是很高前面有 nginx / CDN / gzip 中间件在缓冲关掉响应缓冲(proxy_buffering off)、用流式压缩
状态码改不了在 pipe 之后才设 statusCode所有状态码/响应头都在 onShellReady 里设
慢边界一直不出现abort 的时机太早,或数据 Promise 永远不 resolve给数据请求加超时;abort 兜底并在客户端重试
浏览器控制台报 CSP 错误流式依赖内联 <script>给 nonce 配置 CSP(React 会把 nonce 加到内联脚本上)
爬虫抓到的页面是骨架用了 onShellReady按 UA 走 onAllReady,或对爬虫单独做静态渲染
自定义 pipe 目标忘记处理背压直接 res.write 不理会 drain用 pipe,不要手写循环

10.3.4 ③ renderToReadableStream(18,Edge / Web Streams)

同一套模型,适配 Web Streams:没有 Node 的 pipe,而是返回一个 ReadableStream,可以直接塞进 Response。

import { renderToReadableStream } from 'react-dom/server';

/**
 * Edge Runtime(Cloudflare Workers / Deno / Vercel Edge)里的流式 SSR。
 * 注意:它返回 Promise,await 之后才拿到「壳已就绪」的流。
 */
export default async function handler(request) {
  const stream = await renderToReadableStream(<App />, {
    bootstrapScripts: ['/static/main.js'],
    onError(error) {
      console.error(error);
    },
  });

  return new Response(stream, {
    status: 200,
    headers: { 'Content-Type': 'text/html; charset=utf-8' },
  });
}

和 renderToPipeableStream 的三个差异:

维度renderToPipeableStreamrenderToReadableStream
返回值{ pipe, abort } 对象Promise<ReadableStream>
何时算「壳就绪」onShellReady 回调await 完成
错误兜底onShellError 换页面await 抛错 → 自己 try/catch 返回 500

Edge 的额外约束(踩过才知道):

  • 响应头必须一次性给定:不能像 Node 那样「先 pipe 再改状态码」,因为 Response 构造时头就固定了;
  • 不能依赖 Node API:stream.pipe、fs、Buffer 都没有,用 Web 标准;
  • CPU 时间有限制:Edge 函数通常有 10–50ms 的 CPU 预算,重 SSR 会被截断,复杂页面仍应放 Node。

10.3.5 ④ prerender / prerenderToNodeStream(19,静态输出)

import { prerenderToNodeStream } from 'react-dom/static';

/**
 * 等所有 Suspense 边界就绪,再输出「完整」HTML。
 * 返回 { prelude, postponed }:postponed 供 PPR 的 resume 使用。
 */
const { prelude, postponed } = await prerenderToNodeStream(<App />);
prelude.pipe(res);

它和流式是相反的策略:不追求 TTFB,追求「一次性给出完整 HTML」。适合:

场景为什么
SSG(构建期生成静态页)产物完整、可 CDN 缓存、零运行时
爬虫/分享卡片抓取器拿到的是最终内容,不是骨架
Partial Prerendering(19.2)先 prerender 出静态壳,请求时用 resume 只补动态边界

它的返回值里有个 postponed(19.2 起),这是 PPR 的关键:静态壳里留了「洞」,resume 时只渲染这些洞。这也是 Next.js 的 PPR 在底层用的能力。

10.3.6 五个 API 的选型表

API运行环境首字节时机等 Suspense出错兜底版本
renderToStringNode / 任意 JS整棵树算完否(发 fallback)整次失败0.4
renderToStaticMarkupNode / 任意 JS整棵树算完否整次失败0.4
renderToNodeStreamNode边算边发否断连接16 → 19 移除
renderToPipeableStreamNode壳就绪是onShellError / onError / abort18
renderToReadableStreamEdge / Web壳就绪是Promise reject18
prerender / prerenderToNodeStreamNode / Web全部就绪是Promise reject19

一句话记:16 流的是字节,18 流的是边界,19 补了一个「等全部」的静态模式。

10.3.7 流式的三个常见误解

误解事实
「用了流式,页面就一定更快」流式优化的是 TTFB 与首屏可见,不减少总计算量;如果瓶颈在数据库,先治数据
「流式取代了 SSR 的其它部分」数据预取、缓存、状态序列化、水合一致性,一个都不能少
「流式 = 渐进式水合」流式解决「内容何时到」,选择性水合解决「哪块先可交互」,两者常配合但不是一件事

10.4 入口四兄弟:ReactDOM.render / createRoot / hydrate / hydrateRoot

这四个 API 决定「React 从哪个容器开始接管页面」,也是升级时改动最集中的地方。它们的分歧只有一个:是「新建一棵树」,还是「认领已有的 HTML」;以及用 legacy 模式还是并发模式。

维度ReactDOM.rendercreateRootReactDOM.hydratehydrateRoot
首次渲染新建 DOM新建 DOM复用服务端 HTML复用服务端 HTML
渲染模式legacy(同步)并发legacy(同步)并发
并发特性不可用可用不可用可用
状态19 移除推荐19 移除推荐

10.4.1 什么是「root」:一个容易被忽略的概念

root 不是一个 DOM 节点,而是 React 内部的一棵渲染树实例。它记着:

  • 这个容器里有哪些 Fiber 节点;
  • 事件监听绑在哪(React 17 起绑在根容器上);
  • 当前的更新队列、优先级、错误处理回调。

所以「一个容器只能有一个 root」不是限制,而是定义:两个 root 抢同一个容器,等于两套树往同一个 DOM 上写,必然互相覆盖。

10.4.2 ① ReactDOM.render(0.14 → 19 移除)

import ReactDOM from 'react-dom';

/**
 * legacy 入口:同步渲染,调用后立刻算完整棵树并提交。
 */
ReactDOM.render(<App />, document.getElementById('root'));

它从 0.14 一直活到 18,是 2015–2021 年所有 React 项目的标准入口。它的行为特征:

  • 同步:render 返回时,DOM 已经更新完;
  • 不可中断:一次更新从头算到尾,中间插入的更高优先级更新只能排队;
  • legacy 批处理:只有 React 事件处理器里的更新会自动批处理,setTimeout / Promise 里的不会;
  • 没有选择性水合:SSR 页面必须整棵树水合完才能交互。

18 起调用它会打印弃用警告(行为仍按 legacy 模式跑):

Warning: ReactDOM.render is no longer supported in React 18.
Use createRoot instead.

19 直接从 react-dom 移除,升级后遇到的不是警告而是直接失败:

TypeError: ReactDOM.render is not a function
// 或在 ESM 下:The requested module 'react-dom' does not provide an export named 'render'

为什么一定要换掉:并发能力(Transition、Suspense 数据、选择性水合、自动批处理)全部依赖新的 root 实现。留在 legacy 入口,等于把 18/19 的核心能力全部关掉。

10.4.3 ② createRoot(18,推荐)

import { createRoot } from 'react-dom/client';

/**
 * 并发入口:同一个容器只创建一次,后续更新复用这个 root。
 * @param {HTMLElement} container
 */
const root = createRoot(document.getElementById('root'), {
  // 可恢复错误:水合不匹配、渲染中抛错后被兜底
  onRecoverableError(error, info) {
    report(error, { componentStack: info.componentStack });
  },
  // 19 起还可分别处理「被 Error Boundary 捕获」和「没人接住」的错误
  onCaughtError(error, info) {
    report(error, { componentStack: info.componentStack });
  },
  onUncaughtError(error, info) {
    reportFatal(error, { componentStack: info.componentStack });
  },
});

root.render(<App />);    // 首次挂载
// root.render(<Next />); // 后续更新:替换根内容(不是叠加)
// root.unmount();        // 卸载整个应用,之后这个 root 不能再用

三个必须记住的规则:

规则违反后的现象
一个容器只能有一个 root重复 createRoot 会得到两套事件系统,表现为「点一次触发两次」或状态错乱
render 可以多次调用它是「换内容」,不是「再挂一个」
unmount() 之后不能复用root 已销毁,要重新 createRoot

为什么它比 render 强:

  • 默认启用并发渲染:startTransition、useDeferredValue 才真正生效;
  • 自动批处理覆盖所有场景(包括 setTimeout、Promise、原生事件);
  • 支持选择性水合(配合 hydrateRoot);
  • 错误边界信息更完整(onCaughtError / onUncaughtError)。

10.4.4 ③ ReactDOM.hydrate(16 → 19 移除)

// 服务端已经输出了 HTML,客户端「接活」
ReactDOM.hydrate(<App />, document.getElementById('root'));

它的关键差异只有一个:不创建 DOM,而是复用服务端已有的节点并绑上事件。

这里有一段值得知道的历史,能解释「为什么水合这么挑剔」:

版本水合怎么判断「这份 HTML 是我渲染的」
15 及以前服务端在根节点写 data-react-checksum(adler32 校验和),客户端重算并比对,不一致就丢掉整段 HTML 重新渲染
16去掉校验和,改用 data-reactroot + 逐节点比对;不匹配时打 warning 并保留服务端 HTML 修补
18标记改为注释节点(Suspense 边界),不匹配时客户端重渲染该子树,并可通过 onRecoverableError 上报
19hydrate 移除,统一用 hydrateRoot;错误信息会给出属性 diff,定位快很多

所以「首帧必须一致」不是 React 苛刻,而是水合的本质就是复用:复用要求两边对得上,对不上就只能重建。

10.4.5 ④ hydrateRoot(18,推荐)

import { hydrateRoot } from 'react-dom/client';

/**
 * 并发水合:复用服务端 HTML,同时启用并发能力。
 * 第三个参数与 createRoot 相同,可配置错误回调。
 */
hydrateRoot(document.getElementById('root'), <App />, {
  onRecoverableError(error, info) {
    // 水合不匹配会走到这里:说明服务端与客户端首帧不一致
    report(error, { componentStack: info.componentStack });
  },
});

它内部做的四步(理解这四步,就能理解所有水合报错):

① 拿到容器里已有的 DOM 节点
② 按 Fiber 树逐个「认领」这些节点(不是创建,是对齐)
③ 绑上事件监听与 ref
④ 恢复状态(useState 的初始值由客户端组件自己算,服务端状态不保留)

第②步失败的典型原因:时间戳、随机数、window 相关分支、登录态、Intl 格式化差异、浏览器自动补全改了 input 的值。

19 的水合报错长什么样(比以前友好很多):

Hydration failed because the server rendered HTML didn't match the client.

  <div className="user">
-   <span>2026-11-02 15:27:00</span>
+   <span>2026-11-02 15:27:03</span>
  </div>

看到 diff 就知道该把哪段输出推迟到 useEffect。

选择性水合(18+):流式 SSR 下,每个 Suspense 边界可以独立水合。用户点了某个还没水合的区域,React 会优先水合那一块(因为事件被捕获了),而不是从头按顺序水合。这就是「点了立刻有反应」在流式页面上的实现方式。

10.4.6 迁移映射表

旧写法(16/17)新写法(18+)备注
ReactDOM.render(<App />, el)createRoot(el).render(<App />)root 只创建一次
ReactDOM.hydrate(<App />, el)hydrateRoot(el, <App />)需要 SSR 场景
ReactDOM.unmountComponentAtNode(el)root.unmount()19 移除旧 API
ReactDOM.render 第三个参数(回调)不需要提交后逻辑用 useEffect
ReactDOM.findDOMNode(this)ref19 移除

10.4.7 坑

现象原因修法
白屏 + 控制台提示「root 已存在」迁移到一半,render 与 createRoot 同时作用于一个容器全局搜 ReactDOM.render,统一入口
应用不报错但白屏容器是 null(脚本早于 DOM 执行)把脚本放 defer,或等 DOMContentLoaded
水合后整页闪烁容器里塞了 loading 文案,结构对不上容器内容必须与服务端输出完全一致
水合报 mismatch,但代码看起来一样浏览器扩展/自动填充改了 DOM(如密码框)用 suppressHydrationWarning(仅限确定安全处),或改 defaultValue
以为 hydrateRoot 会「补上」缺失的 HTML它只认领已有节点缺内容就是 mismatch,必须服务端渲染出来
开发环境 effect 跑两次StrictMode 刻意行为用幂等 + AbortController 修,不要关 StrictMode

10.5 createPortal()

10.5.1 它是什么:把「渲染位置」和「DOM 位置」拆开

createPortal(children, domNode, key?): ReactPortal

它把 children 渲染到另一个 DOM 节点下,但这段内容在 React 树里的位置不变。

import { createPortal } from 'react-dom';

/**
 * 弹窗:DOM 挂到 body 下,避免被父级的 overflow / transform 裁剪。
 * 逻辑上仍在原来的 React 树中:context 能读到,事件按 React 树冒泡。
 * @param {{ open: boolean, onClose: () => void, children: React.ReactNode }} props
 */
function Modal({ open, onClose, children }) {
  if (!open) return null;

  return createPortal(
    <div className="modal-mask" onClick={onClose}>
      <div
        className="modal-body"
        onClick={(e) => e.stopPropagation()}
      >
        {children}
      </div>
    </div>,
    document.body
  );
}

注意两个参数的分工:children 是要渲染的内容,domNode 是真实 DOM 节点。容易写错的地方是把组件本身传进去(createPortal(Modal, node)),那是错的。

10.5.2 它从哪来:DOM 位置和组件树位置被迫绑死

16 之前,做弹层只有两条路,都很难受:

路线一:直接操作 DOM

// 手动 appendChild,自己管生命周期
const node = document.createElement('div');
document.body.appendChild(node);
ReactDOM.render(<Modal />, node);

// 卸载时还得记得清理
ReactDOM.unmountComponentAtNode(node);
node.remove();

痛点:

  • context 断了:这是另一棵 React 树,读不到外层的 Provider;
  • 事件不冒泡到原树:祖先组件上的 onClick、onKeyDown 收不到;
  • 生命周期要手动管:忘记 unmount 就是内存泄漏。

路线二:unstable_renderSubtreeIntoContainer

// 把子树渲染到别处,但保留「容器组件」的 context
this.node = document.createElement('div');
document.body.appendChild(this.node);
ReactDOM.unstable_renderSubtreeIntoContainer(this, <Modal />, this.node);

它能传 context,但 API 名字里带 unstable,行为不稳定、更新时机难控,19 已移除。

它们共同的根因:弹层必须挂到 body 下(否则被父容器裁掉),但「挂到 body」意味着脱离了 React 树。DOM 位置和逻辑位置被绑成了同一个东西。

Portal 的解法就是把这根绳子剪断:

DOM 树:       body
                 └── #portal-root
                       └── .modal-mask          ← 实际绘制在这

React 树:     App
                 └── Page
                       └── Modal
                             └── createPortal(...)  ← 逻辑上仍在这

10.5.3 为什么一定要挂到 body:三个 CSS 陷阱

父容器的样式弹层会怎样
overflow: hidden / auto超出部分被裁掉,弹窗只露一半
transform / filter / perspective子元素的 position: fixed 不再相对视口,而是相对这个容器
z-index + position形成层叠上下文,弹层再高的 z-index 也压不过外面的元素

这些不是 React 的问题,是 CSS 规范决定的。Portal 把节点搬到 body 下,等于换了一个不受这些规则影响的父级。

10.5.4 内部怎么工作:一棵树,两个父指针

React 的 Fiber 节点同时记着两件事:

  • return(父 Fiber):决定 context 从哪来、事件往哪冒泡、错误边界在哪;
  • 真实 DOM 父节点:决定画在哪、被谁裁剪、继承谁的 CSS。

Portal 只是让第二个指针指向 domNode,第一个指针保持不变。所以:

组件树位置  → 决定:context、事件冒泡、错误边界、Suspense 边界
DOM 位置    → 决定:布局、裁剪、层叠、继承样式

这两件事分开之后,就出现了一个反直觉但重要的现象:CSS 的继承走的是 DOM 树,不是 React 树。所以 Portal 出去的弹窗拿不到父组件上的 CSS 变量、字体、颜色继承——因为它的 DOM 父级变成了 body。

10.5.5 版本

版本变化
16.0引入 ReactDOM.createPortal,同时支持事件冒泡与 context
16.6+与 forwardRef、Suspense 组合使用无特殊限制
18仍在 react-dom;并发渲染下行为一致
19仍在;很多场景可用原生 <dialog> / popover 替代

10.5.6 主要用在哪

场景为什么用 Portal额外要处理的
Modal / Drawer避开 overflow、transform、z-index焦点陷阱、ESC 关闭、滚动锁定、aria-modal
Tooltip / Dropdown避免被表格单元格、滚动容器裁剪定位(跟随锚点)、碰撞翻转
Toast / 全局通知视觉上属于页面顶层队列、超时、无障碍播报
富文本编辑器工具栏需要浮在内容之上跟随滚动、选区同步
微前端子应用弹窗挂到主应用的容器下,避免被子应用根节点裁剪样式隔离

10.5.7 坑

现象原因修法
点弹窗,外层组件的 onClick 也触发了事件按 React 树冒泡,不是按 DOM 树内层 stopPropagation,或调整结构
弹窗里的字体/主题色不对CSS 继承走 DOM 树,Portal 到 body 后丢了祖先继承在 Portal 容器上重新挂主题 class / CSS 变量
SSR 报 document is not defined服务端没有 document客户端再渲染(useEffect 后挂载),或用框架的 portal 组件
列表里渲染多个 Portal,顺序乱缺少稳定 key传第三个参数 key
弹窗打开后键盘 Tab 跑到底层页面Portal 不提供焦点管理用 <dialog> 或焦点陷阱库
以为 Portal 能解决所有层级问题目标容器自己也可能在层叠上下文里挂到 body 或顶层容器,检查目标的 position/z-index

10.5.8 19 的替代方案

原生 <dialog> + showModal() 自带三件 Portal 时代要手写的事:顶层渲染(top layer)、焦点陷阱、ESC 关闭。

import { useEffect, useRef } from 'react';

/**
 * 19 时代更省事的写法:原生 dialog 已在 top layer,不需要 Portal。
 * @param {{ open: boolean }} props
 */
function Dialog({ open }) {
  const ref = useRef(null);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    if (open) el.showModal();
    else el.close();
  }, [open]);

  return <dialog ref={ref}>内容</dialog>;
}

选型:需要复杂定位(Tooltip、Dropdown)仍用 Portal;标准弹窗优先 <dialog>。


10.6 createContext() / useContext()

10.6.1 它是什么:一条「跳过中间层」的通道

const ThemeContext = createContext('light'); // 参数是「没有 Provider 时的默认值」
  • createContext(defaultValue) 返回一个 Context 对象,里面带 Provider 和 Consumer;
  • <Provider value={...}> 向后代广播这个值;
  • useContext(Context) 在任意深度的后代读取它。
import { createContext, useContext, useMemo, useState } from 'react';

const ThemeContext = createContext('light');

function App() {
  const [theme, setTheme] = useState('light');

  // value 是对象时必须 memo:否则每次渲染都是新引用,所有消费者都重渲染
  const value = useMemo(() => ({ theme, setTheme }), [theme]);

  return (
    <ThemeContext.Provider value={value}>
      <Toolbar />
    </ThemeContext.Provider>
  );
}

/** 只有 value 变化时才重渲染,不受中间层 shouldComponentUpdate 影响。 */
function Toolbar() {
  const { theme } = useContext(ThemeContext);
  return <div className={theme} />;
}

注意 Toolbar 和 App 之间可以隔任意多层组件,中间层完全不需要知道 theme 的存在。这就是它解决的问题:prop drilling(逐层透传)。

10.6.2 它从哪来:legacy context 的两个致命伤

16.3 之前,React 有一套「老 Context」:

// 老写法(16.3 前,19 已移除)
class App extends React.Component {
  static childContextTypes = { theme: PropTypes.string };

  getChildContext() {
    return { theme: this.state.theme };
  }

  render() { return <Toolbar />; }
}

class Toolbar extends React.Component {
  static contextTypes = { theme: PropTypes.string };

  render() { return <div className={this.context.theme} />; }
}

它有两个致命问题:

问题一:隐式。 有哪些字段、谁提供的、类型是什么,全靠运行时约定。TypeScript 帮不上忙,重构时改名会静默失效。

问题二:会被 shouldComponentUpdate 阻断。 老 Context 的传播走的是普通 props 那条路:中间组件返回 false,下游就收不到新值。

// 经典 bug:加了性能优化,Context 就"坏"了
shouldComponentUpdate() {
  return false;   // 下游的 this.context.theme 永远停在被阻断前的值
}

新 Context 换成了订阅模型:Provider 直接持有消费者列表,值一变就通知消费者,不经过中间组件。所以「加了 SCU 优化,Context 就失效」这个矛盾被彻底解决。

10.6.3 内部怎么工作:Provider 是发布者,消费者是订阅者

<Provider value={v}>
      │
      ├─ 记录当前 value
      └─ 维护一张「读过这个 Context 的 Fiber」列表
                │
                ▼
         value 变化时,逐个标记这些 Fiber 需要更新
                │
                ▼
         跳过中间层(不看 SCU / memo),直接调度消费者

由此推出三个行为:

  1. 中间层 memo 挡不住 Context 更新:因为通知是直接发给消费者的,不经过中间层;
  2. 消费者自己 memo 也挡不住:useContext 让组件订阅了它,值变就必须重渲染;
  3. provider 的 value 用 Object.is 比较:所以「每次渲染新建对象」= 每次都是新值 = 全部消费者重渲染。

10.6.4 版本

版本变化
16.3引入新 createContext / Provider / Consumer / contextType
16.8useContext 出现,函数组件可直接读
18并发渲染下行为一致;useContext 无需再包 Consumer
19可直接写 <ThemeContext value={...}>,省略 .Provider;也可 use(Context)

10.6.5 主要用在哪

场景为什么适合注意事项
主题 / 国际化变化频率低、读取方多拆成 theme 与 locale 两个 Context
当前用户 / 权限跨层读取,几乎每页都用服务端渲染时要传入同一份用户数据
表单 / 组件库上下文Tabs、Select、Form 的父子配置尽量只放「配置」不放「高频状态」
依赖注入(API client、logger)测试时可替换实现默认值给一个安全的空实现

不适合:高频变化的值(鼠标位置、滚动进度、输入框内容)。这类值每秒变几十次,Context 会带着整棵子树重渲染——应该用 useSyncExternalStore 订阅,或把状态放在离使用点最近的组件里。

10.6.6 坑

现象原因修法
随便改个 state,整页都重渲染value={{ theme }} 每次都是新对象useMemo 包住,或拍平成多个 Provider
Provider 传了 undefined,读到的是 undefined 而不是默认值默认值只在没有 Provider 时生效用 value={x ?? fallback} 显式兜底
在 Provider 所在组件里 useContext 读到旧值它读的是上层 Provider,不是自己提供的把读取移到子组件
组件库升级后 <Context value> 在 18 下报错19 的简写语法 18 不支持库代码统一写 .Provider
一个 Context 装十个字段,改一个全重渲染所有消费者共享同一订阅按「变化频率」拆分 Context

10.7 ref 家族:useRef / createRef / 回调 ref / forwardRef / useImperativeHandle

10.7.1 为什么需要 ref

React 的数据流是「state → UI」,单向且声明式。但有些事必须命令式地做:

  • 让输入框聚焦、选中文本、滚动到某个位置;
  • 测量元素尺寸、监听滚动;
  • 集成非 React 的库(地图、编辑器、图表、视频播放器)。

这些操作不能靠「改 state 再等渲染」——因为它们的时机、返回值都不在渲染模型里。ref 就是那个直接拿到东西的口子。

10.7.2 五个成员的分工

API引入用在哪解决什么
字符串 ref(ref="input")0.x老代码已废弃(16.3 警告,19 移除)
回调 ref很早任意组件挂载/卸载时执行副作用,最灵活
createRef16.3类组件创建一个「可变的引用盒子」
useRef16.8函数组件同上,且能在渲染之间保持
forwardRef16.3组件库 / HOC把外部 ref 透传到内部 DOM
useImperativeHandle16.8组件库只暴露「最小接口」,不交出整个 DOM
ref 作为普通 prop19函数组件省掉 forwardRef 包装

10.7.3 从「能用」到「好用」

/** 最基础:拿到 DOM 并聚焦。 */
function SearchBox() {
  const inputRef = useRef(null);

  useEffect(() => {
    inputRef.current?.focus();
  }, []);

  return <input ref={inputRef} />;
}
/**
 * 只暴露 focus / clear,外部拿不到真实 DOM。
 * 好处:内部实现可以换(input → textarea),外部调用不变。
 */
const SearchInput = forwardRef(function SearchInput(props, ref) {
  const innerRef = useRef(null);

  useImperativeHandle(ref, () => ({
    focus: () => innerRef.current?.focus(),
    clear: () => {
      if (innerRef.current) innerRef.current.value = '';
    },
  }), []);

  return <input ref={innerRef} {...props} />;
});
// 19:ref 就是普通 prop,不再需要 forwardRef
/**
 * @param {{ ref?: React.Ref<HTMLInputElement> } & React.InputHTMLAttributes<HTMLInputElement>} props
 */
function MyInput({ ref, ...props }) {
  return <input ref={ref} {...props} />;
}
// 19:回调 ref 可以返回清理函数(以前只能靠"传 null 时手动清理")
<input
  ref={(node) => {
    const observer = new ResizeObserver(() => measure(node));
    observer.observe(node);
    return () => observer.disconnect(); // 卸载或 ref 变化时执行
  }}
/>

10.7.4 它从哪来:字符串 ref 与 findDOMNode 的时代

老代码里常见这种写法:

// 15/16 时代:通过字符串拿到实例
<input ref="input" />
this.refs.input.focus();

问题:

  • 隐式:ref 名字是字符串,重构改名不会报错;
  • 会拿到组件实例:拿到类组件实例后,调用方可以随意调它的方法,封装被破坏;
  • 和 findDOMNode 绑在一起:为了拿 DOM,很多人用 ReactDOM.findDOMNode(this),它穿透组件边界、在 Fragment/Portal/函数组件下行为诡异。

16.3 用 createRef + 回调 ref 取代字符串 ref,同时在 19 彻底移除字符串 ref 与 findDOMNode。方向很明确:ref 只能指向一个明确的节点或一个显式暴露的接口,不能用来偷看组件内部。

10.7.5 内部怎么工作:ref 在什么时机被赋值

渲染阶段(render)
  → 只记下「这个元素要挂哪个 ref」,不赋值

提交阶段(commit)
  → DOM 插入完成后,把真实节点写进 ref.current
  → 在 useLayoutEffect 之前完成
  → 卸载时写回 null(19 的回调 ref 则调用 cleanup)

三个推论:

  1. render 期间 ref.current 还是旧值(或 null),不能拿它算渲染结果;
  2. ref 更新不会触发重渲染:它不在 React 的依赖系统里;
  3. useImperativeHandle 在提交阶段执行:所以外部在 useEffect 里能拿到接口,在 render 里拿不到。

10.7.6 主要用在哪

  • 焦点管理、表单校验定位;
  • 测量与虚拟滚动(ResizeObserver + ref);
  • 与第三方库桥接(把 DOM 交给它,卸载时销毁);
  • 保存「不需要触发渲染」的值:定时器 id、上一次的值、是否首次渲染;
  • 组件库暴露命令式接口(focus / scrollTo / play)。

10.7.7 坑

现象原因修法
改了 ref.current,UI 不更新ref 不是响应式的需要驱动 UI 的值用 state
渲染时读到 nullref 在 render 阶段还没赋值放到 useEffect / useLayoutEffect
useRef(expensive()) 每次都算初始值表达式每次渲染都会执行改 useRef(null) + 惰性初始化
函数组件上传 ref 没反应16–18 需要 forwardRef升级 19 或包 forwardRef
19 里回调 ref 返回了值导致 TS 报错返回非函数会被当成 cleanup用块级函数体 { node => { ... } }
forwardRef 组件在 DevTools 里叫 ForwardRef没设 displayNameComp.displayName = 'Comp'
在并发渲染下读写 ref 出现错乱render 必须纯,读写 ref 属于副作用只在事件/effect 里读写

10.8 React.lazy() + Suspense

10.8.1 它是什么:把「未就绪」变成声明式边界

const Chart = lazy(() => import('./Chart'));

<Suspense fallback={<ChartSkeleton />}>
  <Chart />
</Suspense>
  • lazy(loader):把「动态 import 返回的 Promise」包装成一个组件;
  • <Suspense fallback>:当子树「没准备好」时,显示 fallback。

两者是配套的:lazy 负责「抛出一个未完成的 Promise」,Suspense 负责「接住它并显示兜底」。

10.8.2 它从哪来:每个懒加载组件都要自己管 loading

16.6 之前,代码分割靠 HOC 库:

// 以前:每个懒加载组件都配一份 loading 逻辑
const Chart = Loadable({
  loader: () => import('./Chart'),
  loading: ChartSkeleton,
});

痛点:

  • 状态散落:每个组件自己管 loading / error,写法五花八门;
  • 嵌套地狱:A 里懒加载 B,B 里懒加载 C,页面会出现「骨架套骨架」;
  • 无法统一控制:想让整页「任一模块没好就显示一个总骨架」,很难做到。

lazy + Suspense 把它变成声明式:你只说「这里可能没好,没好就显示这个」,状态流转交给 React。

10.8.3 内部怎么工作:抛 Promise 的机制

① 首次渲染 <Chart />
     lazy 调用 import(),拿到一个未完成的 Promise
② 组件「抛」出这个 Promise(对,就是 throw 一个 Promise)
③ React 捕获到抛出物,向上找最近的 Suspense 边界
④ 找到 → 渲染 fallback;没找到 → 整棵树崩(报错)
⑤ Promise resolve → React 重新渲染这个子树 → 这次拿到真组件

这套机制后来被推广到「数据读取」:18 的流式 SSR、19 的 use(promise) 都是同一个模型——「未就绪」用抛出表达,边界用 Suspense 表达。

10.8.4 版本

版本变化
16.6引入 lazy + Suspense(仅用于代码分割)
16.9与 act 的异步支持配合更好
18Suspense 支持数据读取与流式 SSR;可选择性水合
19use(promise) 让客户端也能读 Promise;RSC 把边界延伸到服务端

10.8.5 主要用在哪

场景切分粒度理由
路由级分割每个页面一个 chunk首屏只加载当前页
重组件图表、富文本、地图、代码高亮首屏不需要,体积大
低频弹窗设置面板、帮助中心打开时才加载
按权限加载管理员模块普通用户不下载

10.8.6 坑

现象原因修法
直接报错「A component suspended while responding to synchronous input」没有 Suspense 包裹加边界;交互触发的懒加载用 startTransition 包住
请求数暴涨,反而更慢切得太细(每个按钮都 lazy)按「路由 / 大模块」切
发版后旧页面白屏chunk 404(文件名带 hash)Error Boundary + 提示刷新;保留上一版静态资源
骨架一闪而过,体验更差加载很快,fallback 只显示几毫秒延迟显示骨架(150–200ms 后再显示)或用 useTransition 避免闪
SSR 下 lazy 直接报错16 的 Suspense 不支持 SSR16/17 用 @loadable/component;18+ 用流式
想在 SSR 里预加载所有 chunk没配 modulepreload构建工具配置预加载,或服务端 preloadModule
数据也想像 lazy 一样「读」16/17 不支持数据 Suspense用框架的数据层(Next/Remix)或 19 的 use

一个值得记住的细节:lazy 只加载「组件代码」,不加载「数据」。想让数据和代码一起就绪,需要框架层的能力(loader / RSC),不要自己用 throw promise 手搓——很容易漏掉缓存、重试、竞态。


10.9 memo / useMemo / useCallback

10.9.1 三个 API,三个不同的问题

它们经常被混为一谈,其实缓存的对象完全不同:

API缓存什么解决的问题失效条件
React.memo组件父组件重渲染时,props 没变就别重渲染我任一 prop 引用变化,或自身 state 变化
useMemo一个值依赖没变就别重算依赖数组变化
useCallback一个函数依赖没变就别新建函数依赖数组变化
// memo 等价于:对每个 prop 做 Object.is 比较
const Row = memo(function Row({ item, onSelect }) { /* ... */ });

10.9.2 它从哪来:从「手动挡」到「自动挡」

版本变化当时的痛点
15.3PureComponent类组件每次 setState 都重渲染,需要手动浅比较
15.xshouldComponentUpdate逻辑要手写,容易漏字段、写错方向
16.6React.memo函数组件没有 shouldComponentUpdate,需要等价能力
16.8useMemo / useCallback函数组件里没有「实例变量」缓存计算结果
19 / Compiler编译器自动插入缓存「手写 memo」从默认做法变成兜底手段

10.9.3 为什么 memo 经常「失效」:引用相等是硬门槛

浅比较只看引用,不看内容。下面三种写法每次都产生新引用:

<Row item={{ ...item }} />              // 新对象
<Row tags={['a', 'b']} />               // 新数组
<Row onSelect={(id) => select(id)} />   // 新函数

结果:memo 每次都判定「变了」,优化归零,还多了一次比较开销。

正确姿势:

/**
 * 父组件:把「每次渲染都会新建」的东西稳定下来。
 * @param {{ items: Item[] }} props
 */
function List({ items }) {
  const handleSelect = useCallback((id) => select(id), []);
  const rows = useMemo(
    () => items.map((it) => <Row key={it.id} item={it} onSelect={handleSelect} />),
    [items, handleSelect]
  );
  return <>{rows}</>;
}

10.9.4 什么时候该用,什么时候是自嗨

该用不该用
组件渲染很贵(大列表行、图表、富文本)组件很轻,比较成本 ≈ 渲染成本
父组件频繁重渲染,子组件 props 稳定父组件本来就很少重渲染
值要作为其他 Hook 的依赖(需要引用稳定)只是为了「看起来优化了」
第三方库要求引用不变(地图实例、订阅)依赖频繁变化,缓存永远命中不了

一个判断方法:先测,再包。用 React DevTools Profiler 看这个组件一次渲染的耗时——低于 1ms 的组件,包 memo 基本是负收益。

10.9.5 内部规则:缓存的是「这一份引用」

useMemo(fn, deps)
  → 首次:执行 fn,存下结果
  → 后续:deps 逐项 Object.is 比较
      全等 → 直接返回缓存值(fn 不执行)
      不等 → 重新执行,替换缓存

useCallback(fn, deps)
  → 等价于 useMemo(() => fn, deps)

两个常被忽略的后果:

  1. 缓存也会被丢弃:React 可以在内存压力下丢掉缓存(官方明确不保证语义),所以不能把 useMemo 当「只执行一次」用;
  2. useMemo 在 render 期间执行:里面不能有副作用,否则并发渲染重放时会跑多次。

10.9.6 与 React Compiler 的关系

19 之后,编译器会为「符合规则」的代码自动插入缓存。所以:

  • 性能型 memo 可以逐步删掉(编译器接管);
  • 语义型 memo 要保留:比如「这个对象必须引用稳定,否则 useEffect 会反复执行」;
  • 别指望 memo 解决一切:它只减少「重复渲染」,不解决「单次渲染太慢」。

10.9.7 坑

现象原因修法
包了 memo 还是重渲染props 里有内联对象/函数用 useMemo / useCallback 稳定引用
useMemo 里请求数据它在 render 期间执行副作用放 useEffect
依赖数组漏项,用到旧值闭包捕获了旧变量开 eslint-plugin-react-hooks,不要手写依赖
依赖写多了,缓存永远失效把每次新建的对象放进依赖依赖只放「基本类型」或稳定的引用
用 useCallback 想「只执行一次」它只保证引用稳定,不保证执行次数用 useRef 标记或空依赖 useEffect
所有组件都包 memo,页面更卡比较开销 + 内存占用上升只对「贵的组件」用,先测量

10.10 Transition 家族:startTransition / useTransition / useDeferredValue / flushSync

10.10.1 它们从哪来:18 之前,更新没有「轻重缓急」

18 之前,一次 setState 就是一次同步渲染:谁先调用谁先算,中间不可插入、不可打断。于是这类场景必然卡:

// 输入框 + 大列表过滤:每敲一个字,全量过滤 1 万条
function Search({ source }) {
  const [query, setQuery] = useState('');
  const filtered = source.filter((x) => x.includes(query)); // 同步计算
  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <List items={filtered} />
    </>
  );
}

用户敲一个字母,浏览器要做三件事:更新输入框、过滤 1 万条、重渲染列表。三件事在同一个同步任务里,输入框的反馈被排在最后——打字卡顿。

18 引入 lane(优先级车道)之后,React 第一次能表达「这次更新不紧急,可以被打断、被丢弃」。这组 API 就是给业务用的「优先级开关」。

10.10.2 四个成员

API引入作用你什么时候需要它
startTransition(fn)18把 fn 里的更新标记为低优先级不需要 pending 状态
useTransition()18同上,并返回 isPending需要提示「正在追」
useDeferredValue(value)18让某个值「慢半拍」拿不到 setState(值来自 props / 第三方)
flushSync(fn)18强制同步刷新必须立刻读更新后的 DOM
/**
 * 搜索:输入即时,结果延后。
 * query 走紧急更新(用户能看到自己打的字),list 走过渡更新(可以慢)。
 */
function Search({ source }) {
  const [query, setQuery] = useState('');
  const [list, setList] = useState(source);
  const [isPending, startTransition] = useTransition();

  function onChange(e) {
    const next = e.target.value;
    setQuery(next);                     // 紧急:输入框必须立刻响应
    startTransition(() => {
      setList(filterAll(source, next)); // 可中断、可丢弃
    });
  }

  return (
    <>
      <input value={query} onChange={onChange} />
      <List items={list} dimmed={isPending} />
    </>
  );
}
/**
 * 拿不到 setState 时的写法:更新照旧,只让昂贵子组件用旧值渲染。
 */
function ResultPanel({ query, source }) {
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;

  return <ExpensiveList query={deferredQuery} stale={isStale} />;
}

10.10.3 内部怎么工作:不是「延迟执行」,是「可被打断」

startTransition 并不把函数放进定时器。它做的是给这次更新打一个 lane 标记:

紧急更新(默认 lane)     → 立刻渲染,不可被低优先级阻塞
过渡更新(Transition lane)→ 可以被打断;新输入来了,旧计算直接作废

三个关键行为:

  1. 可中断:渲染大列表时,用户又敲了字,React 会放弃当前渲染、先处理输入;
  2. 可丢弃:旧的过渡结果如果已经被新输入取代,直接不提交(不会出现「结果闪回」);
  3. 不改变结果:算完之后,UI 与「直接同步算」完全一致,只是「什么时候算、算几次」变了。

flushSync 正好相反:它把更新降级为同步、不可中断,并立刻提交。用于「必须马上读 DOM」的少数场景(例如第三方库测量尺寸),滥用等于关掉并发。

10.10.4 版本

版本变化
18引入 startTransition / useTransition / useDeferredValue / flushSync
19startTransition 支持 async 函数,isPending 能跨 await 保持;useDeferredValue 新增 initialValue 参数
19.3Transition 之间不再相互纠缠:一个慢过渡不会拖住无关的过渡

19 的异步支持很实用:

// 19:await 期间 isPending 一直为 true
const [isPending, startTransition] = useTransition();

function submit() {
  startTransition(async () => {
    await saveDraft();      // 这期间 isPending = true
    setStatus('已保存');     // 这次更新仍是过渡优先级
  });
}

10.10.5 主要用在哪

场景用哪个关键点
搜索框 + 大列表过滤useTransition输入值走紧急,结果走过渡
Tab 切换重型面板useTransition用 isPending 做加载提示,避免「点了没反应」
图表随筛选条件重算useDeferredValue值来自 props,拿不到 setState
路由跳转框架内部用 TransitionNext.js / React Router 已封装
必须同步读 DOMflushSync只在事件回调里用,且尽量少用

10.10.6 坑(这一组最多)

现象原因修法
输入框回弹、光标跳到末尾把受控输入的 value 放进了 Transition输入值必须紧急,只让派生结果慢
按钮点了像没反应过渡期间没有任何反馈用 isPending 禁用按钮或显示骨架
以为 Transition 能减少总耗时它只调整优先级真正的优化靠虚拟化、缓存、减少计算
持续滚动/输入导致「饿死」低优先级更新一直被打断节流输入,或降低更新频率
useDeferredValue 结果看起来是错的没区分「正在追」和「已追上」用 value !== deferredValue 做 stale 提示
flushSync 警告「不能在渲染期间调用」在 render / 生命周期里用了只在事件处理器、useEffect 里用
列表在过渡中闪回旧数据直接改了同一个 state 且没有 key 区分让过渡结果整体替换,或用 key 强制重建

10.11 三个「库作者 Hook」:useId / useSyncExternalStore / useInsertionEffect

这三个 Hook 不面向业务页面,而是给组件库 / 状态库 / 样式库补齐并发时代的三个缺口。业务里遇到它们,通常是在「用库」而不是「写库」。

10.11.1 useId(18)

解决什么:SSR 需要「服务端和客户端生成同一个 ID」。Math.random()、自增计数器都会两边不一致,直接导致水合失败。

/**
 * 生成跨端一致的唯一 ID。
 * 规则:只在顶层调用;同一组件多次调用会得到不同后缀。
 * @param {{ label: string }} props
 */
function Field({ label }) {
  const id = useId();
  return (
    <>
      <label htmlFor={id}>{label}</label>
      <input id={id} aria-describedby={`${id}-hint`} />
      <p id={`${id}-hint`}>必填</p>
    </>
  );
}

内部怎么保证一致:ID 不是随机的,而是由「这棵树在组件树里的位置」推导出来的。服务端和客户端渲染同一棵树,位置相同,ID 就相同。

版本细节:18 的格式带冒号(:r0:),19 改成下划线(_R_0_,实测输出)。原因很实际——冒号在 CSS 选择器和 querySelector 里需要转义,给使用者添麻烦。

坑:

  • 不能用于列表 key(它不是业务 ID);
  • 拼 CSS 选择器时仍建议 CSS.escape(老版本格式含特殊字符);
  • 一个页面挂多个 React 实例(微前端)时,用 identifierPrefix 区分,否则两个实例都从 0 开始编号 → 冲突;
  • 不要指望它「稳定跨请求」:每次渲染的 ID 由位置决定,重新挂载会变。

10.11.2 useSyncExternalStore(18)

解决什么:并发渲染下,外部 store 可能被读到两个版本(撕裂 / tearing)——渲染到一半,store 变了,页面上一半是新值一半是旧值。

/**
 * 订阅浏览器在线状态:库作者写 store 的标准姿势。
 * @returns {boolean}
 */
function useOnline() {
  return useSyncExternalStore(
    (cb) => {
      window.addEventListener('online', cb);
      window.addEventListener('offline', cb);
      return () => {
        window.removeEventListener('online', cb);
        window.removeEventListener('offline', cb);
      };
    },
    () => navigator.onLine,   // 客户端快照:必须返回稳定引用
    () => true                // 服务端快照:避免水合不一致
  );
}

内部怎么工作:React 在提交前会再读一次 getSnapshot,如果和渲染期间读到的不一致,就重新渲染——用多算一次换取一致性。

坑:

现象原因修法
无限重渲染getSnapshot 每次返回新对象缓存快照,或在 store 里返回稳定引用
订阅反复建立/销毁subscribe 每次渲染新建函数useCallback 包住,或把 subscribe 提到模块级
服务端渲染报错没提供 getServerSnapshot补第三个参数,返回一个确定值
页面偶发「新旧值混排」直接用了 useState + 手动订阅换这个 Hook,让 React 管一致性

Redux、Zustand、Jotai 等库内部都依赖它——你在业务里写 useSelector / useStore 时,用的就是这层能力。

10.11.3 useInsertionEffect(18)

解决什么:CSS-in-JS 需要在「布局测量之前」把样式插进 DOM。用 useLayoutEffect 太晚(测量会基于旧样式),用 useEffect 更晚。

/**
 * 仅供样式库使用:插入 <style>,并在卸载时移除。
 * 限制:不能读 DOM 布局、不能 setState、不能订阅。
 */
useInsertionEffect(() => {
  const style = document.createElement('style');
  style.textContent = `.box-${id} { color: ${color} }`;
  document.head.appendChild(style);
  return () => style.remove();
}, [id, color]);

执行顺序:

DOM 变更 → useInsertionEffect → useLayoutEffect(可测量)→ 浏览器绘制 → useEffect

业务代码基本用不到;如果你在业务里想用它,通常说明你在手写样式库。


10.12 Actions 四件套:useActionState / useFormStatus / useOptimistic / use

10.12.1 它们从哪来:一个表单要手写四个状态

19 之前,任何表单提交都要自己维护:

// 19 之前的典型写法:四个状态、三处 try/catch、一次手动重置
const [pending, setPending] = useState(false);
const [error, setError] = useState(null);
const [optimistic, setOptimistic] = useState(initial);

async function onSubmit(e) {
  e.preventDefault();
  setPending(true);
  setError(null);
  try {
    await save(new FormData(e.target));
    e.target.reset();
  } catch (err) {
    setError(err.message);
  } finally {
    setPending(false);
  }
}

问题不只是啰嗦:

  • 容易漏:忘记 finally 就永远 pending;忘记重置就重复提交旧数据;
  • 并发提交:用户连点两次,两个请求竞争,最后落库的可能是旧值;
  • 乐观更新难回滚:先改 UI,失败后要恢复,还要处理「回滚时又有新提交」;
  • 渐进增强做不到:JS 没加载完,表单完全不可用。

19 把这套东西收进 React:

用户提交 → React 自动包进 transition → pending 由框架管
         → 返回值变成 state → 可预期错误用返回值,意外错误抛给边界
         → 成功后自动重置非受控表单

10.12.2 分工

API包管什么典型位置
useActionStatereact提交结果 state + isPending + 包装后的 action提交表单的组件
useFormStatusreact-dom读取最近父级 <form> 的 pending设计系统里的按钮
useOptimisticreact乐观值 + 结束自动回滚点赞、收藏、乐观列表
usereact渲染期读取 Promise / Context客户端读服务端给的 Promise
/**
 * 一个完整的「乐观 + 校验错误 + pending」表单。
 * @param {{ initialName: string, save: (name: string) => Promise<void> }} props
 */
function ProfileForm({ initialName, save }) {
  // ① 提交状态机:[当前状态, 提交函数, 是否 pending]
  const [error, submit, isPending] = useActionState(async (_prev, formData) => {
    const name = String(formData.get('name') ?? '').trim();
    if (!name) return '名字不能为空';       // 可预期错误:return 一个值
    await save(name);                        // 意外错误:throw 给 Error Boundary
    return null;
  }, null);

  // ② 乐观值:先显示,失败自动回到 initialName
  const [optimisticName, setName] = useOptimistic(initialName, (_prev, next) => next);

  return (
    <form
      action={(formData) => {
        setName(String(formData.get('name')));  // 立刻显示
        return submit(formData);                // 真正提交
      }}
    >
      <input name="name" defaultValue={optimisticName} />
      <SubmitButton />
      {error && <p role="alert">{error}</p>}
      {isPending && <p>保存中…</p>}
    </form>
  );
}

/** ③ 子组件读父级 form 的 pending,不需要层层传 props。 */
function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? '保存中…' : '保存'}</button>;
}
// ④ use:读服务端递过来的 Promise
function Comments({ commentsPromise }) {
  const comments = use(commentsPromise); // 未完成时挂起,由 Suspense 接住
  return comments.map((c) => <p key={c.id}>{c.text}</p>);
}

10.12.3 内部怎么工作:提交被包进 Transition

< form action={fn}> 提交
   │
   ├─ React 拦截原生提交(已水合的情况)
   ├─ 把 fn 放进 Transition 执行 → isPending 自动维护
   ├─ fn 的返回值 → 成为 useActionState 的 state
   ├─ fn 抛错     → 冒泡到最近的 Error Boundary
   └─ 成功后      → 重置非受控表单字段

渐进增强:配合 Server Action('use server'),React 会为表单生成 action 属性。JS 还没加载完时,浏览器按原生表单提交,服务端照样能处理——这是 19 表单能力里最容易被忽略的一点。

10.12.4 版本

版本变化
18 canary以 useFormState / useFormStatus 形式出现
19useFormState 正式改名 useActionState;useOptimistic、use 稳定
19.2prerender + resume 支持 PPR;Actions 与之配合更顺

10.12.5 坑

现象原因修法
照着老教程写 useFormState 报错它是 canary 名字,19 已改名换 useActionState
校验失败后整个表单消失把可预期错误 throw 了可预期错误 return,意外错误才 throw
禁用按钮后仍能重复提交isPending 只是 UI 状态服务端做幂等(幂等键、唯一约束)
useFormStatus 永远是 false按钮不在 <form> 内部(或经过 Portal)把按钮放进 form,或用 useActionState 的 isPending
use 包在 try/catch 里失效挂起靠「抛 Promise」实现,catch 会吞掉把 use 移到 try 外面,错误用 Error Boundary
乐观更新回滚后数据错乱回滚时又有新提交用唯一的临时 id 关联,回滚只移除自己那条

10.13 资源提示四件套:preload / preinit / prefetchDNS / preconnect

这些能力以前靠手写:

<link rel="preload" href="/fonts/inter.woff2" as="font" crossorigin>
<link rel="preconnect" href="https://api.example.com">

问题:

  1. 组件里没法方便地「提升到 head」:你只能在组件里写 <link>,但它出现在 body 中间,浏览器发现得晚;
  2. 流式渲染时插入太晚:内容一段段发出去,等你渲染到那个组件,首屏资源早就该下载了;
  3. 容易漏属性:crossorigin 漏了,字体/图片会下载两次。

19 把它们变成 API,React 负责提升到文档头部、去重、按优先级插入——即使你在很深的组件里调用。

10.13.2 四个的区别

API浏览器行为适合代价
prefetchDNS只解析域名可能会访问的第三方域极低
preconnectDNS + TCP + TLS一定会请求的 API / CDN占用连接
preload高优先级下载,但不执行字体、首屏大图、关键 CSS不用就浪费带宽
preinit下载并立即执行 / 插入必须尽早生效的脚本、样式可能阻塞
import { preload, preinit, prefetchDNS, preconnect } from 'react-dom';

function App() {
  // 字体:as/type/crossOrigin 必须与真实请求完全一致,否则会下载两次
  preload('/fonts/inter.woff2', {
    as: 'font',
    type: 'font/woff2',
    crossOrigin: 'anonymous',
  });

  preinit('/analytics.js', { as: 'script' });
  prefetchDNS('https://cdn.example.com');
  preconnect('https://api.example.com');

  return <Main />;
}

10.13.3 主要用在哪

  • 字体:首屏字体是最典型的「发现晚」资源;
  • 首屏大图 / LCP 图:提前下载,直接影响 LCP;
  • 关键 CSS:避免 FOUC;
  • 第三方 API 域名:preconnect 省掉 TLS 握手;
  • 埋点脚本:preinit 尽早执行,避免丢事件。

10.13.4 坑

现象原因修法
首屏反而更慢预加载了用不上的资源,抢带宽只对「首屏确定要用」的资源用
字体下载两次crossOrigin / type 与真实请求不一致参数必须与 <link> / @font-face 完全一致
控制台警告「preload 了但未使用」资源没被实际引用删掉,或改成 prefetch 语义
preinit 阻塞渲染它会让脚本尽早执行只给必须尽早跑的脚本用
同一资源重复调用多个组件都调了React 会去重,但参数不一致时不会——保持参数统一

10.14 Profiler

10.14.1 它是什么

<Profiler id="Dashboard" onRender={onRender}>
  <Dashboard />
</Profiler>

16.9 引入,用于采集「这棵子树在一次提交里花了多久」。

10.14.2 为什么需要它

在它之前,定位 React 性能问题只能靠浏览器 Performance 面板「猜哪段是 React」。Profiler 第一次让「组件级耗时」变成可编程获取的数据——可以打点、可以上报、可以在 CI 里做阈值检查。

10.14.3 关键字段

字段含义怎么用
id你给这个边界的名字上报时区分页面/模块
phasemount / update / nested-update区分首屏与更新
actualDuration本次实际渲染耗时找慢提交
baseDuration不用 memo 时的估算值判断 memo 是否有效
startTime / commitTime起止时间戳与长任务、INP 对齐
/**
 * 采集并上报:只上报超过阈值的提交,避免打点本身成为负担。
 * @param {string} id
 * @param {'mount'|'update'|'nested-update'} phase
 * @param {number} actualDuration
 * @param {number} baseDuration
 */
function onRender(id, phase, actualDuration, baseDuration) {
  if (actualDuration > 50) {
    report({ id, phase, actualDuration, baseDuration });
  }
}

10.14.4 版本

版本变化
16.9引入 Profiler 与 onRender
18与并发渲染配合;交互追踪能力调整
19.2新增 Performance Tracks,可在 Chrome Performance 面板看到组件轨道

10.14.5 坑

现象原因修法
生产环境没有数据只在开发 / profiling 构建里生效线上用 Performance 面板 + 自定义埋点,别指望它
页面本身变慢了每个边界都要计时与闭包只包「怀疑慢」的模块,别包列表每一行
数字加起来超过总时长嵌套边界重复计时汇总时去重,或只统计最外层
actualDuration 很小但页面卡时间花在 DOM 提交、样式计算、图片解码上结合 Performance 面板看主线程

10.15 19.1 / 19.2 / 19.3 的新面孔:captureOwnerStack / <Activity> / useEffectEvent / cacheSignal / <ViewTransition>

这一批 API 都很新,共同点是:它们解决的是「并发 + 服务端」时代才出现的问题,在 18 之前根本不存在。

10.15.1 captureOwnerStack(19.1)

解决什么:错误栈只告诉你「哪一行炸了」,不告诉你「这个组件是被谁渲染出来的」。在组件库、动态渲染、children 透传的场景里,光看栈很难定位。

import { captureOwnerStack } from 'react';

function Widget() {
  // 拿到「谁渲染了我」的调用链,用于开发期错误覆盖层/调试
  const stack = captureOwnerStack();
  return <div data-owner={stack ?? ''} />;
}

它和 Error.stack 的区别:它记录的是 React 的 owner 链(父组件 → 子组件),不是 JS 调用栈。

坑:它只在开发模式可用(生产构建里返回 null),适合错误覆盖层、调试面板,不能当线上日志的来源;也不要在 render 里高频调用并拼接字符串。

10.15.2 <Activity>(19.2,原 Offscreen)

解决什么:让「隐藏的页面」保留状态,同时不占用可见区域;回到前台时立刻恢复(不用重新请求、重新滚动、重新输入)。

import { Activity } from 'react';

/**
 * 类似「后台 Tab」:隐藏时暂停 effect 与渲染,
 * 但保留状态(输入内容、滚动位置、已加载的数据)。
 * @param {{ active: string, panels: Array<{id: string, content: React.ReactNode}> }} props
 */
function Tabs({ active, panels }) {
  return panels.map((panel) => (
    <Activity key={panel.id} mode={active === panel.id ? 'visible' : 'hidden'}>
      {panel.content}
    </Activity>
  ));
}

和 display: none 的区别:

方式状态渲染effect恢复速度
display: none保留继续渲染继续执行快(DOM 已在)
卸载组件丢失停止清理慢(要重新请求)
<Activity mode="hidden">保留暂停暂停快且不浪费 CPU

坑:隐藏时 effect 会被清理/暂停,依赖「常驻副作用」的逻辑(全局定时器、WebSocket)要重新评估;隐藏树里的错误不会冒到可见 UI。

10.15.3 useEffectEvent(19.2)

解决什么:effect 里经常要读「最新的 props / state」,但把它们放进依赖数组会导致 effect 反复执行(比如重连 WebSocket)。以前只能用 ref 绕。

import { useEffect, useEffectEvent } from 'react';

function Chat({ roomId, onMessage }) {
  // 这个函数总是读到最新的 props,但它不是 effect 的依赖
  const handleMessage = useEffectEvent((msg) => {
    onMessage(msg, roomId);
  });

  useEffect(() => {
    const socket = connect(roomId);
    socket.on('message', handleMessage);
    return () => socket.disconnect();
  }, [roomId]); // 只在切房间时重连,onMessage 变了也不重连
}

它把「响应式依赖」和「最新值」解耦:effect 依赖只保留真正影响副作用的变量,其余通过 Effect Event 读取。

坑:返回的函数只能在 effect 里调用,当普通事件处理器传给子组件会报错;它不改变 effect 的执行时机,只改变「读到哪个值」。

10.15.4 cacheSignal(19.2)

解决什么:用 cache() 包裹的数据请求,缓存生命周期结束后,还在飞的请求应该被取消,避免浪费。cacheSignal 提供一个 AbortSignal,让你知道「这次缓存已经不需要了」。

import { cacheSignal } from 'react';

/**
 * 把缓存失效信号接给 fetch,缓存结束后自动 abort。
 * @param {string} id
 */
async function loadUser(id) {
  const signal = cacheSignal();
  const res = await fetch(`/api/user/${id}`, { signal });
  return res.json();
}

坑:它只在 RSC / cache() 的作用域内有意义;在浏览器渲染里不要依赖它,也不要把它当通用的请求取消方案。

10.15.5 19.3 新增(2026-09):<ViewTransition> / addTransitionType / browser()

19.3 的主线是「把 View Transition 变成 React 的一等公民」,并补上「只在浏览器渲染」的显式表达:

API解决什么一句话用法
<ViewTransition>路由切换、列表增删的动画以前要手写 FLIP用组件包住「变化前后要保持连续」的部分
addTransitionType动画需要知道「这次过渡是前进还是后退」在 startTransition 里打类型标记,供 CSS / 组件选择不同动画
browser()有些子树只能在浏览器渲染(依赖 window、第三方 SDK)use(browser()) 让这棵子树在服务端跳过、在浏览器挂起后渲染
Fragment Ref想给一组元素统一挂 ref<Fragment ref={...}>
onBrowserBailout服务端要知道「哪些子树被推迟到浏览器」react-dom/server 的选项回调
import { Suspense, use, ViewTransition } from 'react';
import { browser } from 'react-dom';

/** 只在浏览器渲染的图表:服务端跳过这棵子树,不算水合失败。 */
function Chart() {
  use(browser());          // 浏览器里挂起,由外层 Suspense 接住
  return <HeavyCanvas />;
}

/** 页面切换时保持前后内容连续,动画交给 ViewTransition。 */
function Page() {
  return (
    <ViewTransition>
      <Suspense fallback={<ChartSkeleton />}>
        <Chart />
      </Suspense>
    </ViewTransition>
  );
}

注意:19.3 很新,框架和打包链的适配还在推进;用在生产前先确认 Next.js / Remix 的支持情况,并准备好降级(不支持时就是普通渲染,没有动画)。

10.15.6 这一批 API 的共同坑

现象原因修法
升级后 import 报「没有这个导出」版本不够(19.1 / 19.2 / 19.3 各有门槛)先确认 react 与 react-dom 版本一致且足够新
<Activity> 隐藏后定时器停了隐藏会暂停 effect把常驻逻辑提到 Activity 外层
useEffectEvent 传给孩子报错它只能在 effect 中调用需要传下去就用普通函数 + useCallback
ViewTransition 没有动画浏览器不支持 / 没有触发 transition做特性检测,保证无动画时功能正常

10.16 怎么用这个附录

遇到一个陌生 API,按这五步问自己:

  1. 它在哪一层——交付(SSR)、入口(root)、调度(优先级)、数据(context/store)、还是边界(portal/ref)?
  2. 它替代了谁——这个「前身」往往就是它存在的理由(render → createRoot、hydrate → hydrateRoot、字符串 ref → useRef);
  3. 它的产物/返回值是什么形状——字符串、流、Promise、{ pipe, abort }、还是 [state, action, isPending]?形状决定了它能做什么、不能做什么;
  4. 它现在是什么状态——推荐 / 可用但不推荐 / 弃用 / 已移除(本章索引表第二列);
  5. 不用它用什么——迁移路径比 API 本身更重要。

最后给一张「判断一个 API 该不该用」的速查表:

你的处境结论
新项目、React 18/19、需要 SSR直接上 renderToPipeableStream + hydrateRoot,不要从 renderToString 起步
老项目还在用 ReactDOM.render先升 18 换 createRoot,再谈 19;并发能力是后续所有优化的前提
看到 renderToNodeStream已移除,必须迁移,没有兼容开关
想给列表加动画/交互却卡先量(Profiler),再判断是「渲染次数」问题还是「单次渲染」问题
表单要 pending / 错误 / 乐观更新19 用 Actions 四件套,不要手写四个 state
不确定某个 API 能不能用于 SSR看它是否依赖 window / document;依赖就不能在服务端跑

把这张表当成「字典」用:正文讲「为什么会有这一版」,附录讲「这个 API 到底该不该用」。