21 · React 16 → 19 版本演进(详解版)
一句话定位:React 16 到 19 不是「API 多了几个」,而是渲染模型(Fiber → 并发)、服务端边界(SSR → RSC)、交互模型(手写 loading → Actions)、优化方式(手写 memo → 编译器) 四次代际变化叠加的结果。
读完本篇你应该能做到
- 不背特性清单,也能说清 16 / 17 / 18 / 19 各自解决了什么真问题、留下了什么坑;
- 拿到一个 React 16 的老项目,能判断升级到 19 的收益、成本、风险,并给出分阶段路线;
- 被追问「并发到底并发什么」「RSC 和 SSR 什么关系」「为什么 effect 跑两次」时,能讲到调度、序列化与 Hydration 的机制层;
- 遇到一个陌生 API(比如
renderToString),能说清它从哪来、解决什么、现在该不该用——见第 10 章附录。
0. 领域导论
0.1 这个领域解决什么问题
假设你接手一个 2019 年上线的中台:React 16.14、类组件、ReactDOM.render、componentWillReceiveProps、react-router@4。现在业务要求接入 Next.js App Router,用 Server Components 降低首屏 JS,用 Actions 统一表单提交。
你会同时撞上三类断裂:
- API 断裂:
ReactDOM.render/findDOMNode/ 字符串 ref / 函数组件的defaultProps在 19 里被移除或忽略;forwardRef不再是必需品。 - 心智断裂:从「生命周期编排」变成「渲染必须纯净 + 依赖必须诚实 + 副作用必须可重放」;StrictMode 会故意把 effect 跑两遍来抓你不纯的代码。
- 运行模型断裂:默认渲染从「同步一次性」变成「可中断、可排序、可丢弃」;服务端从「输出 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.0 | 2017-09 | Fiber 落地 | 可返回数组/字符串、Fragment、Portal、Error Boundary、renderToNodeStream |
| 16.3 | 2018-03 | 新 Context / Ref / StrictMode | createContext、createRef、forwardRef、生命周期改 UNSAFE_ 前缀 |
| 16.6 | 2018-10 | 代码分割的声明式写法 | React.lazy、Suspense、React.memo、contextType |
| 16.8 | 2019-02 | Hooks | useState、useEffect、useContext、useReducer、useMemo、useCallback、useRef |
| 16.9 | 2019-08 | 可观测与清理 | <Profiler>、act 支持异步、移除 unstable_ConcurrentMode |
| 16.14 | 2020-10 | 兼容新 JSX | 支持自动运行时(为 17 铺路) |
| 17.0 | 2020-10 | 垫脚石 | 无新特性;新 JSX 转换、事件委托到根、事件池移除、渐进升级 |
| 18.0 | 2022-03 | 并发能力可用 | createRoot、自动批处理、startTransition、useDeferredValue、流式 SSR |
| 19.0 | 2024-12 | Actions / RSC / 编译器时代 | useActionState、useFormStatus、useOptimistic、use、ref 作为 prop、文档元数据、RSC 稳定 |
| 19.2 | 2025-10 | 后台渲染与 Effect Event | <Activity>、useEffectEvent、cacheSignal、Performance Tracks、Partial Pre-rendering |
| 19.3 | 2026-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 的结构性缺陷」:
- 新 Context:老 Context(
contextTypes/getChildContext)是隐式的、且会被shouldComponentUpdate阻断传播;新 Context 用订阅机制,绕开 SCU。 createRef+forwardRef:让 ref 能穿过 HOC 与函数组件边界,不再依赖字符串 ref。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」。
它做了四件对迁移极有价值的事:
- 新 JSX 转换:不再需要
import React from 'react',产物更小。 - 事件委托从
document改到根容器:同一页面可以并存多个 React 版本,微前端与渐进升级成为可能。 - 移除事件池:
e.persist()变成 no-op,异步里读事件对象不再踩坑。 - 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.1 | captureOwnerStack、更完整的 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.0 | Fiber 是可中断渲染的地基,但并发开关还没交出来 |
| 16.3 | 新 Context 用订阅绕开 SCU,老 Context 会被阻断 |
| 16.6 | lazy + Suspense 只是代码分割,不是数据获取 |
| 16.8 | Hooks 解决逻辑复用与生命周期耦合,代价是依赖数组 |
| 17 | 不加功能,只降低升级成本(新 JSX、事件挂根、渐进升级) |
| 18 | 并发是「可选启用」;自动批处理覆盖所有更新来源 |
| 19 | Actions 管异步状态,RSC 管服务端边界,use 管渲染期读取 |
| 19.2 | <Activity> 让隐藏页面可预渲染,useEffectEvent 拆非响应式逻辑 |
| 19.3 | 视图过渡与浏览器专属子树成为一等 API |
自检题
- React 16 已经有 Fiber,为什么不能说它已经有并发渲染?——Fiber 只是把渲染拆成可中断的单元;16 没有把优先级调度、Transition、可丢弃渲染暴露给业务,真正可用是 18 的
createRoot+ 并发特性。 - React 17 为什么被称为「垫脚石」?——它不引入新能力,只做接口改造:新 JSX 转换、事件委托到根容器、移除事件池、清理时机调整,让 16 → 18 可以分两步走,并支持同页多版本。
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 只完成了「结构改造」,还缺三样东西:
- 优先级:没有 lane,无法表达「输入比列表重要」。
- 开关:没有
createRoot,业务无法主动启用可中断渲染。 - 配套能力:没有 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;
}
}
两个生命周期分工不同:
| 方法 | 调用时机 | 能做什么 | 不能做什么 |
|---|---|---|---|
getDerivedStateFromError | render 阶段 | 返回 state 触发降级 UI | 不能有副作用(不能发请求) |
componentDidCatch | commit 阶段 | 上报、打日志 | 不能 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 };
它有两个结构性缺陷:
- 隐式:值从哪来、有哪些字段,全靠运行时约定;TypeScript 也帮不上忙。
- 会被
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 的三种写法
| 写法 | 形态 | 适用 | 注意 |
|---|---|---|---|
| 回调 ref | ref={(node) => ...} | 需要在挂载时做副作用 | 每次渲染都会以新函数调用,旧实现会先 null 再 node |
createRef | ref={this.inputRef} | 类组件 | 函数组件里每次渲染都会新建,等于没用 |
forwardRef | React.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'));
拆开看:
import('./Chart')返回一个 Promise,resolve 出模块对象{ default: Chart };React.lazy接收这个函数(必须返回 Promise),产出一个「懒加载组件」;- 首次渲染时,React 调用该函数拿到 Promise,然后「挂起」这棵子树,交给最近的
Suspense显示 fallback; - 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 | 执行时机 | 适合 |
|---|---|---|
useLayoutEffect | DOM 更新后、浏览器绘制前,同步 | 测量布局、同步改样式,避免闪烁 |
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 的核心字段:
| 字段 | 含义 | 怎么用 |
|---|---|---|
phase | mount / 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 | 受限 | 完整支持 |
| 卸载 API | unmountComponentAtNode | root.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 / useTransition | useDeferredValue |
|---|---|---|
| 控制对象 | 控制「这次 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 的对比:
| 维度 | useContext | use(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 再水合」,而是在服务端把组件跑完,只把结果(一棵可序列化的树)和必要的客户端代码发给浏览器。
它解决三个具体问题:
- JS 体积:只有需要交互的组件(
'use client')才下发代码。一个「读数据库 → 展示列表」的页面可以做到 0 业务 JS。 - 数据靠近源头:服务端组件可以直接
await db.query(),不用先暴露一个 API 再在客户端请求,少一次网络往返。 - 消除请求瀑布:父组件拿到数据后再渲染子组件,天然是「服务端串行、客户端不等待」。
// 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.render | createRoot().render() | 18 起弃用,19 移除 |
ReactDOM.hydrate | hydrateRoot() | 同上 |
unmountComponentAtNode | root.unmount() | 同上 |
findDOMNode | ref | 破坏 StrictMode 与并发 |
字符串 ref(ref="x") | createRef / 回调 ref | 16.3 起不推荐 |
React.createFactory | JSX | 基本无人使用 |
函数组件 defaultProps | 默认参数 | 类组件仍可用 |
propTypes | TypeScript / 运行时校验 | 函数组件上被忽略 |
legacy context(contextTypes) | createContext | 19 移除 |
react-dom/test-utils 的 act | React.act | 测试代码需改 import |
| UMD 构建 | ESM / bundler | CDN 场景需换包 |
react-test-renderer/shallow | Testing 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 |
| Suspense | 16 只服务 lazy;18 起参与 SSR 与数据;19 配合 use 与 RSC |
| Hooks | 按逻辑组织;代价是依赖数组与闭包 |
| 17 | 升级垫脚石:新 JSX、事件挂根、无事件池 |
| 18 | createRoot 启用并发;批处理覆盖所有来源;流式 SSR |
| 19 | Actions 管异步状态;use 条件读取;RSC 稳定;旧 API 移除 |
自检题
- 为什么
createRoot是 18 的「总开关」?——只有它创建的 root 才启用并发渲染、自动批处理与 Transition;ReactDOM.render会退回 legacy 模式,行为接近 17。 - 19 的
use和useContext有什么本质区别?——use可以条件调用(early return 之后也能用),且能读取 Promise 并触发 Suspense;useContext必须无条件调用,只能读 Context。 - 为什么说「上了 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、首次渲染 | 中 |
TransitionLanes | startTransition / 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/17 | 18/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 | 触发新的渲染 |
| Layout | DOM 改完、绘制之前 | 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 / 动态 import | chunk 未加载完时显示 fallback | 16.6 |
| 数据读取 | 框架 loader / use(promise) / RSC | 依赖未就绪时挂起,边界内显示 fallback | 18 / 19 |
| 流式 SSR | renderToPipeableStream | 先发壳,边界内容就绪后追加替换 | 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 和一次额外的完整渲染。
| 代际 | 做法 | 解决什么 | 遗留问题 |
|---|---|---|---|
| 16 | ReactDOM.hydrate 全量、一次性 | 首屏有内容 | JS 未到前不可交互;一处不匹配整树重建 |
| 18 | hydrateRoot + 选择性 / 并发 hydration | Suspense 边界可独立水合;可优先水合用户正在交互的区域 | 仍要求首帧一致 |
| 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 的本质区别:
| 维度 | SSR | RSC |
|---|---|---|
| 产出 | HTML 字符串 | 可序列化的组件树(payload) |
| 客户端拿到什么 | 已经画好的 DOM | 结构描述 + 少量客户端组件 |
| 是否下发组件代码 | 全部要下发(否则无法水合) | 只有 'use client' 的部分下发 |
| 能否直接读数据库 | 能(但代码仍会进客户端包) | 能,且代码不进客户端包 |
| 解决的核心问题 | 首屏可见 | 首屏 JS 体积与数据获取位置 |
请求 → 服务端执行 Server Components(可 await 数据库)
→ 产出 RSC payload(元素类型、props、客户端组件引用)
→ 客户端 React 消费 payload,构建树
→ 只有 'use client' 的模块会下发 JS
payload 的结构:它是一份「分块传输的指令流」,不是 JSON 文件。大致包含四类信息:
- 元素:类型(
div、article)、props、children; - 客户端组件占位:模块 id + props(客户端据此
import真正的组件); - 文本与 Suspense 边界:哪些片段先到、哪些后补;
- Server Action 引用:一个可被客户端调用的「函数句柄」,不是函数体。
四条边界规则,以及它们为什么存在:
'use client'是模块边界:因为 bundler 需要一条静态可分析的线来决定「哪些模块进客户端包」。它只能画在文件粒度上,所以标了之后,整个模块及其导入都会下沉。- props 必须可序列化:payload 要跨网络传输,还要跨「服务端运行时 → 浏览器」的语言边界。函数、类实例、
Symbol、DOM 节点都无法表达;Server Action 例外,因为它传的是引用(一个 id),不是函数体。 - Server Component 不能用状态与副作用:它只执行一次(或在缓存周期内执行),没有「重新渲染」这回事,所以
useState、useEffect、DOM API、window全都不存在。 '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 在构建期做数据流分析,把「跨渲染可以复用」的计算、回调、元素自动缓存起来,让「默认写法」就接近「手写优化过的写法」。
它在做什么(原理层):
- 分析组件 / Hook 里每个表达式依赖了哪些值(props、state、模块常量、闭包变量);
- 给「可复用」的结果分配缓存槽(相当于自动生成
useMemo/useCallback/memo); - 只有依赖变化时才重算,否则复用上次结果;
- 生成的代码只依赖一个极小的运行时辅助函数,不需要你改写法。
// 你写的
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、状态管理或服务端缓存。 - 手写与编译器不冲突,编译器会尽量复用你已有的缓存。
落地顺序:
- 升级
eslint-plugin-react-hooks(v6 起包含编译器规则),先修违规; - CI 里跑静态检查,只报告不开启编译;
- 选 1–2 个纯展示目录开启,观察构建时间与运行时表现;
- 全量开启后,再逐步删掉「纯性能目的」的
useMemo/useCallback; - 用 Profiler 对比 commit 次数与
actualDuration,确认收益真实存在。
常见坑:
- 以为「不用再管性能」:慢在 commit、慢在数据、慢在算法,编译器都帮不上。
- 不看 lint:违规代码会被静默跳过,你以为优化了,其实没有。
- 调试时遇到「缓存了旧闭包」:先查依赖是否被正确分析(用了 ref、可变对象、外部变量)。
- 构建时间上升:编译期分析有成本,大项目要评估 CI 时长与增量编译表现。
3.10 L3 小结与自检
- 为什么 Transition 能改善输入卡顿,却不一定减少总渲染时间?——它把低优先级更新变成可中断、可丢弃的渲染,让高优先级输入先提交;总工作量可能不变,但关键路径变短。
- 为什么 Suspense 边界内的多个请求容易形成瀑布?——边界会等待其内部所有挂起资源就绪后才揭示;请求若在边界内串行发起,就会一层等一层,需要提前并行预取。
- 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 → 19 | codemod 后无旧 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 在并发下失效,被移除 |
排查顺序(按成本从低到高):
- 先看控制台:root 重复、ref 类型、水合 diff、
UNSAFE_警告,这些都有明确提示; - 再看请求数:双执行导致的重复请求最容易被误判为「后端变慢」;
- 对比渲染次数:用 Profiler 统计 commit 次数,确认是「批处理让次数变少」还是「并发让次数变多」;
- 最后查时序:只有前三条都排除,才去怀疑「cleanup 异步」「事件顺序」这类细微差异。
4.3 性能治理:从「手写 memo」到「度量驱动」
三代优化手段的定位:
| 时代 | 主要手段 | 局限 |
|---|---|---|
| 16 | PureComponent、shouldComponentUpdate、memo | 依赖人工判断,容易漏 |
| 18 | Transition、useDeferredValue、流式 SSR、useId | 只解决「优先级」,不解决「重复计算」 |
| 19 | Compiler 自动 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 Router | RSC / 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 组织级决策:什么时候「不该升」
升级不是默认正确。用四个问题过滤:
- 收益是否可量化:INP 能否从「差」到「良」?首屏 JS 能否降 30%?表单代码能否减少一半?
- 成本是否可控:测试矩阵、第三方库、SSR 基础设施、团队熟悉度。
- 风险是否可回滚:能否按子应用/路由灰度?能否在一天内回退?
- 是否被生态倒逼:新依赖只支持 18+、框架要求 19、安全修复只在新版本。
结论模板:「在 Q3 完成 16 → 18,覆盖 3 个核心子应用,目标 INP P75 从 320ms 降到 200ms;19 的 RSC 作为 Q4 的 POC,不进入主链路。」
4.6 L4 自检
- 一条 16 → 19 的迁移路线,你会怎么分段并设置验收点?——先摸底旧 API 与依赖,再 16→17(新 JSX、事件挂根)→18(
createRoot、StrictMode、批处理验证)→19(codemod、删旧 API、按需 Actions/RSC);每段以「核心流程回归 + 错误率 + INP」为验收,且可独立回滚。 - 团队说「升级后感觉变慢了」,你怎么定位?——先分清是「渲染次数变化」「批处理导致的观测差异」还是「并发下的重复 render」;用 Profiler 对比 commit 次数与 actualDuration,用 RUM 看 INP/LCP,再判断是 Transition 用错、StrictMode 双执行、还是 Compiler 未生效。
- 什么时候应该拒绝上 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为refprop(可保留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 秒骨架:按「渲染模型 → 服务端边界 → 交互模型」三层答,每层给一句「解决了什么现象」。
- 展开:
- 渲染模型:16 的 Fiber 把递归换成链表,让渲染可中断;18 用
createRoot+ lane 把「可中断」变成业务可用,解决输入卡顿(INP)。注意 16 只是地基,不能说 16 有并发。 - 服务端边界:从「SSR 输出 HTML」到「RSC 输出可序列化的树」,只有交互组件下发 JS,解决首屏 JS 体积与数据获取瀑布。
- 交互模型:从「每个表单手写 pending / error / 乐观值」到 Actions 状态机,减少样板代码与状态不一致。
- 渲染模型:16 的 Fiber 把递归换成链表,让渲染可中断;18 用
- 常见追问:「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不改变更新来源,只让某个值滞后一帧,适合「输入即时、结果慢半拍」。
- 要点:Transition 标记「这次更新不紧急」,可被打断;
链 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 与个性化内容,避免整页动态化。
- 要点:把静态壳预渲染到 CDN,动态部分用
链 3 · Actions → 乐观更新 → 幂等与回滚
- 面试官:Actions 帮我们省了什么?
- 要点:pending、error、表单重置、乐观更新的标准状态机;
useActionState/useFormStatus/useOptimistic分工明确。 - 追问:乐观更新失败后怎么回滚?
- 要点:
useOptimistic在 Action 结束(成功或失败)后自动回落到真实值;失败时同时把错误交给 Error Boundary 或返回 state。 - 再追问:用户双击提交怎么办?
- 要点:pending 期间禁用按钮只是 UI 层;服务端必须用幂等键/唯一约束兜底,客户端可用请求去重。
- 要点:
- 追问:Actions 和普通
onSubmit的本质区别?- 要点:Actions 运行在 transition 中,可被打断、可管理 pending,并能与服务端 Action 直接对接;普通
onSubmit是同步事件回调,状态要自己管。
- 要点:Actions 运行在 transition 中,可被打断、可管理 pending,并能与服务端 Action 直接对接;普通
- 要点:pending、error、表单重置、乐观更新的标准状态机;
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 周摸底:统计旧 API、依赖 peerDependencies、核心流程清单;建立 INP/LCP/错误率基线。
- 第 2–3 周升 17:新 JSX、事件挂根;先让微前端能按子应用灰度。
- 第 4–5 周升 18:
createRoot、StrictMode;修双执行问题;用 Profiler 找最贵的 5 个页面。 - 第 6–7 周升 19:codemod、删旧 API、改
forwardRef/defaultProps;升级测试工具。 - 第 8 周按需启用:3 个重列表页用 Transition/虚拟化;3 个高频表单用 Actions;Compiler 先在纯展示模块开启。
- 验收: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-hooksv6 规则说明。 - Next.js App Router 文档:RSC 与 Server Actions 的工程化落地。
- 配套阅读:本库
06 框架与组件化(Hooks 与协调)、08 路由与渲染架构(SSR/流式)、11 性能优化(INP 与长任务)、09 工程化与构建(codemod 与 Monorepo 升级)。
10. 附录:核心 API 逐个拆解
正文按「版本演进」讲,很多 API 只来得及提一句「19 新增」「18 弃用」。这个附录换一个视角:一个 API 一个 API 地拆,每个都回答同样八个问题——
- 它是什么:输入什么、输出什么、在哪运行、返回什么东西;
- 它长什么样:先跑一个最小例子,看清产物;
- 它从哪来:在它之前人们怎么写,为什么那样写不下去;
- 哪个版本:引入、变更、弃用、移除,每一版为什么变;
- 内部大致怎么工作:不需要读源码,但要能解释现象;
- 主要用在哪:真实场景 + 它在主流框架里扮演什么角色;
- 坑:现象 → 原因 → 修法;
- 现在该不该用:不用它用什么。
读法建议:先读 10.1(renderToString),它是本章的样板——后面每个 API 都按同样的层次拆,只是篇幅短一些。如果你只想知道「某个 API 还能不能用」,直接看每节最后的选型表和索引表。
10.0 先给 API 分层:它们解决的不是同一类问题
初学者最容易把「SSR 的 API」和「渲染的 API」混在一起背,结果记了一堆名字却不知道什么时候该用谁。先按它作用在哪一层分类,记忆量立刻减半:
| 层 | 这一层要解决的问题 | 代表 API | 一句话 |
|---|---|---|---|
| 交付层(服务端 → 浏览器) | 怎么把 React 树变成 HTML / 流,什么时候发出去 | renderToString、renderToStaticMarkup、renderToPipeableStream、renderToReadableStream、prerender | 决定「用户多久看到内容」 |
| 入口层(React 接管哪个 DOM) | 从哪开始渲染、是新建还是接管已有 HTML | ReactDOM.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 | 引入 | 现状 | 一句话定位 | 详见 |
|---|---|---|---|---|
renderToString | 0.4(当时叫 renderComponentToString) | 仍在,但不再是首选 | 同步把 React 树转成 HTML 字符串 | 10.1 |
renderToStaticMarkup | 0.4 | 仍在 | 转成「纯静态」HTML,不能水合 | 10.2 |
renderToNodeStream | 16 | 18 弃用、19 移除 | 传输层流式输出 | 10.3 |
renderToPipeableStream | 18 | 推荐(Node) | 按 Suspense 边界流式输出 | 10.3 |
renderToReadableStream | 18 | 推荐(Edge / Web Streams) | 同上,Web Streams 版本 | 10.3 |
prerender / prerenderToNodeStream | 19 | 新增 | 等所有边界就绪,输出完整静态 HTML | 10.3 |
ReactDOM.render | 0.14 | 19 移除 | legacy 同步入口 | 10.4 |
createRoot | 18 | 推荐 | 并发入口 | 10.4 |
ReactDOM.hydrate | 16 | 19 移除 | 旧版水合 | 10.4 |
hydrateRoot | 18 | 推荐 | 并发水合 | 10.4 |
createPortal | 16 | 仍在 | DOM 换位置,React 树不变 | 10.5 |
createContext / useContext | 16.3 / 16.8 | 仍在 | 跨层传值,绕开 SCU | 10.6 |
createRef / forwardRef | 16.3 | 仍可用(19 非必需) | 把 ref 透传到内部节点 | 10.7 |
useRef / useImperativeHandle | 16.8 | 仍在 | 保存可变值 / 暴露命令式接口 | 10.7 |
lazy + Suspense | 16.6 | 能力持续扩展 | 代码分割与异步边界 | 10.8 |
memo | 16.6 | 仍在 | props 浅比较缓存 | 10.9 |
useMemo / useCallback | 16.8 | 仍在 | 手动缓存值与函数 | 10.9 |
startTransition / useTransition | 18 | 19 支持异步 | 标记「不紧急」的更新 | 10.10 |
useDeferredValue | 18 | 仍在 | 让某个值慢半拍 | 10.10 |
flushSync | 18 | 仍在 | 强制同步刷新 | 10.10 |
useId | 18 | 仍在 | SSR 稳定的唯一 ID | 10.11 |
useSyncExternalStore | 18 | 仍在 | 安全订阅外部 store | 10.11 |
useInsertionEffect | 18 | 仍在 | 布局前注入样式 | 10.11 |
useActionState | 19 | 新增(原名 useFormState) | 表单提交状态机 | 10.12 |
useFormStatus | 19 | 新增 | 读取父级 form 的 pending | 10.12 |
useOptimistic | 19 | 新增 | 乐观更新 + 自动回滚 | 10.12 |
use | 19 | 新增 | 渲染期读取 Promise / Context | 10.12 |
preload / preinit / prefetchDNS / preconnect | 19 | 新增 | 资源提示 API | 10.13 |
Profiler | 16.9 | 仍在 | 采集子树渲染耗时 | 10.14 |
<Activity> | 19.2 | 新增 | 后台预渲染 / 隐藏页面保活 | 10.15 |
useEffectEvent | 19.2 | 新增 | 把「事件逻辑」从依赖数组里拆出来 | 10.15 |
captureOwnerStack | 19.1 | 新增 | 追溯「这个组件是谁渲染的」 | 10.15 |
<ViewTransition> / addTransitionType | 19.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-06 | React 首次公开,React.renderComponentToString | 名字里的 Component 是当时 renderComponent 家族的一部分 |
| 2013 前后 | 0.4 / 0.5 | 服务端渲染已经是官方卖点之一,用来解决上面的四个痛点 |
| 0.12(2014-10) | 改名 React.renderToString | renderComponent 统一简化为 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 的发布说明写得很直白:
renderToStringwill 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
renderToStringmethod 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>`);
});
这条链路里有三个「必须自己扛」的责任,框架帮你做了,所以很多人没意识到:
- 数据预取:
renderToString不等任何异步数据,读不到就是undefined或骨架; - 文档拼接:它只输出组件对应的片段,
<!doctype>、<html>、<head>全归你; - 状态传递:服务端算出来的数据不会自动带到客户端,必须自己序列化。
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 直接不输出
④ 转义文本与属性
& → &,< → <,> → >,引号 → "
—— 这是 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
| 项 | 说明 | 容易踩的点 |
|---|---|---|
element | React 元素,如 <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 | 输出 | 能水合吗 | 会等数据吗 | 典型用途 |
|---|---|---|---|---|
renderToString | HTML 字符串 | 能 | 不等(输出 fallback) | 简单 SSR、SSG、测试 |
renderToStaticMarkup | HTML 字符串 | 不能 | 不等 | 邮件、PDF、静态片段 |
renderToPipeableStream | Node 可写流 | 能 | 按边界等 | 面向用户的动态页 |
renderToReadableStream | Web 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 条展开 |
| 3 | window is not defined | 组件体里直接访问浏览器全局 | 移到 useEffect,或用 typeof window !== 'undefined' 守卫 |
| 4 | 首屏闪一下、内容重排 | 水合失败后 React 丢弃服务端 HTML 重建 | 定位不一致来源(时间/随机数/登录态),不要靠 suppressHydrationWarning 掩盖 |
| 5 | 数据要请求两次 | 忘了把服务端数据序列化进 HTML | 用 __INITIAL_STATE__ 或框架的 loader 传下去 |
| 6 | XSS(页面被注入脚本) | 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 自测:三个问题
- 为什么
renderToString的输出里没有onClick?如果一定要让按钮能点,缺的是哪一步? - 为什么它遇到 Suspense 只输出 fallback,而
renderToPipeableStream能等到数据?差别在「返回值形态」还是「调度能力」? - 线上报「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 在遍历时要做两件额外的事:
- 给每个可能参与水合的边界打标记(相邻文本分隔、Suspense 边界、根节点);
- 维护一张「这棵树长什么样」的隐式账本,供客户端对齐。
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 常被笼统地叫「流式渲染」,但它们其实回答了两个不同的问题:
- 什么时候把第一块字节发出去?(传输层)
- 慢的那部分内容,能不能晚点补上?(数据层)
先记住这个二维坐标,后面每个 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 的三个差异:
| 维度 | renderToPipeableStream | renderToReadableStream |
|---|---|---|
| 返回值 | { 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 | 出错兜底 | 版本 |
|---|---|---|---|---|---|
renderToString | Node / 任意 JS | 整棵树算完 | 否(发 fallback) | 整次失败 | 0.4 |
renderToStaticMarkup | Node / 任意 JS | 整棵树算完 | 否 | 整次失败 | 0.4 |
renderToNodeStream | Node | 边算边发 | 否 | 断连接 | 16 → 19 移除 |
renderToPipeableStream | Node | 壳就绪 | 是 | onShellError / onError / abort | 18 |
renderToReadableStream | Edge / Web | 壳就绪 | 是 | Promise reject | 18 |
prerender / prerenderToNodeStream | Node / Web | 全部就绪 | 是 | Promise reject | 19 |
一句话记:16 流的是字节,18 流的是边界,19 补了一个「等全部」的静态模式。
10.3.7 流式的三个常见误解
| 误解 | 事实 |
|---|---|
| 「用了流式,页面就一定更快」 | 流式优化的是 TTFB 与首屏可见,不减少总计算量;如果瓶颈在数据库,先治数据 |
| 「流式取代了 SSR 的其它部分」 | 数据预取、缓存、状态序列化、水合一致性,一个都不能少 |
| 「流式 = 渐进式水合」 | 流式解决「内容何时到」,选择性水合解决「哪块先可交互」,两者常配合但不是一件事 |
10.4 入口四兄弟:ReactDOM.render / createRoot / hydrate / hydrateRoot
这四个 API 决定「React 从哪个容器开始接管页面」,也是升级时改动最集中的地方。它们的分歧只有一个:是「新建一棵树」,还是「认领已有的 HTML」;以及用 legacy 模式还是并发模式。
| 维度 | ReactDOM.render | createRoot | ReactDOM.hydrate | hydrateRoot |
|---|---|---|---|---|
| 首次渲染 | 新建 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 上报 |
| 19 | hydrate 移除,统一用 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) | ref | 19 移除 |
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),直接调度消费者
由此推出三个行为:
- 中间层
memo挡不住 Context 更新:因为通知是直接发给消费者的,不经过中间层; - 消费者自己
memo也挡不住:useContext让组件订阅了它,值变就必须重渲染; - provider 的 value 用
Object.is比较:所以「每次渲染新建对象」= 每次都是新值 = 全部消费者重渲染。
10.6.4 版本
| 版本 | 变化 |
|---|---|
| 16.3 | 引入新 createContext / Provider / Consumer / contextType |
| 16.8 | useContext 出现,函数组件可直接读 |
| 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 | 很早 | 任意组件 | 挂载/卸载时执行副作用,最灵活 |
createRef | 16.3 | 类组件 | 创建一个「可变的引用盒子」 |
useRef | 16.8 | 函数组件 | 同上,且能在渲染之间保持 |
forwardRef | 16.3 | 组件库 / HOC | 把外部 ref 透传到内部 DOM |
useImperativeHandle | 16.8 | 组件库 | 只暴露「最小接口」,不交出整个 DOM |
| ref 作为普通 prop | 19 | 函数组件 | 省掉 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)
三个推论:
- render 期间
ref.current还是旧值(或null),不能拿它算渲染结果; - ref 更新不会触发重渲染:它不在 React 的依赖系统里;
useImperativeHandle在提交阶段执行:所以外部在useEffect里能拿到接口,在 render 里拿不到。
10.7.6 主要用在哪
- 焦点管理、表单校验定位;
- 测量与虚拟滚动(
ResizeObserver+ ref); - 与第三方库桥接(把 DOM 交给它,卸载时销毁);
- 保存「不需要触发渲染」的值:定时器 id、上一次的值、是否首次渲染;
- 组件库暴露命令式接口(
focus/scrollTo/play)。
10.7.7 坑
| 现象 | 原因 | 修法 |
|---|---|---|
改了 ref.current,UI 不更新 | ref 不是响应式的 | 需要驱动 UI 的值用 state |
渲染时读到 null | ref 在 render 阶段还没赋值 | 放到 useEffect / useLayoutEffect |
useRef(expensive()) 每次都算 | 初始值表达式每次渲染都会执行 | 改 useRef(null) + 惰性初始化 |
| 函数组件上传 ref 没反应 | 16–18 需要 forwardRef | 升级 19 或包 forwardRef |
| 19 里回调 ref 返回了值导致 TS 报错 | 返回非函数会被当成 cleanup | 用块级函数体 { node => { ... } } |
forwardRef 组件在 DevTools 里叫 ForwardRef | 没设 displayName | Comp.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 的异步支持配合更好 |
| 18 | Suspense 支持数据读取与流式 SSR;可选择性水合 |
| 19 | use(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 不支持 SSR | 16/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.3 | PureComponent | 类组件每次 setState 都重渲染,需要手动浅比较 |
| 15.x | shouldComponentUpdate | 逻辑要手写,容易漏字段、写错方向 |
| 16.6 | React.memo | 函数组件没有 shouldComponentUpdate,需要等价能力 |
| 16.8 | useMemo / 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)
两个常被忽略的后果:
- 缓存也会被丢弃:React 可以在内存压力下丢掉缓存(官方明确不保证语义),所以不能把
useMemo当「只执行一次」用; 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)→ 可以被打断;新输入来了,旧计算直接作废
三个关键行为:
- 可中断:渲染大列表时,用户又敲了字,React 会放弃当前渲染、先处理输入;
- 可丢弃:旧的过渡结果如果已经被新输入取代,直接不提交(不会出现「结果闪回」);
- 不改变结果:算完之后,UI 与「直接同步算」完全一致,只是「什么时候算、算几次」变了。
flushSync 正好相反:它把更新降级为同步、不可中断,并立刻提交。用于「必须马上读 DOM」的少数场景(例如第三方库测量尺寸),滥用等于关掉并发。
10.10.4 版本
| 版本 | 变化 |
|---|---|
| 18 | 引入 startTransition / useTransition / useDeferredValue / flushSync |
| 19 | startTransition 支持 async 函数,isPending 能跨 await 保持;useDeferredValue 新增 initialValue 参数 |
| 19.3 | Transition 之间不再相互纠缠:一个慢过渡不会拖住无关的过渡 |
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 |
| 路由跳转 | 框架内部用 Transition | Next.js / React Router 已封装 |
| 必须同步读 DOM | flushSync | 只在事件回调里用,且尽量少用 |
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 | 包 | 管什么 | 典型位置 |
|---|---|---|---|
useActionState | react | 提交结果 state + isPending + 包装后的 action | 提交表单的组件 |
useFormStatus | react-dom | 读取最近父级 <form> 的 pending | 设计系统里的按钮 |
useOptimistic | react | 乐观值 + 结束自动回滚 | 点赞、收藏、乐观列表 |
use | react | 渲染期读取 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 形式出现 |
| 19 | useFormState 正式改名 useActionState;useOptimistic、use 稳定 |
| 19.2 | prerender + 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
10.13.1 从哪来:手写 <link> 的三个问题
这些能力以前靠手写:
<link rel="preload" href="/fonts/inter.woff2" as="font" crossorigin>
<link rel="preconnect" href="https://api.example.com">
问题:
- 组件里没法方便地「提升到 head」:你只能在组件里写
<link>,但它出现在 body 中间,浏览器发现得晚; - 流式渲染时插入太晚:内容一段段发出去,等你渲染到那个组件,首屏资源早就该下载了;
- 容易漏属性:
crossorigin漏了,字体/图片会下载两次。
19 把它们变成 API,React 负责提升到文档头部、去重、按优先级插入——即使你在很深的组件里调用。
10.13.2 四个的区别
| API | 浏览器行为 | 适合 | 代价 |
|---|---|---|---|
prefetchDNS | 只解析域名 | 可能会访问的第三方域 | 极低 |
preconnect | DNS + 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 | 你给这个边界的名字 | 上报时区分页面/模块 |
phase | mount / 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,按这五步问自己:
- 它在哪一层——交付(SSR)、入口(root)、调度(优先级)、数据(context/store)、还是边界(portal/ref)?
- 它替代了谁——这个「前身」往往就是它存在的理由(
render→createRoot、hydrate→hydrateRoot、字符串 ref →useRef); - 它的产物/返回值是什么形状——字符串、流、Promise、
{ pipe, abort }、还是[state, action, isPending]?形状决定了它能做什么、不能做什么; - 它现在是什么状态——推荐 / 可用但不推荐 / 弃用 / 已移除(本章索引表第二列);
- 不用它用什么——迁移路径比 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 到底该不该用」。