全景:React 的定位与现状
钻进 API 之前先回答三个问题:它的心智模型是什么、生态走到了哪里、四大框架怎么选。
React 的立身之本:UI 是状态的函数——状态一变,组件函数整个重新执行,产出新的虚拟 DOM,由 React diff 后把最小差异提交到真实 DOM。
心智模型一句话
- 重渲染是常态:每次
setState都会重跑组件函数,React 对比前后两棵虚拟 DOM 树,只更新变化的部分; - React 的大部分心智负担(依赖数组、不可变更新、memo)都源自「函数会反复执行」这一前提——这正是 Svelte / Solid 选择另一条路的地方。
和另外三家怎么选
- 本页赢在最大的生态、人才市场与 RSC 全栈方向,输在运行时开销和「多余重渲染」的优化心智;
- Vue 渐进式、上手曲线更平;Svelte 产物最小、代码量最少;Solid 无重渲染但生态最小;
- 大团队、长周期、重度依赖第三方库的项目,选 React 最稳。
import { useState } from "react";
// React 的心智模型:状态一变,整个函数重新执行
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(c => c + 1)}>
点击 {count} 次
</button>
);
}componentWillMount 系列、Enzyme、create-react-app、Pages Router 的 getServerSideProps——这些在新项目里都不该再出现,却仍占据着搜索结果的前排。use() 与 React Compiler 是官方主推方向。React 的一切优化手段都围绕「重渲染」展开,抓住这条主线就不会迷路。这一页后面所有内容都是一条式子的推论:界面是状态的函数。状态一变,React 把整个组件函数从头重跑一遍,拿新返回的 JSX 和上一次比对,只把差异写进真实 DOM。你从头到尾没有写过一行「找到那个元素然后改它」的代码。
「重跑」到底重跑了什么
- 函数体里每一行都重新执行:局部变量重新计算,事件处理函数是全新的一个;
- 只有存在 Hook 里的东西能跨渲染活下来(03 章)——这就是为什么普通变量存不住状态;
- 所以能算出来的数据不要存成 state:总价、筛选后的列表、按钮该不该禁用,当场算。多存一份就多一个「两处数据对不上」的机会。
后面每一章都是这条式子的推论
| 你将学到的规则 | 它其实是这条式子的哪一面 |
|---|---|
| 不能原地改数组和对象,要建新的(03 章) | 只有 f 的输入换了 React 才知道要重跑,原地改动它看不见 |
useEffect 要写依赖数组(05 章) | 函数重跑,里面的 effect 自然也重跑,得有办法说清什么时候才该重跑 |
| 事件处理函数读到的是「那一次渲染时」的值(03 章) | 每次渲染都是一份独立快照,闭包捕获的是当时那一份 |
key 决定状态跟着哪一项走(03 章) | 重跑产生的是全新一棵描述树,React 得有依据把旧状态对上新位置 |
import { useState } from "react";
function Cart() {
// state 是唯一的输入,其余全是从它现算出来的
const [items, setItems] = useState([
{ name: "键盘", price: 399, qty: 1 }
]);
// 派生数据:不要用 useState 再存一份,每次渲染现算
const total = items.reduce((s, i) => s + i.price * i.qty, 0);
const empty = items.length === 0;
console.log("组件函数跑了一次,total =", total);
return (
<div>
<p>{empty ? "购物车是空的" : `合计 ${total} 元`}</p>
<button onClick={() => setItems(list => [
...list, { name: "鼠标", price: 99, qty: 1 }
])}>加购</button>
</div>
);
}
// 点一下按钮,日志 total 从 399 变 498,DOM 上的文字自己跟着变了。
// 你没写过一行「找到 p 标签再改 textContent」的代码 —— 这就是 f(state)。console.log 反复打印就以为组件在疯狂重建,于是到处套 memo——绝大多数情况下重跑一次函数的开销可以忽略不计,过早优化换来的是一堆看不懂的代码(见 09 章)。useState。这一条能预防掉新手一半以上的状态 bug——两份数据只要存在,迟早会对不上。上手:跑通第一个 React 应用
先把东西跑起来再谈原理。这一章解决建项目、看懂入口文件,以及配齐能救命的开发工具。
React 官方自己不提供脚手架了——它把「怎么建项目」整个交给了框架。所以写第一行代码之前只需要做一个决定:这个应用需不需要服务端。
两条路,先选一条
- 纯前端应用(后台管理、内部工具)→ Vite:
npm create vite@latest my-app -- --template react,冷启动到可用是秒级; - 要 SEO、要服务端渲染、要在同一仓库写后端接口 → Next.js:
npx create-next-app@latest。
create-react-app 已经退场
- React 官方 2025 年初宣布它退役,文档里已经没有它;问题不只是不再更新——构建链慢、依赖树锈死,也接不上文件路由与服务端组件;
- 老教程里那条命令今天照样能跑完,这正是它危险的地方:你看不到任何警告,只是从第一天起就落后了两代。
# ① 纯前端应用 —— Vite(create-vite 9.1.1 → vite 8 + react 19.2)
npm create vite@latest my-app -- --template react
cd my-app
npm install
npm run dev # 默认 http://localhost:5173
# 产出里你真正要动的就这几个:
# index.html vite.config.js .oxlintrc.json
# src/main.jsx src/App.jsx src/index.css
# 其余是可删的样例:src/App.css、src/assets/、public/、README.md
# ② 需要 SEO / 服务端渲染 / 同仓库写接口 —— Next.js(16.2.11)
npx create-next-app@latest my-app
cd my-app
npm run dev # 默认 http://localhost:3000
# ③ 下面这条今天还能跑完,但请不要用:
# npx create-react-app my-app 最后一版停在 5.1.0,官方已宣布退役create-react-app 已经退场——官方文档 2023 年起就不再推荐它,仓库也已归档。但它仍占据着大量搜索结果与旧教程的前排;照着建出来的项目一上手就是过时的构建链,看到这个命令直接换一篇。React 应用的入口只有三个文件,关系是一条直线:空 div → main.jsx 接上去 → App.jsx 才是你写的地方。
三个文件各干什么
index.html——整个应用唯一的 HTML 文件,里面只有一个<div id="root">。这就是「单页应用」的字面意思;main.jsx——createRoot(...).render(...),写完就再也不动;App.jsx——你的第一个组件。组件就是一个返回 JSX 的函数,没有 class、没有继承、没有生命周期要背。
组件是被 React 调用的,不是被你调用的
- 你写
<App />,这只是描述「这里该有一个 App」,由 React 决定什么时候、调用多少次; - 所以几乎永远不该自己写
App():画面看着一样,但它的 Hook 会被算到调用者头上,状态全部错位,而且不会报错; - 组件名必须大写开头,这不是风格约定而是语法。
// index.html —— 整个应用唯一的 HTML,只留一个空壳
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
// src/main.jsx —— 把 React 接到那个空 div 上,全应用只做这一次
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
createRoot(document.getElementById("root")).render(
<StrictMode><App /></StrictMode> // 只包最外一层,且只在开发环境生效
);
// src/App.jsx —— 组件 = 返回 JSX 的函数。名字必须大写开头
export default function App() {
const user = { name: "张三", unread: 3 };
return ( // 漏掉 return 不报错,只是一片空白
<main>
<h1>你好,{user.name}</h1> // {} 里放 JS 表达式
<p>你有 {user.unread} 条未读</p>
</main>
);
}const App = () => { <h1>你好</h1> }。大括号是函数体,那行 JSX 只是个被丢弃的表达式,函数返回 undefined,页面全白且一条报错都没有。main.jsx 当成一次性的接线工作:写完就别再往里加东西。见过很多项目在入口文件里堆全局初始化、埋点、主题设置,结果它成了唯一一个「谁都不敢动」的文件。这些全部应该放进 App 里或它下面的组件。React 的报错写得比大多数库都体贴。真正要额外装的只有两样:一个浏览器扩展,和一组 lint 规则。
React DevTools:先装这个
- Components 面板:看组件树,选中任一组件就能看到它当前的 props 和 state,还能就地改 state 值立刻看界面变化——调试空态、错误态时不用再去造数据;
- Profiler 面板:录一段交互,回放时能看到每次更新里哪些组件重渲染了、各花多久。「为什么这个组件又跑了」只能靠它回答(09 章)。
hooks 的 lint 规则要开全
两条规则抓的都是肉眼几乎看不出来的错:rules-of-hooks(条件调用 hook)与 exhaustive-deps(依赖数组漏项)。Vite 8 的 React 模板自带 oxlint,配置里只开了前者,请自己补上后者(ESLint 项目则装 eslint-plugin-react-hooks)。exhaustive-deps 值得听话,因为它抓的是闭包读到旧值这一整类 bug——effect 里用了某个变量却没写进依赖,它就永远读到第一次渲染时的值。这种 bug 不报错、不崩溃,只是数据莫名其妙不对(05 章)。
// .oxlintrc.json —— Vite 8 的 React 模板只开了第一条
{
"plugins": ["react"],
"rules": {
"react/rules-of-hooks": "error",
"react/exhaustive-deps": "error" // ← 这行要自己加
}
}
// 下面这个组件,两条规则各抓到一处(oxlint 1.74)
function Room({ roomId }) {
const [msg, setMsg] = useState("");
useEffect(() => {
console.log("连上", roomId); // 用了 roomId
}, []); // exhaustive-deps: missing dependency: 'roomId'
// 后果:换房间后这里永远还在连第一个房间
if (msg) { useState(1); } // rules-of-hooks: called conditionally
return <p>{msg}</p>;
}exhaustive-deps 报警时删依赖或加 eslint-disable 是把 bug 埋起来——正确应对是改结构(05 章)。Element type is invalid,直接去看 import 那几行:九成是默认导出配了具名导入。JSX 与组件
JSX 不是模板语言而是函数调用的语法糖。看清它编译成什么,组件、props 与组合的规则就全都是普通 JavaScript 的规则。
JSX 不是 React 的一部分,它是一段在构建时就被彻底编译掉的语法。理解它最快的办法不是读文档,是看编译产物——一段 <h1 className="t">{title}</h1> 出来之后就是一次再普通不过的函数调用。
产物
用 esbuild 以 automatic runtime 编译(和 Vite、Next 生产构建用的是同一套规则):
- 源码
return <h1 className="t" data-on={active}>{title}</h1> - 产物
jsx("h1", { className: "t", "data-on": active, children: title }),文件顶上自动多出一行import { Fragment, jsx, jsxs } from "react/jsx-runtime" - Fragment
<><a/><b/></>编译成jsxs(Fragment, { children: [jsx("a", {}), jsx("b", {})] })
子节点是一个时用 jsx,多个时用 jsxs(s 表示 static children,React 借此知道这批子节点的数量在编译期就定死了,可以少做一些检查)。属性名原样变成对象的 key,children 也只是其中一个普通的 key——这一点很快会用上。
为什么现在不用写 import React 了
- React 17 之前是 classic 模式,产物是
React.createElement("h1", { className: "t" }, title)。这行代码里出现了React这个标识符,所以每个.jsx文件顶上都必须有import React from "react",否则运行时报ReferenceError: React is not defined。 - React 17 起的 automatic runtime 改成由编译器自动插入
import { jsx } from "react/jsx-runtime"。React这个名字不再出现在产物里,那一行 import 就成了纯粹的多余。 - 老教程里照抄那一行不会报错,只是没用。要用
useState之类当然还是得 import——那是另一回事。
「React 元素」是一个对象,不是 DOM
jsx(...)返回的只是一个描述对象:类型是什么、属性有哪些、子节点是谁。它极其便宜,创建一百万个也不会碰 DOM 一次。真正把它变成 DOM 的是createRoot().render()(见 01 章)。- 元素创建之后不可变。想让界面变,唯一的办法是重新渲染、生成一批新元素——这正是 UI = f(state) 在底层的样子(见 01 章)。
- 既然元素只是普通的值,就可以存进变量、放进数组、当参数传给函数。本章后面的「组合优于继承」全部建立在这一条上。
大写小写的差别,就写在产物里
<Btn /> 编译成 jsx(Btn, {})——第一个参数是变量;<btn /> 编译成 jsx("btn", {})——第一个参数是字符串。编译器只看首字母是不是大写,没有别的判断依据。这就是「组件名必须大写」这条规则的全部来源。
// ── 你写的 ──────────────────────────────
function Card({ title, active }) {
return <h1 className="t" data-on={active}>{title}</h1>;
}
const frag = <><a /><b /></>;
// ── esbuild --jsx=automatic 的产物 ──
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
function Card({ title, active }) {
return jsx("h1", { className: "t", "data-on": active, children: title });
}
const frag = jsxs(Fragment, {
children: [jsx("a", {}), jsx("b", {})]
});
// 单个子节点用 jsx,多个用 jsxs。children 只是普通的一个 key。
// ── 首字母决定第一个参数是变量还是字符串──
<Btn /> // → jsx(Btn, {}) 变量,React 会调用你的函数
<btn /> // → jsx("btn", {}) 字符串,当成 HTML 标签处理React.createElement 的语法糖」——这句话在 React 17 之前是对的,今天的产物里根本没有 createElement。真正会咬人的是反过来的情况:在一个 automatic runtime 的项目里手写 React.createElement(...),而文件顶上没有 import React,直接抛 ReferenceError: React is not defined。npx esbuild --jsx=automatic 文件.jsx(可用,也支持从标准输入读)。属性名怎么变、children 放在哪、Fragment 变成什么,一眼就清楚了。遇到「这个写法到底合不合法」的争论,这是最快的裁判。JSX 长得像 HTML,但它是 JS。凡是和 HTML 不一样的地方,原因都指向同一件事:属性名最终要变成一个 JS 对象的 key,而 class、for 在 JS 里是保留字。
五条硬规则
class写成className,for写成htmlFor。React 19 里写成class其实能渲染出来(DOM 是<p class="x">),只是控制台告警Invalid DOM property `class`. Did you mean `className`?。能跑不等于该写。- 必须单根。
return <p/><span/>;连编译都过不去,esbuild 报Unexpected ">"——一个return只能返回一个值。要并列就用 Fragment<>...</>,它不产生多余的 DOM 节点。 - 标签必须闭合,HTML 里可以裸写的
<img>、<br>在 JSX 里都要写成<img />。 {}里只能放表达式。{if (x) {...}}编译期就失败:Unexpected "if"。if和for是语句,没有值,而{}要的是一个值。- 非字符串的属性值要用
{}。width={100}传数字,width="100"传字符串。布尔属性disabled={true}可简写成disabled。
最高频的坑:0 会被渲染出来
{} 里的 false、null、undefined、true 都会被跳过,但0 和 NaN 不会。一次渲染的结果:
| 你写的 | 页面上实际出现 |
|---|---|
{0 && <span>有货</span>} | 0 ← 凭空冒出一个 0 |
{items.length && <List />}(空数组时) | 0 ← 最常见的犯法现场 |
{NaN && <span />} | NaN |
{false && ...} / {null} / {undefined} / {"" && ...} | 什么都没有 |
{items.length > 0 && <List />} | 什么都没有 ← 正确写法 |
原因分两层:&& 在左边为假时返回的是左操作数本身(也就是数字 0,不是 false);而 React 只把 false / null / undefined / true 当成「不渲染」,数字 0 在它眼里是完全合法的可渲染内容。两者一叠加,页面上就多出一个孤零零的 0。
修法:把 && 左边显式变成布尔值——items.length > 0 && ... 或 !!items.length && ...,或者直接改用三元。
另外三件小事
- JSX 内部的注释要写成
{/* 这样 */}——本质是在{}里放一个注释。 - 拼字符串要用模板字符串加
{}:className={`btn btn-${variant}`}。写成className="btn ${variant}"就是一串纯文本,不会替换。 - 展开传递
<Btn {...rest} id="x" />编译成jsx(Btn, { ...rest, id: "x" })——普通的对象展开,后写的覆盖先写的。
function Inbox({ items, unread }) {
return (
<> {/* Fragment:并列而不产生 DOM 节点 */}
<label htmlFor="q" className="lbl">搜索</label>
<input id="q" disabled maxLength={20} /> {/* 数字要用 {} */}
{/* ✗ 错:unread 为 0 时,页面上会冒出一个 0 */}
{unread && <b>{unread} 条未读</b>}
{/* ✓ 对:左边先变成布尔值 */}
{unread > 0 && <b>{unread} 条未读</b>}
{/* ✗ 错:编译期就挂,esbuild 报 Unexpected "if" */}
{/* {if (items.length) <List />} */}
{/* ✓ 对:三元是表达式,有值 */}
{items.length ? <List items={items} /> : <p>暂无邮件</p>}
</>
);
}{items.length && <List />} 在列表有数据时一切正常,一旦列表为空,页面上就会突然多出一个孤零零的 0——没有报错,没有告警,控制台干干净净。这是最不容易在 code review 里被看出来、也最容易带到线上的一类 bug,因为开发时手头的测试数据往往从来不是空的。&& 左边是数字(.length、count、index、total),当场把它改成比较式 > 0。不要判断「这个数会不会是 0」,一律改——判断错一次的成本远高于多敲四个字符。组件就是一个「拿 props 返回 JSX」的纯函数。React 对它只有两条硬要求:名字大写开头,以及不许修改自己收到的 props。这两条都不是风格建议——一条是语法,一条 React 在开发环境里真会拦着你(生产环境不拦,但照样白改)。
名字必须大写,否则组件根本不会被调用
把组件写成小写 <myButton label="点我" />:编译产物是 jsx("myButton", {})(字符串),React 把它当成一个陌生的 HTML 标签,myButton 这个函数一次都没有执行过。DOM 输出 <mybutton label="点我"></mybutton>,页面上什么都看不见。
控制台会有两条告警,原文是:
<myButton /> is using incorrect casing. Use PascalCase for React components, or lowercase for HTML elements.The tag <myButton> is unrecognized in this browser. If you meant to render a React component, start its name with an uppercase letter.
注意这只是告警,不是报错——页面照样「正常」跑着,只是那块内容凭空消失了。不看控制台的话能查很久。
props 只读,而且 React 真的把它冻上了
很多教程说「props 是只读的,这是约定」。React 19.2.8 在开发环境下要严格得多:Object.isFrozen(props) 返回 true,(仅限开发构建:react-jsx-runtime.production.js 里 Object.freeze 出现 0 次,生产环境改 props 不会报错,只是照样被下次父级渲染覆盖)写 props.label = "改了" 直接抛 TypeError: Cannot assign to read only property 'label' of object '#<Object>'。
为什么必须只读,机制上的原因很简单:props 是父组件那一次渲染的产物。子组件就算改成功了,父组件下次重渲染时会原样把旧值再传一遍,你的修改凭空蒸发。这不是 React 的缺陷,是 UI = f(state) 的必然结果(见 01 章)。
要改数据,就把状态放到真正拥有它的那个父组件,再用回调 prop 通知上去。数据下行,事件上行。
解构、默认值、children
- 解构写在参数上,顺手给默认值:
function Button({ label, variant = "primary" })。这样组件吃哪些 prop 一眼可见,胜过在函数体里到处写props.xxx。 - 默认值只在 prop 为
undefined时生效。传title={null}默认值不会生效,渲染出来是空的。 children是自动来的:写在开闭标签之间的内容会被编译器塞进children这个 key(见本章第一张卡的产物),解构出来就能用。- 展开传递
<Btn {...rest} />方便,但会让「这个组件到底收了哪些 prop」看不出来。透传一两层可以用,写进业务组件要克制。
组件必须是纯的
同样的 props 进去,必须是同样的 JSX 出来。函数体里不要改外部变量、不要直接操作 DOM、不要发请求——这些都是副作用,该放进事件处理函数或 useEffect(见 05 章)。<StrictMode> 的双渲染就是专门用来当场揪出不纯组件的(见 01 章)。
// 参数上解构,一眼看清这个组件吃哪些 prop
function Button({ label, onClick, variant = "primary", disabled = false }) {
return (
<button className={`btn btn-${variant}`} onClick={onClick} disabled={disabled}>
{label}
</button>
);
}
// children:标签之间的内容自动成为一个 prop,不用在调用处写出来
function Panel({ title, children }) {
return (
<section className="panel">
<h3>{title}</h3>
{children}
</section>
);
}
// 数据下行、事件上行:子组件不改 props,只通知父组件
function Page() {
const [n, setN] = useState(0);
return (
<Panel title="计数">
<p>当前 {n}</p>
<Button label="加一" onClick={() => setN(n + 1)} />
</Panel>
);
}undefined,不认 null。写 function Card({ title = "无标题" }),调用处 <Card title={data.title} /> 而接口返回的 data.title 是 null——渲染出的是空白,不是「无标题」。后端字段可空时别指望默认参数,要在组件里写 title ?? "无标题"。Button.jsx 里写 function Button)。因为小写组件不会中断渲染、页面上一声不吭,只有控制台那两条告警兜着——一旦日志被刷掉就再没有线索。这也是为什么整个 React 社区在这一点上高度统一。React 里没有「组件继承」这回事。复用 UI 的唯一手段是把组件塞进另一个组件,而不是让一个组件继承另一个。这不是官方偷懒,是因为在一个「元素只是普通的值」的世界里,参数比继承便宜太多。
为什么继承在这里根本用不上
- 组件是函数,不是类。函数没有 protected 成员可供子类改写,也没有「先调父类实现再补一点」的插入点。
- 就算用 class 组件去「继承一个 Button 再重写 render」,你得到的是一个既依赖父类内部结构、又不允许父类改动的死耦合——父类改一行,所有子类一起塌。
- 更根本的原因在上一张卡里:JSX 元素是普通的值。既然一段 UI 可以当值传,那么「会变化的那部分」就总能当参数传进去。继承想解决的问题,一个参数就解决了。
children:留一个洞
children 的全部含义就是「我在这里留个洞,你来填」。Panel、Card、Modal、Layout、权限包装器,这类容器组件全都靠它。关键在于:容器完全不需要知道洞里装的是什么——它只管边框、内边距、阴影、滚动,内容是谁的事跟它无关。
这就是组合优于继承的实际形态:不是「Modal 的子类 ConfirmModal」,而是「Modal 里放一段确认用的 JSX」。
多个洞就是 slot 模式:把组件当 prop 传
需要留两个以上的洞时 children 不够用了,那就再开几个 prop,值直接就是 JSX。
- 写
<Layout header={<b>标题</b>} footer={<small>版权</small>}><p>正文</p></Layout> - 渲染
<div><h1><b>标题</b></h1><main><p>正文</p></main><footer><small>版权</small></footer></div>
这就是 Vue 具名插槽在 React 里的样子。React 不需要为它专门发明语法,因为元素本来就是值,prop 本来就能传任何值。
想复用什么,就用对应的手段
| 你想复用的东西 | 该用的手段 |
|---|---|
| 一块 UI 的外壳(边框、布局、间距) | children |
| 外壳上的多个位置 | 把 JSX 当 prop 传(slot 模式) |
| 一段带状态的逻辑 | 自定义 Hook(见 08 章) |
| 跨层级共享的值 | Context(见 07 章) |
| 「同一个组件的几种样式」 | 加一个 prop 加条件类名,不要拆成几个组件 |
// 一个洞:children。Panel 不关心里面装什么
function Panel({ children }) {
return <section className="panel">{children}</section>;
}
// 多个洞:slot 模式 —— prop 的值直接就是 JSX
function Layout({ header, children, footer }) {
return (
<div>
<h1>{header}</h1>
<main>{children}</main> {/* children 就是没写名字的那个洞 */}
<footer>{footer}</footer>
</div>
);
}
function App() {
return (
<Layout header={<b>标题</b>} footer={<small>版权</small>}>
<p>正文</p>
</Layout>
);
}
// 产出:<div><h1><b>标题</b></h1><main><p>正文</p></main>…
// 不需要继承,也不需要插槽语法 —— 元素本来就是值Nested 定义在 Outer 内部,点两下让它的计数变成 2,然后父组件因为别的原因重渲染一次,计数立刻回到 0。因为每次渲染都产生一个新的函数,React 认为这是一个全新的组件类型,于是整棵卸载重建。组件定义一律写在模块顶层,要传的是元素或函数,不是定义(见 03 章)。children 或 slot prop),要复用的是「怎么运作」就用自定义 Hook(见 08 章)。另外一个信号:当一个组件的 prop 数量超过七八个、而且大半是布尔开关时,通常说明它该被拆成一个留洞的容器。JSX 的 {} 只接受表达式,所以 React 没有模板指令——没有 v-if,没有 *ngFor。你用的就是 JS 本来就有的那几个表达式。这既是限制,也正是你不需要另学一套模板语言的原因。
四种写法,什么时候用哪个
| 写法 | 用在 |
|---|---|
| 提前 return | 整个组件级别的状态分支:加载中 / 出错 / 无权限。最清晰,优先用 |
三元 ? : | 二选一,且两个分支都要渲染点什么 |
&& | 有就渲染、没有就完全不渲染。注意左边是数字时那个 0 的坑(见本章前面) |
| 先存进变量 | 分支超过两个,或条件表达式已经长到一行读不完 |
反面典型是把加载态、错误态、空态一层层嵌进同一棵 JSX 树里的三重三元。它一定会变成没人敢改的代码。
提前 return 是被低估的那一个
- 把
if (loading) return <Spinner />这类判断提到组件最上面各占一行,剩下的主干 JSX 就只需要处理「一切正常」这一种情况,可读性差别巨大。 - 代价:提前 return 之后不能再调用任何 Hook——Hook 必须每次渲染都以完全相同的顺序被调用。所以顺序必须是「所有
useState/useEffect先调完 → 再提前 return」(见 06 章)。 - 违反了会被 lint 当场抓住,告警原文
React Hook "useState" is called conditionally. React Hooks must be called in the exact same order in every component render.(见 01 章)。
列表就是 .map()
- 没有循环语法,因为
{}里要的是一个值,而.map()正好返回一个数组。React 会把数组里的元素依次摊平渲染。 - 写
for循环是行不通的——它是语句。真要用循环就在return之前把结果推进一个数组,再把数组放进{}。 .map()出来的每一项都必须有key。缺了告警原文Each child in a list should have a unique "key" prop.。key决定 React 怎么把上一次的状态对应到这一次的哪一项,是个值得单独讲的话题(见 03 章),这里只要记住「map 了就写 key」。
空态几乎总是被忘掉
items.map(...) 在空数组上返回空数组,页面上就是一片什么都没有——不报错,也不提示。列表渲染基本上总要配一个「暂无数据」的分支。顺手写的话正好落进上面那个 0 的坑里,所以请写成 items.length === 0 ? ... : ... 或者用提前 return。
function UserList({ users, loading, error }) {
// Hook 必须全部先调完,才能开始提前 return
const [q, setQ] = useState("");
// 提前 return:把异常态挑出去,主干只管正常情况
if (loading) return <Spinner />;
if (error) return <p className="err">{error.message}</p>;
const shown = users.filter(u => u.name.includes(q));
// 空态用三元,别用 && —— shown.length 为 0 时会渲染出个 0
return shown.length === 0 ? (
<p>没有匹配的用户</p>
) : (
<ul>
{shown.map(u => (
<li key={u.id}> {/* key 用数据本身的 id,见 state 章 */}
{u.name}
{u.isAdmin && <Badge>管理员</Badge>} {/* 布尔值,安全 */}
</li>
))}
</ul>
);
}if 直接写进 JSX 的 {} 里,比如 {if (loading) <Spinner />}——编译期就失败,esbuild 报 Unexpected "if"。{} 要的是一个有值的表达式,而 if 是语句,没有值。for 同理。刚从模板语言转过来的人几乎人人踩一次。State、事件与列表 key
状态是跨渲染保留的值,set 不是赋值而是安排下一次渲染。本章还把 React 最经典的坑——列表 key——讲透。
组件函数每次渲染都被从头调用一遍,函数里的局部变量随之作废。state 是 React 替你存在组件之外的那份记忆;而 set 函数不是赋值语句,它做的是「安排下一次渲染」。
为什么普通变量不行
React 更新界面的唯一手段是重新调用你的组件函数,拿新返回的 JSX 去比对(见 02 章)。这就带来两个问题,普通变量一个都解决不了。
- 函数里的
let count = 0每次渲染都被重新初始化,上一次的值留不住。 - 就算你把它提到模块顶层躲过重置,改了它 React 也无从得知,界面不会重画。
useState 恰好补上这两块:值存在组件对应的内部记录上、跨渲染保留;调 setter 时顺便通知 React「这个组件该重画了」。
渲染快照:一次渲染里 state 是常量
注意 const [count, setCount] = useState(0) 用的是 const,这不是笔误。在这一次渲染的整个函数体里,count 就是一个不会变的值;你写的事件处理函数是在这次渲染里创建的闭包,它捕获的也是这个值。
初值为 2,在一次点击里连写两次 setA(a + 1),结果是 3 而不是 4——两次读到的 a 都是那一次渲染的快照 2。换成函数式 setA(x => x + 1) 连写两次,从 2 直接跳到 4(对应右侧代码的初值),因为这些函数是排队执行的,后一个拿到的是前一个的返回值。
别说「setState 是异步的」
这个说法流传很广,但它会把人带偏。setCount 不返回 Promise,也不是被塞进微任务队列,你 await 它毫无意义。准确的说法只有两条机制:批处理(同一批更新攒起来只渲染一次,见下面的批处理卡)和渲染快照(同一次渲染里读到的永远是当时那个值)。把它理解成「安排下一次渲染」,所有反直觉的现象都能推出来。
两种更新写法怎么选
| 写法 | 同一事件里连调两次 | 什么时候用 |
|---|---|---|
setX(x + 1) | 只生效一次(都基于同一快照) | 新值和旧值无关时——来自输入框、来自接口响应 |
setX(p => p + 1) | 依次累加,2 → 4 | 新值由旧值算出来时,一律用它 |
function Counter() {
// count 在这一次渲染里就是个常量快照,函数体里不会中途变
const [count, setCount] = useState(2);
const wrong = () => {
setCount(count + 1); // 读到 2,安排「下次是 3」
setCount(count + 1); // 还是读到 2,又安排一次「下次是 3」
}; // 结果 3,不是 4
const right = () => {
setCount(c => c + 1); // 收到 2 返回 3
setCount(c => c + 1); // 收到上一个的 3 返回 4:排队执行才累加
}; // 结果 4
const stale = () => {
setCount(99);
console.log(count); // 打出 2:新值要等下次渲染才存在
};
return (
<div>
<p>{count}</p>
<button onClick={wrong}>连写两次</button>
<button onClick={right}>函数式两次</button>
</div>
);
}setCount(99) 之后紧接着 console.log(count) 打出的是旧值,很多人由此以为「没生效」而反复调用。同一个坑还有个变体:在 setTimeout 里读 count,读到的是创建那次渲染的快照,哪怕定时器触发时界面早就更新了好几轮。setX(p => …);新值和旧值无关(来自输入框、来自接口返回)才直接传值。拿不准时用函数式永远不会错,它顶多是多写几个字符。React 判断 state 变没变,用的是 Object.is 比较新旧两个值——是引用比较,不是深比较。原地改一个对象,引用没变,React 认定「没变」,你的组件函数一次都不会重跑。
原地改是彻底静默的
写一个按钮做 arr.push("y") 然后 setArr(arr),结果是:组件函数多跑 0 次,DOM 一直停在 x。对象也一样,obj.n = 99 后 setObj(obj),界面还是 0。
请注意这不是「渲染了但看不出变化」,而是根本没有渲染。React 在 setter 内部就发现新旧值全等,直接退出,连排队都不排。这也是这个 bug 极难排查的原因——你明明调了 setter,没有任何报错,界面就是不动。
正确写法速查
| 要做的事 | 不要(原地改) | 要(造新值) |
|---|---|---|
| 改对象字段 | user.name = n | setUser(p => ({ ...p, name: n })) |
| 数组追加 | arr.push(x) | setArr(p => [...p, x]) |
| 数组删除 | arr.splice(i, 1) | setArr(p => p.filter(t => t.id !== id)) |
| 改数组里某一项 | arr[0].done = true | setArr(p => p.map(t => t.id === id ? { ...t, done: true } : t)) |
| 排序 / 反转 | arr.sort() | setArr(p => p.toSorted()) |
嵌套结构要逐层展开:只展开最外层、内层还是老引用的话,那一层的改动同样会被吃掉。这也正是「state 结构越扁平越好」的现实理由。
为什么 React 不做深比较
深比较一个几千项的数组,成本和重新渲染差不多,那这个优化就白做了。React 选了 O(1) 的引用比较,把「值变了引用就得变」这个约定外包给你遵守。这是一笔明确的交易:你多敲几个展开运算符,换来 React 每次判断只花一次比较。
// ❌ 原地改:引用没变,组件函数一次都不重跑,界面纹丝不动
const bad = () => {
todos.push({ id: 3, text: "跑步" });
setTodos(todos); // Object.is(todos, todos) 为真 → 直接退出
};
// ✅ 对象:展开旧的,覆盖要改的字段
setUser(p => ({ ...p, name: "老王" }));
// ✅ 数组三件套:增用展开、删用 filter、改用 map
setTodos(p => [...p, { id: crypto.randomUUID(), text, done: false }]);
setTodos(p => p.filter(t => t.id !== id));
setTodos(p => p.map(t => t.id === id ? { ...t, done: !t.done } : t));
// ✅ 排序:sort 是原地方法,toSorted 返回新数组(原数组不变)
setTodos(p => p.toSorted((a, b) => a.text.localeCompare(b.text)));
// ✅ 嵌套:每一层都要造新的,漏一层那一层就不会更新
setState(p => ({
...p,
profile: { ...p.profile, address: { ...p.profile.address, city } },
}));todos[0].done = true; setTodos(todos)。因为 setter 确实调了,你会觉得写法没毛病,但组件函数一次都没重跑。同类陷阱还有 sort、reverse、splice 这些原地方法——它们看着像在算新值,其实改的是原数组,顺带把 state 也污染了。useImmer,用「直接改」的写法生成不可变结果。前者治本,优先考虑。React 不会每调一次 setter 就重画一次。它把同一批更新攒起来,算完最终状态只渲染一次——React 18 起这条对 setTimeout、Promise 回调同样成立,这个特性叫自动批处理(automatic batching)。
三种场景,都是一次
做三个按钮,分别在事件处理函数里、setTimeout 回调里、Promise.then 回调里各连调两个 setter,在组件函数里数渲染次数。输出:
- 事件处理函数里连调两个 setter → 重渲染 1 次
setTimeout里连调两个 setter → 重渲染 1 次Promise.then里连调两个 setter → 重渲染 1 次
渲染日志是 a=0 b=0 → a=1 b=1 → a=2 b=2,中间没有出现过 a=1 b=0 这种半更新状态。这一点很重要:批处理不只是省性能,它保证了界面永远不会渲染出「一半新一半旧」的中间态。
老文章里的过时说法
React 17 及更早只在自家事件处理函数里批处理,setTimeout、原生事件监听、接口回调里都是「调几次渲染几次」。所以你会读到「异步回调里要把多个 state 合成一个对象,否则渲染两次」这种建议——从 React 18 用 createRoot 起,这个理由已经不成立了。合并 state 的理由应该是「它们本来就是一件事」,不是「怕多渲染」。
要不要用 flushSync 退出批处理
flushSync(从 react-dom 导入)强制立刻同步渲染并提交到 DOM。普通 setN(1) 后立刻读 DOM 拿到的还是 0,包在 flushSync 里再读就是 9。
- 用 flushSync 换取「立刻能读到新 DOM」优更新完可以马上量新元素的高度、把新增行滚动到可视区、把焦点移到刚出现的输入框短放弃了合并的机会,同步阻塞主线程;在列表里循环调用会造成明显掉帧为何同根在「批处理就是把渲染推迟到本轮任务结束」。推迟带来了合并与一致性,也就必然意味着你在当下读不到新 DOM——想读到,只能付出放弃推迟的代价
判断很简单:99% 的场合不需要 flushSync,只有在「改完 state 必须马上测量或操作真实 DOM」时才用,而且尽量只包住那一个 setter。
import { useState } from "react";
import { flushSync } from "react-dom";
function Demo({ listRef }) {
const [a, setA] = useState(0);
const [b, setB] = useState(0);
const [rows, setRows] = useState([]);
// 以下三处各自都只触发一次重渲染,中间态不会被渲染出来
const inEvent = () => { setA(x => x + 1); setB(x => x + 1); };
const inTimer = () => setTimeout(() => {
setA(x => x + 1); setB(x => x + 1); // React 18 起这里也批
}, 0);
const inPromise = async () => {
await fetch("/api/ping");
setA(x => x + 1); setB(x => x + 1); // 接口回调里也批
};
// 逃生口:加完一行要立刻滚到底部,必须先让 DOM 真的更新
const addAndScroll = (text) => {
flushSync(() => setRows(p => [...p, text]));
listRef.current.scrollTop = listRef.current.scrollHeight;
};
return <button onClick={inEvent}>{a}-{b}</button>;
}setCount(count + 1) 连写三次,结果只加 1。原因是两条机制各出一半力——快照让三次都读到同一个旧值,批处理再把它们合并成一次渲染。改用函数式 setCount(c => c + 1) 即可,三次就是加 3。useReducer。状态放错位置,比状态写错更难救。三条规矩按顺序问:这个值能不能算出来(能就别存);几个组件在用(一个就留在原地);要共享吗(要才提到最近的公共父级)。
第一问:派生状态别塞进 state
凡是能从别的 state 或 props 算出来的值,就在渲染时顺手算,不要设第二份真源。多一份真源就多一处会失同步的地方。
对照:一个组件写 const [total] = useState(price * qty),另一个直接写 const total = price * qty。把 qty 从 2 改成 5,前者界面还是 20,后者变成 50。原因很直白——useState 的参数只在首次渲染被用一次,之后它就和 props 断了联系。
常见的该算不该存的例子:筛选后的列表、全选复选框的勾选态、表单是否可提交、搜索结果的条数。它们都是 state 的函数,不是独立的事实。
第二、三问:就近存放与状态提升
- 就近存放(colocation):只有一个组件用的 state,就让它住在那个组件里。这样重渲染的影响面最小,组件也能被单独复用。
- 状态提升:两个兄弟组件要读同一份数据时,把 state 提到它们最近的公共父级,再把「值」和「更新函数」一起当 props 下发。兄弟之间不能直接通信,永远经由父级。
- 组合破穿层:如果你只是想把数据递给很深的子组件、中间层根本用不到,先试试把子树当
children传进去,让数据留在需要它的那一层,而不是急着上 Context(见 07 章)。
决策表
| 情况 | 该怎么办 |
|---|---|
| 能从别的值算出来 | 渲染时算,不要存 |
| 只有当前组件用 | 留在原地 |
| 两三个兄弟共享 | 提到最近公共父级 |
| 只是穿层,中间层不用 | 用 children 组合 |
| 跨很多层、消费者很多 | Context(见 07 章) |
| 来自服务端的数据 | 交给数据请求库缓存,不要手抄进 state(见 05 章) |
// ❌ 派生状态存进 state:qty 变了它不跟着变(停在 20)
function Bad({ price, qty }) {
const [total] = useState(price * qty); // 只在首次渲染算这一次
return <span>{total}</span>;
}
// ✅ 渲染时算:单一真源只有 price 和 qty
function Good({ price, qty }) {
const total = price * qty;
return <span>{total}</span>;
}
// ✅ 状态提升:两个兄弟共享 selected,就提到它们的公共父级
function Gallery({ photos }) {
const [selected, setSelected] = useState(0);
const [keyword, setKeyword] = useState("");
// 筛选结果是派生的:随渲染现算,不设第二份 state
const shown = photos.filter(p => p.title.includes(keyword));
return (
<>
<input value={keyword} onChange={e => setKeyword(e.target.value)} />
<ThumbList items={shown} active={selected} onPick={setSelected} />
<BigImage photo={shown[selected]} />
</>
);
}useState(props.value)——是最常见的失同步来源,prop 从 2 变 5 之后界面纹丝不动。如果你确实想要「prop 变了就重置内部状态」,正解是换 key(见下面那张卡),而不是加一个 useEffect 去手工同步;后者会多渲染一轮,还容易写出死循环。key 不是给 React 数数用的,它回答的是一个身份问题:「这次渲染的这个元素,和上次那个是不是同一个东西」。答错了,状态就跟错人。
用 index 当 key,文字会跑到别人那行
三行 A / B / C,每行一个输入框。在 B 那行输入「我打在B里」,然后往列表头部插入一项。两种 key 的结果:
key={index}→ 头插后:新= A=我打在B里 B= C=——文字跑到 A 那行去了key={item}→ 头插后:新= A= B=我打在B里 C=——老老实实跟着 B 走
原因顺着「身份」两个字就能推出来:头插之后,B 的 index 从 1 变成 2,而 index 为 1 的位置现在坐着的是 A。React 按 key 配对,发现上一次的 key=1(那时是 B)和这一次的 key=1(现在是 A)是「同一个」,于是把 B 那一行攒下的东西——DOM 节点、里面的输入内容、组件的 state——全部交给了 A。
key 管的不只是 DOM 复用
很多人以为 key 只是性能优化,只影响 React 要不要重建 DOM 节点。实际上挂在这个身份上的东西多得多:
- 组件内部所有
useState/useReducer的值 - 非受控输入框里用户已经敲进去的内容(上面的正是这一条)
- effect 的挂载与卸载时机(见 05 章)
- 滚动位置、焦点、正在播放的动画
所以 key 错了不是「慢一点」,是数据错位,而且没有任何报错。
什么时候 index 能用,什么时候不能
| 列表特征 | index 能不能当 key |
|---|---|
| 纯展示、永不排序、永不增删 | 可以,但也没省什么事 |
| 会在中间或头部增删 | 不行,身份会整体错位 |
| 可以拖拽排序或点列头排序 | 不行 |
| 行内有输入框、折叠态、勾选态 | 绝对不行,状态会跟错行 |
另一个极端同样有害:拿 Math.random() 当 key。每次无关的重渲染都会新挂载 2 个行组件,之前打进 A 行的字全部清空——等于每渲染一次就把整棵子树推倒重建,状态全丢,性能还比 index 更差。
// ❌ index 当 key:头插一项后,B 行输入的内容会跑到 A 行去
{items.map((item, index) => (
<Row key={index} item={item} />
))}
// ❌ 随机数当 key:每次重渲染都重新挂载全部行,状态清空
{items.map(item => <Row key={Math.random()} item={item} />)}
// ✅ 用数据自带的稳定 id,身份跟着数据走
{items.map(item => <Row key={item.id} item={item} />)}
// ✅ 本地新建的项:在「创建那一刻」就生成 id 并存进数据里
const add = (text) => setItems(p => [
{ id: crypto.randomUUID(), text }, // 不是渲染时才算,否则每次都变
...p,
]);
// key 只需在同一组兄弟里唯一,不用全局唯一;它也不会作为 prop 传进组件
function Row({ item, key }) {
console.log(key); // undefined,而且 React 会为此告警
return <li>{item.text}<input /></li>;
}
// 组件内部要用这个值,就再传一个普通属性:<Row key={item.id} id={item.id} />useState,错位就会发生,而且静默失败,通常要等用户报 bug 才发现。React 只在你完全不写 key 时才提醒 Each child in a list should have a unique "key" prop.,写了个错的它不管。crypto.randomUUID() 生成好、存进对象里。判断一个 key 合不合格只有一个标准:同一条数据在两次渲染之间,key 是不是同一个值。既然 key 决定的是身份,那么换掉 key 就等于对 React 说「这是另一个组件了」。旧的整个卸载,新的从初始 state 重新挂载。这是重置组件状态最省事、也最不容易写错的手段。
切换用户时重置表单
一个 EditForm 内部存着草稿 state,父组件在 u1 和 u2 之间切换。两种写法:
- 不加 key:切到 u2 后草稿还在(还是「写了一半的草稿」),组件挂载次数仍是 1——u1 写了一半的内容,原封不动地出现在了 u2 的表单里。
key={userId}:切到 u2 后草稿变空,挂载次数从 1 变成 2——组件真的被卸载重建了。
注意第一种情况的危害:这不只是「界面没刷新」,而是把一个用户的数据泄漏到了另一个用户的编辑界面,一不留神就提交上去了。
和另外两种做法比
| 做法 | 评价 |
|---|---|
key={userId} | 推荐。一行搞定,不会漏掉任何一个字段 |
useEffect 里监听 userId 再逐个 setState | 反模式。多渲染一轮、加字段时容易漏,还常写成死循环(见 05 章) |
| 手写一个 reset 函数在切换时调用 | 可用,但每加一个 state 就要记得改它 |
代价:这是核弹级重置
换 key 会把整棵子树卸载重建,代价一次付清:
- DOM 节点全部重建,滚动位置、焦点、选区丢失
- 所有 effect 走一遍 cleanup 再重新执行,订阅和定时器重来
- 进行中的 CSS 过渡与动画中断
所以它只适合「换了 key 之后本来就该忘掉一切」的场景:切换用户、切换文档、切换会话。想只重置一两个字段,老老实实调 setState。
// ✅ 换用户就换 key:EditForm 整个重建,草稿自然清空(挂载次数 1 → 2)
function Page() {
const [userId, setUserId] = useState("u1");
return (
<>
<UserPicker onPick={setUserId} />
{/* key 要加在「要被重置的那个组件」上,加在它内部的 div 上无效 */}
<EditForm key={userId} userId={userId} />
</>
);
}
function EditForm({ userId }) {
// userId 变了 → key 变了 → 这个 useState 会重新初始化
const [draft, setDraft] = useState("");
return <textarea value={draft}
onChange={e => setDraft(e.target.value)} />;
}
// ❌ 反模式:用 effect 手工同步。多渲染一轮,加字段还容易漏
// useEffect(() => { setDraft(""); setErrors({}); }, [userId]);
// ❌ 别把频繁变化的东西当 key:等于每敲一个字就重建整个表单
// <EditForm key={JSON.stringify(form)} />EditForm,key 就必须加在 <EditForm> 这个元素上;加在它内部的 <div> 上,React 重建的只是那个 div,组件的 state 一点没动,而且没有任何报错,你只会看到「重置不生效」。useState 管的是「一个值」,useReducer 管的是「一次状态转移」。当一次交互要同时改好几个 state、而且怎么改取决于当前状态时,把逻辑集中到一个函数里,比散在五个事件处理函数里可靠得多。
四个该换的信号
- 一个事件处理函数里连着调三四个 setter,而且它们必须同时变,单独变就是非法状态。
- 同一份状态的更新逻辑散落在多个 handler 里,彼此还有约束——比如「切换筛选条件时要顺手退出编辑态」,你得记得在每个改筛选的地方都补一行。
- 下一个状态取决于当前状态的好几个字段,函数式更新写不下了。
- 你想给状态变化写单元测试。reducer 是个纯函数,不用渲染组件就能测(见 12 章)。
形态与规矩
const [state, dispatch] = useReducer(reducer, initialState)。第三个参数可选,useReducer(reducer, arg, init)会用init(arg)做惰性初始化,适合初值需要昂贵计算的场合。reducer(state, action)必须是纯函数:不发请求、不改 DOM、不改传进来的 state,只根据输入返回新状态。它写在组件外面,因此天然不受闭包快照困扰。- 不可变那套规矩照样适用——reducer 里
state.items.push(...)一样会被Object.is挡掉。 dispatch的身份在多次渲染间保持稳定,往下层传不会引起额外重渲染,配合 Context 分发很顺手(见 07 章)。
换过去要付什么
- 把散落的 setState 收进一个 reducer优所有状态转移集中在一处,不变量好维护;组件里只剩「发生了什么」;纯函数可以脱离 React 直接测短样板代码变多;读一个按钮的行为要跳到 reducer 里翻 switch,简单状态反而更难读为何同根在「集中」二字。集中带来了一致性与可测性,也必然把逻辑挪离了使用它的地方——状态越简单,这份间接成本占比越高
结论:默认用 useState,等到上面四个信号出现两个以上再换。别为了「看起来更规范」提前上 reducer。
// reducer 写在组件外:纯函数,不碰 DOM、不发请求、不改入参
function reducer(state, action) {
switch (action.type) {
case "added":
return { ...state, items: [...state.items,
{ id: action.id, text: action.text, done: false }] };
case "toggled":
return { ...state, items: state.items.map(t =>
t.id === action.id ? { ...t, done: !t.done } : t) };
case "filterChanged":
// 换筛选顺手退出编辑态:这条约束只需在这里写一次
return { ...state, filter: action.filter, editingId: null };
default:
// 一定要抛:否则打错 type 时静默什么也不发生
throw new Error("未知 action:" + action.type);
}
}
function TodoApp() {
const [state, dispatch] = useReducer(reducer,
{ items: [], filter: "all", editingId: null });
// 派生值随渲染现算,不进 state
const shown = state.filter === "done"
? state.items.filter(t => t.done) : state.items;
return <button onClick={() =>
dispatch({ type: "filterChanged", filter: "done" })}>
只看已完成({shown.length})</button>;
}state.items.push(...) 然后 return state),和 useState 一样会被 Object.is 挡下来,组件一次都不重渲染。二是 default 分支写成 return state:type 打错一个字母时界面什么反应都没有,排查半天;改成抛错后立刻能看到 未知 action:什么鬼。type 要写发生了什么,不要写要改哪个字段:写 { type: "filterChanged" } 而不是 { type: "setFilterAndClearEditing" }。前者让你日后能在 reducer 里自由增删这次转移的连带效果,后者一改就得同时改所有调用处——这也是 reducer 相比一堆 setter 的全部价值所在。表单与受控组件
表单是「值的真源放在哪」的问题:放 state 是受控,放 DOM 是非受控。再加上 React 19 的表单 Actions,这一章给出完整答案。
受控和非受控的区别只有一句话:这个输入框的值,真源(source of truth)是在 React state 里,还是在 DOM 节点自己身上。剩下所有差异都是这一句的推论。
两条链路
- 受控:
value={x}+onChange。用户每敲一个字符,React 收到事件 → 你调 setState → 组件重渲染 → React 把新的value写回 DOM。屏幕上那个输入框只是显示器,它自己不保存任何东西。 - 非受控:
defaultValue只在挂载那一刻往 DOM 里写一次,之后 React 完全不管。值住在 DOM 节点上,你要读就用ref或提交时的FormData去取。
由此就能推出一切:受控的值随时可读、可即时校验、可被程序改写,代价是每次按键都要走一轮完整渲染;非受控省掉了这轮渲染,代价是「此刻的值是什么」React 不知道,只有你主动去问才知道。
怎么选
| 需求 | 选哪个 | 为什么 |
|---|---|---|
| 边输入边校验、边输入边搜 | 受控 | 没有 state 就没有触发渲染的时机 |
| 输入 A 决定 B 是否可用 / 显示 | 受控 | 联动本质上就是从一个值算出另一个 |
| 输入时格式化(手机号加空格、金额加逗号) | 受控 | 要在写回 DOM 前改写用户输入 |
| 提交按钮随内容变灰 | 受控 | 同上,是派生状态 |
| 只在提交时才需要值 | 非受控 | 白白重渲染没有收益 |
| 字段很多、按键卡顿 | 非受控 | 把每次按键的渲染成本降为零 |
type="file" | 只能非受控 | 出于安全,文件输入的值不能被程序设置 |
判断口诀
问一句:在用户按下提交之前,有没有人需要知道这个值?有——受控;没有——非受控。别按「哪个更规范」来选,两种都是 React 官方支持的正规写法,不存在谁更高级。
// —— 受控:真源在 state,DOM 只负责显示 ——
function Search() {
const [q, setQ] = useState(""); // 初值给 "",绝不能给 undefined
// 有了 state,才能在输入过程中算派生值
const tooShort = q.trim().length < 2;
return (
<>
<input value={q} onChange={e => setQ(e.target.value)} />
<button disabled={tooShort}>搜索</button>
</>
);
}
// —— 非受控:真源在 DOM,提交时才去问它 ——
function Feedback() {
const onSubmit = (e) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.target));
console.log(data.msg); // 打字过程中一次渲染都没发生
};
return (
<form onSubmit={onSubmit}>
{/* defaultValue 而非 value:只在挂载时写一次 */}
<textarea name="msg" defaultValue="" />
<button>提交</button>
</form>
);
}A component is changing a controlled input to be uncontrolled.(值从有变 undefined),反向则是 changing an uncontrolled input to be controlled。根源几乎都是初值写成了 useState(),或接口数据回来前那一段是 undefined——受控输入的初值请一律给 ""。受控输入的全部机制就是一个闭环:value 从 state 流向 DOM,onChange 把用户的输入送回 state。这个环一旦断了一头,输入框就不动了。
一个 handler 管所有字段
字段多起来别写十个 useState 和十个 handler。给每个 input 加 name,用计算属性名 [e.target.name] 定位要改的字段,一个函数通吃。几个类型上的细节:
- 复选框读
e.target.checked,绑的属性是checked而不是value。 type="number"的e.target.value仍然是字符串(typeof为string),要算数就自己转,别指望它给你数字。<select>直接绑value,不要往<option>上写selected;多选<select multiple>的值是数组。
只写 value 不写 onChange 会怎样
React 打出这条告警原文:
You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultValue`. Otherwise, set either `onChange` or `readOnly`.
而且它是说到做到的:程序性地把输入框的值改成「用户想输入这个」并派发 input 事件,读回来还是原来的「锁死的值」——React 每次渲染都会把 value 强行写回去,用户敲什么都留不下。告警里已经把两条出路写清楚了:真要用户能改就补 onChange;本来就是只读的就加 readOnly(加了之后告警消失)。
代价:每按一次键渲染一次
在受控表单里连打 5 个字符,整个表单组件重渲染 5 次。字段少的时候完全无所谓——一次渲染就是跑一遍函数、比一次 JSX,比浏览器自己处理按键还快。但字段到了几十个、每个还带校验逻辑时,这个成本就要认真算了(见本章最后一张卡)。
function SignupForm() {
const [form, setForm] = useState({ // 一个对象装下所有字段
email: "", age: "", plan: "free", agree: false,
});
// 一个 handler 管全部:靠 name 定位,用计算属性名写回
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
// 必须用函数式:同一事件里改两个字段时才不会互相覆盖
setForm(prev => ({
...prev,
[name]: type === "checkbox" ? checked : value,
}));
};
return (
<form onSubmit={e => e.preventDefault()}>
<input name="email" value={form.email} onChange={handleChange} />
{/* number 拿到的仍是字符串,要算数得自己 Number() */}
<input name="age" type="number" value={form.age} onChange={handleChange} />
<select name="plan" value={form.plan} onChange={handleChange}>
<option value="free">免费</option>
<option value="pro">专业</option>
</select>
<input name="agree" type="checkbox"
checked={form.agree} onChange={handleChange} />
<button disabled={!form.agree}>注册</button>
</form>
);
}setForm({ ...form, [name]: value }) 而不是函数式,在同一个事件里改两个字段时会丢一个。两次展开写法的结果是 {"a":"","b":"B"}——第一次的改动被第二次基于旧快照的展开盖掉了;换成 setForm(p => ({ ...p, … })) 才是 {"a":"A","b":"B"}。name 定位,比一堆独立的 useState 省事得多。但如果字段之间开始互相牵制(选了 A 就得清空 B、改了套餐就要重算价格),那是该换 useReducer 的信号——判断标准和 03 章里那四条一模一样。非受控的意思是把值的保管权交还给浏览器。你不再为每次按键惊动 React,代价是想读值必须主动去 DOM 拿——用 ref 拿一个,用 FormData 拿一整组。
defaultValue 只在挂载时生效
挂载时 defaultValue="第一版",DOM 值是「第一版」;之后把 prop 改成「第二版」重新渲染,DOM 值还是「第一版」。这不是 bug,正是非受控的定义——写过一次之后 React 就撒手了。复选框对应的是 defaultChecked。
推论:想让非受控字段随外部数据变化而更新,唯一干净的办法是换 key 让它重建(见 03 章那张卡),而不是去改 defaultValue。
React 19 的 ref 就是普通 prop
一个自定义组件直接在参数里解构 ref 并转给内部的 <input>,父组件用 useRef 就能拿到真实 DOM 节点、读到「改过的@x.com」,没有任何告警。这是 React 19 相比 18 最省事的一处改进。
顺带澄清一个流传很广的错误:forwardRef 在 19.2.8 里仍然可用,而且不打废弃告警——验证过的。老代码不必为了升级 React 19 去批量重写,新代码直接用 ref 作 prop 即可。
FormData 一次取完
提交时 new FormData(e.target) 配合 Object.fromEntries,一行拿到所有带 name 的字段。输出 {"email":"改过的@x.com","nick":"老王","vip":"on"}。三个必须知道的细节:
- 没写
name的字段根本不会出现——非受控表单里name不是可选项。 - 未勾选的复选框整个键都不存在,
"off" in data为false,取出来是undefined而不是false。 - 勾选了的复选框值是字符串
"on",不是布尔true(除非你自己写了value)。
// React 19:ref 直接写在参数里,不需要 forwardRef(无告警)
function TextField({ label, ref, ...rest }) {
return <label>{label}<input ref={ref} {...rest} /></label>;
}
function ProfileForm() {
const emailRef = useRef(null);
const onSubmit = (e) => {
e.preventDefault();
console.log(emailRef.current.value); // 方式一:ref 读单个字段
// 方式二:FormData 一次取全,靠的是每个字段的 name
const data = Object.fromEntries(new FormData(e.target));
// 未勾选的复选框不会出现在 FormData 里,得自己兜底成布尔
send({ ...data, vip: data.vip === "on" });
};
return (
<form onSubmit={onSubmit}>
{/* defaultValue 只在挂载时写一次,之后改它不会更新界面 */}
<TextField label="邮箱" ref={emailRef}
name="email" defaultValue="a@b.com" />
<input name="nick" defaultValue="老王" />
<input name="vip" type="checkbox" defaultChecked />
<button>保存</button>
</form>
);
}Object.fromEntries(new FormData(f)) 的结果直接丢给后端当布尔字段会出事:未勾选的复选框整个键都不存在(不是 false),勾选时是字符串 "on"(不是 true)。后端如果按「有这个键就算 true」来解析还好,按类型严格校验就会直接拒收。name + FormData 解决就别建 ref。ref 留给那些「读值以外」的事:让某个字段自动聚焦、把出错的字段滚进视口、调 select() 选中全部文本。字段一多,一堆 ref 比一堆 state 更难维护。React 19 把「提交 → 显示进行中 → 拿到结果 → 显示成功或错误」这套人人都在手写的样板收进了框架。先澄清一个最大的误解:这套 API 在纯客户端就能用,和 Next.js、和服务端函数没有任何绑定关系。
三件套与准确签名
<form action={fn}>:fn会收到这个表单的FormData。React 自动帮你preventDefault,不用再写。const [state, formAction, isPending] = useActionState(fn, initialState),其中fn的签名是(prevState, formData) => nextState,可以是 async 的。把返回的formAction交给<form action>。const { pending, data, method, action } = useFormStatus(),从react-dom导入(不是react)。返回的键正是这四个。
在纯 react-dom 客户端里完整跑通
用一个订阅表单,全程无任何告警:
- 第一次提交非法邮箱,action 收到
formData.email = 不是邮箱,此时prevState是初值{"ok":null,"msg":""},返回{"ok":false,"msg":"邮箱格式不对"}。 - 第二次提交,
prevState就是上一次的返回值{"ok":false,…}——这正是它叫 ActionState 的原因,状态在多次提交之间接力。 - action 执行期间
isPending为true、子组件里的pending也为true,结束后双双回到false。
两个必须知道的行为
- useFormStatus 必须放在 form 的子组件里。把它写在渲染
<form>的那个组件里,pending全程是false,连一次重渲染都不会发生;挪进<form>内部的子组件后才正常读到true→false。它读的是「我上方最近的那个 form 的提交状态」。 - action 结束后表单会被自动重置。非受控输入提交前是
"me@x.com",action 跑完变成""。受控输入不受影响,提交后 DOM 值仍是"abcde"——因为 state 还在,React 又把它写了回去。这个差别第一次遇到会很困惑。
import { useActionState } from "react";
import { useFormStatus } from "react-dom"; // 注意是 react-dom
// useFormStatus 只能在 form 的「子组件」里用,写在外层读到的永远是 false
function SubmitButton() {
const { pending } = useFormStatus();
return <button type="submit" disabled={pending}>{pending ? "提交中…" : "订阅"}</button>;
}
function Subscribe() {
// 签名:(prevState, formData) => nextState,第二个参数是初始 state
const [state, formAction, isPending] = useActionState(
async (prev, formData) => {
const email = formData.get("email");
if (!String(email).includes("@"))
return { ok: false, msg: "邮箱格式不对" }; // 错误当返回值,不用抛
await fetch("/api/subscribe", { method: "POST", body: formData });
return { ok: true, msg: email + " 订阅成功" };
},
{ ok: null, msg: "" },
);
return (
// action 收到 FormData,React 自动 preventDefault;跑完会重置非受控字段
<form action={formAction}>
<input name="email" defaultValue="" disabled={isPending} />
<SubmitButton />
<p>{state.msg}</p>
</form>
);
}useFormStatus() 写在渲染 <form> 的那个组件里——pending 恒为 false,而且不报任何错,你只会看到「加载态永远不显示」。修法是把提交按钮抽成一个子组件放进 <form> 里面。另一个常见错是从 react 导入它,正确来源是 react-dom。return { ok: false, msg })而不是抛异常。抛出去会冒到错误边界,整块界面被替换掉;作为返回值它会稳稳落进 state,用户看到的是表单原地报错、内容还在(见 11 章)。useActionState 的第三个返回值 isPending 已经够用,只有当提交按钮被拆进独立子组件时才需要 useFormStatus。校验难的从来不是判断对错,而是决定什么时候告诉用户他错了。太早唠叨(刚敲第一个字符就红一片)和太晚才说(填完二十项一起爆)一样让人放弃。
时机:一条经过验证的默认策略
| 时机 | 体验 | 建议 |
|---|---|---|
onChange | 还没打完就报错,体验最差 | 不要作为首次校验时机 |
onBlur | 离开字段才说,符合直觉 | 单字段校验的默认选择 |
onSubmit | 最晚,但跨字段校验只能在这 | 兜底,且必做 |
综合起来的策略是:首次校验放在 onBlur 或 onSubmit;某个字段一旦报过错,就把它切换成 onChange 实时校验——用户在改错时能立刻看到错误消失,这个正反馈很值钱。原则一句话:错了才实时,没错前别催。
原生校验属性:免费但不好看
required、type="email"、min / max、minLength、pattern 这些属性浏览器直接支持,白送你一个 validity 对象。type="email" 填「不是邮箱」→ validity.typeMismatch 为 true;min="18" 填 12 → rangeUnderflow 为 true;pattern="[A-Z]{3}" 填 ab → patternMismatch 为 true;form.checkValidity() 返回 false。
代价是提示气泡的文案和样式由浏览器决定,各家不一致、难以本地化、和你的设计稿必然对不上。实用折中:属性照写(它们同时是给辅助技术看的语义),在 <form> 上加 noValidate 关掉浏览器气泡,然后自己读 validity 出文案——判断逻辑照用,呈现自己控制。
大表单要不要上 React Hook Form
受控表单每按一次键就重渲染整个表单,打 5 个字符 = 5 次重渲染。二十个字段、每个都带校验时,每次按键都要重跑全部字段的 JSX 与校验逻辑,输入延迟肉眼可见。
- 换成 React Hook Form 这类基于非受控的库优用
register把 ref 挂到 DOM 上,值留在 DOM 里,打字过程完全不触发重渲染;只有校验状态变化时才局部更新。校验规则、错误信息、跨字段依赖都有现成方案短值不在 React state 里,DevTools 看不到,调试要靠它自己的watch;对接第三方受控 UI 组件(日期选择器、下拉框)必须用Controller包一层,反而绕为何优点和短板同根在「值不进 React」。正因为不进,才躲开了每次按键的渲染;也正因为不进,React 世界里的一切(DevTools、受控组件、随时读值)就都用不上了
判断线很实在:字段少于十个、没有复杂联动,手写受控就够了,别为了「业界最佳实践」引依赖;一旦出现动态增删字段、跨字段校验、多步骤表单,或者打字已经明显卡顿,再上库。
function EmailField() {
const [value, setValue] = useState("");
const [error, setError] = useState(null);
const validate = (v) => v.includes("@") ? null : "请填写有效的邮箱地址";
// 首次校验放在失焦:等用户打完了再评判
const onBlur = (e) => setError(validate(e.target.value));
// 只在「已经报过错」之后才转为实时校验:错了才实时,没错前别催
const onChange = (e) => {
setValue(e.target.value);
if (error) setError(validate(e.target.value));
};
return (
<label>
{/* required 留着:辅助技术、自动填充、移动端键盘都靠它 */}
<input
type="email" required value={value}
aria-invalid={error ? "true" : undefined}
onChange={onChange} onBlur={onBlur}
/>
{error && <span role="alert">{error}</span>}
</label>
);
}
// 想复用原生判断、自己控制文案:给 form 加 noValidate 关掉气泡,再读 validity
// if (el.validity.typeMismatch) setError("邮箱格式不对");curl:required、pattern、disabled 全都在用户那一侧,改一改 DOM 就没了。服务端必须原样再校验一遍(见 15 章)。附带一个小陷阱:type="number" 的 e.target.value 仍是字符串,拿去比大小会变成字典序比较。role="alert" 和 aria-invalid 标好,别只在表单顶部堆一个错误列表——屏幕阅读器用户和长表单用户都找不到出错的是哪一项。提交失败后把焦点移到第一个出错的字段(用 ref 调 focus()),这一个小动作能显著降低放弃率。useEffect 与副作用
每次渲染都生成全新的 effect 闭包,依赖数组是你对 React 的手动申报。理解清理时机、陈旧闭包与 useEffectEvent,是写对副作用的关键。
依赖数组不是性能开关,而是你替 React 填的一张申报表。React 只会重跑组件函数,它无从知道你的 effect 用到了哪些值,所以它把这件事原样丢回给你——你申报什么,它就盯什么。
先接受一件事:每次渲染都生成一个全新的 effect
- 组件函数每渲染一次就是一次独立的函数调用。写在里面的
useEffect(() => {...})那个箭头函数,每一帧都是新造的一个闭包,捕获的是「这一帧」的 props 与 state 快照; - 所以「effect 只跑一次」这句话严格说是假的——effect 函数每帧都被创建,只是 React 按依赖数组决定要不要执行它。这个区别在本章后面几张卡里会反复咬人;
- React 比较依赖用的是
Object.is,也就是浅比较。对象和数组字面量每帧都是新引用,写进依赖数组等于「每次渲染都变了」,effect 就会次次重跑。
三种写法,三种语义
| 写法 | 什么时候执行 | 典型用途 | 风险 |
|---|---|---|---|
| 不写数组 | 每次渲染提交后都跑 | 几乎没有正当用途 | effect 里若含 setState 极易死循环 |
[] | 只在挂载后跑一次 | 建立一个与外部世界的长期连接 | 闭包永远冻在首帧,读到的值全是过期的 |
[a, b] | a 或 b 与上一帧不同时重跑 | 绝大多数场合 | 漏报就是 stale closure,多报就是无谓重跑 |
判断顺序永远是:先诚实写全依赖,跑起来不对劲再回头改代码,而不是回头删依赖。删依赖能让 lint 闭嘴,但 bug 一个不少。
一次更新里,到底谁先跑
右侧代码在实验台跑出来的真实顺序是(点一次按钮,n 从 0 变 1):
render n=1→layout cleanup n=0→layout n=1→effect cleanup n=0→effect n=1。
关键在两点:清理一定跑在同名 effect 重跑之前;useLayoutEffect 整个跑完之后,useEffect 才开始。useLayoutEffect 在浏览器绘制前同步执行,用来「读了真实布局、又要赶在用户看到之前改掉」(测量气泡高度再定位是唯一常见场景);它同步阻塞绘制,滥用直接拖慢帧率;而服务端渲染时它不执行、也不会有任何告警(renderToString 产物正常、console.error 与 console.warn 各 0 条)——又一个静默差异,别指望 React 提醒你。
import { useState, useEffect, useLayoutEffect } from "react";
function App() {
const [n, setN] = useState(0);
console.log(`render n=${n}`);
// 绘制前同步执行,整批跑完才轮到 useEffect
useLayoutEffect(() => {
console.log(` layout n=${n}`);
return () => console.log(` layout cleanup n=${n}`);
});
// [n] 变了才重跑;重跑前先执行上一帧留下的清理
useEffect(() => {
console.log(` effect n=${n}`);
return () => console.log(` effect cleanup n=${n}`);
}, [n]);
// [] 的闭包永远冻在首帧,卸载时它的清理打印的仍是 n=0
useEffect(() => () => console.log(` [] cleanup n=${n}`), []);
return <button onClick={() => setN(x => x + 1)}>{n}</button>;
}useEffect(fn, [{ id }]) 或 [props.list.filter(...)] 每帧都是新引用,Object.is 判定为「变了」。要么把依赖拆成原始值([user.id] 而不是 [user]),要么用 useMemo 稳住引用(见 06 章)。useEffect,只有「读了真实布局(宽高、位置),还要赶在用户看到之前改掉」才换 useLayoutEffect。它同步阻塞绘制,而且服务端渲染时根本不执行——需要同构时降级成 useEffect,或者用库提供的同构版本。你在组件里写了一句 console.log,控制台打了两遍。这不是 bug,是 <StrictMode> 在故意把每次渲染跑两次——它在替你找那种「跑两次结果就不一样」的代码。这是新手遇到的第一个惊吓,也是 React 送你的第一个免费检测器。
日志长什么样
- 一个带
console.log("render", n)的组件,挂载时输出是render 0 | render 0,点一下按钮之后是render 1 | render 1。组件函数一共跑了 4 次,而 DOM 里始终只有一个按钮——多跑的那一次结果被丢弃了。 - 挂载时 effect 也会多跑一轮。顺序是
建立 0 → 清理 0 → 建立 0:React 故意把 effect 建立完立刻清理再重新建立一次,看你的清理函数写得对不对(就是下一卡的正题)。 - 注意更新时 effect 不会双跑,只有渲染双跑。双跑 effect 只发生在挂载那一次。
它到底在找什么:不纯
React 要求组件是纯函数——同样的 props 和 state,必须返回同样的 JSX,并且渲染过程中不许改任何外部的东西。纯函数跑两次结果一模一样,不纯的跑两次就会露馅。
一个组件在函数体里写 total += n(渲染时修改外部变量),两个实例分别传 1 和 2。
| 环境 | total 值 | 说明 |
|---|---|---|
| 不开 StrictMode | 3 | 看起来一切正常 |
| 开 StrictMode | 6 | 翻倍了——当场证明这个组件不纯 |
换句话说,StrictMode 不制造 bug,它只是把你本来就有的 bug 提前到开发阶段暴露。数字翻倍、请求发两遍、列表里多出一份,都是同一件事的不同表现。
只在开发环境
npm run build打出来的生产产物不会双跑,双渲染的代码在打包时就被剥掉了。所以「会不会拖慢线上」这个担心是不成立的。- 真正的风险方向是反过来的:为了让日志干净而删掉
<StrictMode>,等于把检测器扔了。生产环境虽然不双跑,但 React 的并发渲染本来就允许中途放弃一次渲染再重来——不纯的组件在线上会以更零星、更难复现的方式出错。
import { StrictMode, useState, useEffect } from "react";
let total = 0; // 故意演示一个不纯的组件
function Item({ n }) {
total += n; // 渲染期间改外部变量 = 副作用
return <li>{n}</li>;
}
function App() {
const [n, setN] = useState(0);
console.log("render", n); // 打两遍:render 0 | render 0
useEffect(() => {
console.log(" 建立", n);
return () => console.log(" 清理", n);
}, [n]); // 挂载建立0 → 清理0 → 建立0
return (
<ul onClick={() => setN(n + 1)}>
<Item n={1} /><Item n={2} />
</ul>
);
}
// 包 StrictMode 时 total = 6,不包时 = 3。翻倍就是「不纯」的铁证<StrictMode>,是本页最亏本的一笔交易。它把一个开发期就能发现的问题推到线上,变成偶发、难复现、日志里看不出的怪 bug。正确姿势是留着它,并把它当成免费的代码审查——凡是它闹起来的地方,几乎都真的有问题。effect 的心智模型不是「挂载时做什么、卸载时做什么」,而是「如何开始同步」与「如何停止同步」。React 会在它认为该重新同步时,先停掉旧的、再开始新的——你必须把「停」也写出来,否则每次重跑都是一次泄漏。
清理函数在两个时刻执行
- 组件卸载时——这个大家都知道;
- 下一次同一个 effect 重跑之前——这个才是经常被忘的。依赖变了,React 先跑上一帧留下的清理,再执行新的 effect 函数。上面那张时序卡的日志
effect cleanup n=0→effect n=1就是这条; - 清理函数拿到的是它自己那一帧的闭包,所以它关掉的一定是它自己开出去的那个连接、那个 timer id,绝不会串台。这也是为什么必须在 effect 内部创建资源,而不是放模块顶层。
三个必须清理的典型
| 开出去的东西 | 清理动作 | 不清理的后果 |
|---|---|---|
setInterval / setTimeout | clearInterval / clearTimeout | 组件没了定时器还在跑,回调里 setState 触发对已卸载组件的更新 |
addEventListener | removeEventListener(必须传同一个函数引用) | 监听器越积越多,一次事件触发 N 遍回调 |
| WebSocket / 第三方 store 订阅 | 调它给你的 unsubscribe / close | 连接泄漏,切换房间时新旧消息同时涌进来 |
StrictMode 故意让你出丑
开发环境下 <StrictMode> 会把组件挂载 → 卸载 → 再挂载一遍,effect 因此执行两次。这不是 bug,是一次免费体检:只要清理写对了,第二次挂载前旧资源已经拆干净,最终状态和跑一次完全一样;如果你看到两个定时器、两条 WebSocket、两份重复数据,说明清理漏了或写错了。生产构建不做这件事,但线上真会发生的重跑(依赖变化、路由往返)暴露的是同一个洞。不要靠加一个 ref 挡住第二次执行来「修复」它,那是把体检报告撕了。
import { useState, useEffect } from "react";
function ChatRoom({ roomId }) {
const [msgs, setMsgs] = useState([]);
useEffect(() => {
// 资源必须在 effect 内部创建,闭包才对得上
const conn = new WebSocket(`wss://chat/${roomId}`);
const onMsg = (e) => setMsgs(m => [...m, e.data]);
conn.addEventListener("message", onMsg);
// roomId 变化时:先跑这里断开旧房间,再连新房间
return () => {
conn.removeEventListener("message", onMsg); // 同一个引用才摘得掉
conn.close();
};
}, [roomId]);
// 反例:清理里写 removeEventListener("message", (e) => ...)
// 新建的箭头函数和注册时那个不是同一个引用,永远摘不掉
return <ul>{msgs.map((m, i) => <li key={i}>{m}</li>)}</ul>;
}removeEventListener 必须传注册时那个函数引用。很多人在注册和清理里各写一个箭头函数,看起来对称,实际是两个不同的函数对象,监听器一个都摘不掉——组件反复挂载后同一次点击会触发好几遍回调,而且没有任何报错。把处理函数抽成一个 const 变量再两处引用。return。反过来,如果一个 effect 根本没开出去任何东西(只是算了个值、setState 一下),那它多半根本就不该是 effect——见本章最后一张卡。陈旧闭包(stale closure)不是 React 的怪癖,而是 JavaScript 闭包的必然结果:一个函数捕获的是它被创建那一刻的变量,之后外面再怎么变都与它无关。React 每帧重造函数,于是「哪一帧造的」就决定了「读到哪一帧的值」。
屏幕上是 3,定时器读到的永远是 0
右侧那段代码在实验台跑出来的结果是——连点三次按钮后 DOM 上显示 3,而每秒跑一次的定时器回调打印的始终是 定时器读到 count=0,一次都没变过。
原因:[] 让 effect 只在挂载后执行一次,那次执行时 count 是 0,setInterval 的回调捕获了那一帧的 count。后面三次渲染各自造了新的 effect 闭包,但 React 按你的申报「依赖没变」,一个都没执行。依赖数组里撒的谎,会以「读到过期数据」的形式还回来,而且完全不报错。
三种正解,按优先级排
| 做法 | 适用 | 代价 |
|---|---|---|
| 诚实写全依赖,让 effect 重跑 | 默认选项。订阅、连接这类「重建一次不心疼」的资源 | 依赖变化频繁时会反复拆建 |
函数式更新 setN(c => c + 1) | 只是「基于旧值算新值」,那就根本不需要读 state | 只解决 setState 一类,读别的值没用 |
useEffectEvent 把值摘出去 | 要用某个值、但它变了不该触发重跑 | React 19.2 起才有,见下一卡 |
还有一种老办法是拿 useRef 存最新值、effect 里读 ref.current(能读到 3)。它有效,但把「什么时候该重跑」这件事藏进了一个可变盒子里,可读性差;useEffectEvent 就是官方给这个手法的正名版本。
为什么不要跟 lint 对着干
react-hooks/exhaustive-deps的判断几乎不会错,错的是「我知道我在干什么」的自信;- 看到它报警,正确反应是改代码结构(换函数式更新、把函数挪进 effect 内部、拆出 effect event),而不是加一行
eslint-disable; - React Compiler(见 09 章)会自动记忆化,但它不会替你补依赖——stale closure 是语义 bug,编译器管不了。
import { useState, useEffect } from "react";
// ❌ 依赖撒谎:用了 count 却申报 []
function Bad() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log("定时器读到 count=" + count); // 永远是 0
}, 1000);
return () => clearInterval(id);
}, []); // 闭包冻在首帧,之后三次点击一次都没重跑
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
// ✅ 根本不需要读 count:函数式更新只描述「怎么变」
function Good() {
const [n, setN] = useState(0);
useEffect(() => {
const id = setInterval(() => setN(c => c + 1), 1000);
return () => clearInterval(id);
}, []); // 依赖为空是诚实的:effect 体内确实没读任何 state
return <b>{n}</b>;
}ref 存最新值」不是万能解药。它能修好「读到旧值」,但同时也消灭了「值变了该重新同步」这层语义——如果这个值本来就应该触发重连、重订阅,用 ref 挡住会得到一个连着旧房间却显示新房间名的界面。先想清楚这个值到底该不该触发重跑,再选工具。setInterval、setTimeout、事件监听、WebSocket 回调这四类——它们的共同点是回调活得比创建它的那一帧长,最容易把过期快照带到未来。依赖数组只有一档粗糙的语义:用了就得申报,申报了就会重跑。可现实里总有第三种值——「我要读它,但它变了不该重连」。useEffectEvent(React 19.2 新增,typeof React.useEffectEvent === "function")就是官方给这一档开的口子。
它把 effect 切成响应式和非响应式两半
- 包在
useEffectEvent里的那段逻辑叫「effect event」:它不是响应式的,里面读到的所有值都不进依赖数组; - 但它每次被调用时读到的都是最新的值——不是快照,是实时的。这一点和 stale closure 的
ref手法效果一样,语义却清晰得多; - 心智模型:effect 决定「什么时候同步」,effect event 决定「同步时做什么」。前者响应依赖,后者只管执行。
日志:只有 roomId 会触发重连
右侧代码在实验台跑三次渲染——先 general/light,再只把主题改成 dark,最后把房间改成 music。输出是:
连上 general,当时主题=light断开 general连上 music,当时主题=dark
两件事同时成立:中间那次只改 theme 时一条日志都没有(effect 一次都没重跑);而真正因 roomId 重跑时,读到的 theme 是最新的 dark,不是首帧的 light。这正是上一张卡里 ref 手法想要、却说不清楚的那个效果。
它和「诚实写全依赖」怎么选
不是所有值都该摘出去,摘错了 bug 更隐蔽。
- 诚实写全依赖,让 effect 重跑优语义最直白,lint 自动帮你把关,谁看都懂短无关的值一变就拆连接重建,切个主题都要断线重连为何同根在「依赖数组只有一档语义」——它无法表达「用了但不响应」,只能一刀切
- 用
useEffectEvent摘出去优精确控制重跑时机,读到的还永远是最新值短多一层间接;摘错了值,界面会停在「不该停」的旧状态上而毫无提示为何同根在它绕开了 lint 的静态检查——好处是你说了算,坏处也是你说了算
import { useEffect, useEffectEvent } from "react";
function ChatRoom({ roomId, theme }) {
// 非响应式:读 roomId 和 theme,但它们都不进依赖数组
const onConnected = useEffectEvent(() => {
showToast(`连上 ${roomId},当时主题=${theme}`);
});
useEffect(() => {
const conn = connect(roomId);
conn.on("open", onConnected); // 调用时读到的是最新的 theme
return () => conn.close();
}, [roomId]); // 只申报 roomId:改主题不重连,一条日志都没有
// ❌ 千万别写成 [roomId, onConnected]
// useEffectEvent 每次渲染返回的是新引用,写进依赖等于每帧重跑
return <div className={theme}>{roomId}</div>;
}[ev, roomId] 后只改无关的 prop 也会让 effect 每帧重跑,等于白摘。其二,别在渲染期直接调用它,抛出 Error: A function wrapped in useEffectEvent can't be called during rendering.——它只能在 effect 或事件处理函数里调。useEffectEvent。典型的「不希望」是主题、当前用户、埋点上下文、onXxx 回调 prop 这类陪跑数据;典型的「希望」是 roomId、userId 这类决定同步对象是谁的标识。在 effect 里发请求,最大的敌人不是加载态,而是时间:依赖快速变化会同时飞出去好几个请求,而网络不保证先发先回。谁最后 setState,谁就赢——这跟谁的数据正确毫无关系。
慢的那个请求赢了
实验台里模拟两个请求,userId=1 耗时 40ms,userId=2 耗时 5ms。渲染 userId=1 后立刻切到 2:
- 裸写
fetch(...).then(setData)→ 最终屏幕显示用户1的资料。快的 2 先回来渲染上去,40ms 后慢的 1 才回来,把正确结果盖掉了; - 加上
cancelled标志 → 最终显示用户2的资料,正确。
这个 bug 在本地开发几乎撞不上(延迟太低),一上线用户手快点两下就复现。它没有报错、没有告警,只是数据静静地错了。
两种作废手段
cancelled 标志 | AbortController | |
|---|---|---|
| 做了什么 | 请求照发照回,只是丢弃过期响应 | 真的中断底层请求 |
| 省不省流量 | 不省 | 省,服务端也能提前放手 |
| 额外负担 | 无 | 被取消的 fetch 会 reject,必须捕获 AbortError 否则控制台一片红 |
| 选谁 | 够用,且对任何 Promise 都适用 | 优先,前提是你的取数函数肯接受 signal |
两者可以一起用:AbortController 负责省流量,cancelled 负责兜住那些取消不掉的异步(比如已经进入 .json() 解析阶段的)。
然后你会发现,这只是清单上的第一项
- 竞态解决了,接下来还有:缓存(返回上一页别再请求一遍)、并发去重(三个组件要同一份数据只该发一次)、重新验证(切回标签页时后台刷新)、重试、分页与无限滚动、写完之后让相关缓存失效;
- 这些全手写,每个组件里都要重来一遍,而且每一项都有自己的竞态。客户端取数的事实标准是 TanStack Query(旧名 React Query)与 SWR:你给一个「查询键 + 取数函数」,上面这一整张清单由它兜住,组件里只剩
data/isLoading/error; - 代价是多一个依赖、多一套缓存心智(
staleTime、gcTime、查询键设计)。但只要你的应用超过三五个数据源,这个代价一定比手搓便宜; - 还有一条更彻底的路:如果用 Next.js App Router,数据在服务端组件里
await就取完了(见 14 章),根本不存在客户端竞态。effect 取数是没有服务端可用时的方案,不是默认方案。
import { useState, useEffect } from "react";
function Profile({ userId }) {
const [user, setUser] = useState(null);
const [err, setErr] = useState(null);
useEffect(() => {
let cancelled = false; // 兜住取消不掉的那部分
const ac = new AbortController(); // 真的中断请求,省流量
(async () => {
try {
const res = await fetch(`/api/users/${userId}`, { signal: ac.signal });
const data = await res.json();
if (!cancelled) setUser(data);
} catch (e) {
// 取消导致的 reject 不是错误,别渲染成错误态
if (e.name !== "AbortError" && !cancelled) setErr(e);
}
})();
return () => { cancelled = true; ac.abort(); };
}, [userId]); // userId 一变:先作废旧请求,再发新的
if (err) return <p>出错了</p>;
return <b>{user?.name ?? "加载中"}</b>;
}AbortController 取消请求会让 fetch reject,不是静默返回。清理函数里 abort() 之后,那个 await fetch 会抛出 AbortError;如果你的 catch 一律 setErr(e),用户只是快速切了个标签页,界面就弹出「加载失败」。必须先判 e.name !== "AbortError" 再当错误处理。useState 或状态库(见 07 章)。把服务器状态硬塞进全局 store 手动维护,是中大型项目最常见的自找麻烦。新手写出的 effect,大概有一半是多余的。根源是把 effect 当成了「生命周期钩子」——「数据变了我要做点什么」。effect 的正确定位只有一个:把组件和一个 React 管不着的外部系统同步起来。不涉及外部系统的,都不该是 effect。
三分口诀
| 这段逻辑是…… | 放哪 | 例子 |
|---|---|---|
| 渲染时就能算出来的 | 直接在函数体里算,昂贵时套 useMemo | 全名、筛选后的列表、总价、是否可提交 |
| 用户做了某事才触发的 | 事件处理函数 | 提交表单、埋点上报、弹 toast、跳转 |
| 和外部系统保持同步的 | 才轮到 useEffect | WebSocket、订阅、手动操作 DOM、接入非 React 库 |
用错的代价不只是「不优雅」。右侧那个「派生数据放 effect + state」的写法,一次交互跑了 4 次渲染,每次渲染看到的值是 ["", "张 三", "张 三", "李 三"]——首帧屏幕上是空的,中间还闪了一帧旧值;改成渲染时直接派生,同样的交互只有 2 次渲染,值是 ["张 三", "李 三"],一帧都不闪。
几个高频反模式
- 用 effect 同步派生 state——上面那个例子。判断标准:这个 state 的值能不能从别的 state 或 props 算出来?能就删掉它;
- 用 effect 响应「props 变了要重置内部 state」——绝大多数场合改用
key让 React 整个重建组件更干净(见 03 章); - 用 effect 通知父组件(
useEffect(() => onChange(value), [value]))——直接在触发变化的那个事件处理函数里同时调onChange,少一趟渲染,还避免了「父子来回同步」的死循环; - 用 effect 做「提交后弹提示」——这是用户操作的后续,写在
handleSubmit里。放 effect 会导致刷新页面、路由返回时莫名其妙又弹一次。
唯一的正当理由:外部系统
「外部系统」的判据是:它不受 React 渲染管辖,React 重渲染时它不会自动跟着变。浏览器 API(document.title、localStorage、媒体播放器)、网络连接、第三方图表/地图实例、全局事件总线——这些都是。而「另一个组件的 state」不是外部系统,它就在 React 里,用 props 或状态提升解决。
// ❌ 派生数据塞进 effect + state:一次交互跑了 4 次渲染
const [first, setFirst] = useState("张");
const [cached, setCached] = useState("");
useEffect(() => { setCached(first + " 三"); }, [first]);
// 首帧 cached 是空字符串,第二帧才补上 —— 屏幕会闪
// ✅ 渲染时直接派生:只跑 2 次渲染,无中间态
const full = first + " 三";
// ❌ 把「提交后的后续」放 effect:路由返回时会莫名再弹一次
useEffect(() => { if (saved) showToast("已保存"); }, [saved]);
// ✅ 用户操作的后续就写在事件处理函数里
async function handleSubmit(e) {
e.preventDefault();
await save(draft);
showToast("已保存");
onSaved(draft); // 通知父组件也在这里,别再绕一趟 effect
}
// ✅ 这才是 effect:document.title 是 React 管不着的外部系统
useEffect(() => { document.title = full; }, [full]);Error: Maximum update depth exceeded.。但更糟的情况是它没死循环,只是每次交互多跑一两趟渲染,你根本不会发现——直到列表变长后界面开始发卡。return 清理。没有清理的 effect,八成没在跟任何外部系统打交道,值得高度怀疑。再问一句「它的第一行是不是 setXxx」——如果是,几乎可以断定这个 state 该被删掉,换成渲染时直接算。核心 Hooks
useRef、useMemo、useContext、useTransition 到 React 19 的 use()——逐个讲清机制、适用场景与各自的代价。
useRef 返回一个跨渲染始终是同一个的 { current } 对象。它和 useState 的唯一区别只有一条,但这条决定了一切:改 .current 不会通知 React。所以它装的是「组件要记住、但屏幕不关心」的东西。
屏幕会撒谎
实验台里一个组件同时持有 state 和 ref,连点三次「ref+1」:
- 渲染记录只有一条
render state=0 ref=0——三次点击一次渲染都没触发; - 屏幕上仍然显示
0/0,尽管ref.current其实已经是3; - 之后随便点一次「state+1」,屏幕突然跳到
1/3——那个3一直在,只是没人去重新渲染。
这就是 ref 的全部性格:它记得住,但它不吭声。把该显示的数据放 ref,就会得到这种「值对了、界面不动」的诡异 bug。
两个用途
| 用途 | 怎么写 | 要点 |
|---|---|---|
| 拿真实 DOM 节点 | <input ref={r} /> | 渲染期 r.current 是 null,React 提交 DOM 之后才填上;只能在 effect 或事件处理函数里读 |
| 存不影响渲染的可变值 | r.current = 值 | 定时器 id、上一次的 props、第三方实例、「是否已初始化」这类标记 |
React 19 起 ref 是普通 prop:函数组件直接写 function MyInput({ ref }) 就能接住,父组件能拿到真实 <input> 节点且无任何告警。forwardRef 在 19.2.8 里仍然可用、也不打废弃告警,官方说法是未来版本才会移除——新代码用 prop 写法,老代码不必急着改。
不要在渲染期读写 ref
渲染必须是纯的:同样的输入给出同样的输出。渲染期读 ref.current,读到的值取决于「之前有没有人改过它」,同一份 props 可能渲染出不同结果;渲染期写 ref.current,在并发渲染下 React 可能丢弃这次渲染再重来一遍,你的写入就重复执行了。读写 ref 只在事件处理函数和 effect 里做——初始化用 useRef(初值) 的参数,别用 if (!r.current) r.current = ... 这种渲染期赋值(除非你确实要做一次性惰性初始化,那是唯一被官方点名认可的例外)。
import { useState, useRef, useEffect } from "react";
function StopWatch() {
const [ms, setMs] = useState(0);
const timerRef = useRef(null); // 屏幕不关心 timer id,放 ref
const inputRef = useRef(null); // 渲染期是 null,提交后才是真实节点
useEffect(() => {
inputRef.current.focus(); // effect 里才读得到
return () => clearInterval(timerRef.current);
}, []);
const start = () => {
if (timerRef.current) return; // 防重复启动
timerRef.current = setInterval(() => setMs(t => t + 100), 100);
};
const stop = () => { clearInterval(timerRef.current); timerRef.current = null; };
return (
<div>
<input ref={inputRef} />
<button onClick={start}>开始</button>
<button onClick={stop}>停</button>
{/* ms 要上屏,所以它必须是 state 而不是 ref */}
<b>{ms}</b>
</div>
);
}ref.current 拿到的几乎总是 null。组件函数体里打印是 null,同一个 ref 在 useEffect 里打印是 INPUT。因为 React 先调用组件函数拿到要渲染的内容,之后才创建 DOM 节点并回填 ref。想在挂载后立刻 focus,必须写进 effect,写在函数体里会得到 Cannot read properties of null。useState,不需要就是 useRef。还有第三档常被忘记——如果这个值能从别的 state 算出来,那它两个都不是,渲染时直接算就行(见 05 章最后一卡)。这两个 Hook 的机制完全一样:依赖数组和上一帧逐项相同,就把上次的结果原样返回;否则重新算一遍。useMemo 缓存的是计算结果,useCallback 缓存的是函数本身——后者只是前者的语法糖,useCallback(fn, d) 严格等价于 useMemo(() => fn, d)(两种写法跨渲染都保持同一引用)。
它们各自挡住了什么
- 一个组件里
useMemo(() => ..., [n]),改另一个无关 state 两次 → 计算次数仍是 1;改n一次 → 变成 2。缓存生效; - 子组件
memo包裹、prop 传useCallback稳住的函数 → 父组件重渲染两次,子组件只渲染了 1 次; - 同样的子组件,prop 改成行内箭头函数
onClick={() => {}}→ 父组件重渲染一次,子组件就跟着渲染了 2 次。memo白加了。
最后这条是关键:useCallback 单独用毫无意义。它的全部价值是让下游的 memo 或另一个 Hook 的依赖数组比较能通过。下游没有 memo、也没人把它当依赖,那就是纯开销。
它们不是免费的
- 每个
useMemo都要保存上次的依赖和结果(占内存),并在每次渲染都做一遍依赖比较(占时间)。缓存一个a + b这种计算,比较的开销比计算本身还大; - 而且它不保证缓存一定命中——React 明确保留了丢弃缓存、重新计算的权利,所以它是性能优化,不能当语义保证(别依赖它来「只执行一次副作用」);
- 真正的判断依据、React Compiler 会不会让这两个 Hook 彻底退休、以及怎么用 Profiler 量出「昂贵」,全部在 09 章展开。本卡只负责让你看懂机制。
useMemo 还有一个非性能用途
常被忽略:把一个对象或数组稳定成同一个引用,好让它能安全地进依赖数组。useEffect(fn, [{ id }]) 每帧都是新对象、effect 每帧重跑,套一层 useMemo 就治好了。这时候 useMemo 解决的不是「算得慢」,而是「引用不稳」——这类用法即使计算本身很便宜也完全正当。
import { useState, useMemo, useCallback, memo } from "react";
const Row = memo(function Row({ item, onPick }) {
return <li onClick={() => onPick(item.id)}>{item.name}</li>;
});
function List({ items, onPick }) {
const [q, setQ] = useState("");
const [theme, setTheme] = useState("light");
// 依赖是 [items, q],所以切 theme 时一次都不重算
const shown = useMemo(
() => items.filter(i => i.name.includes(q)).sort((a, b) => a.price - b.price),
[items, q]
);
// 只有下游是 memo 组件时,这层包装才有意义
const handlePick = useCallback((id) => onPick(id), [onPick]);
return (
<ul className={theme}>
{shown.map(i => <Row key={i.id} item={i} onPick={handlePick} />)}
</ul>
);
}useMemo 当成「只执行一次」的保证。React 文档明确保留在内存压力下丢弃缓存、下次渲染重新计算的权利,所以 useMemo(() => new WebSocket(url), [url]) 这种写法随时可能给你开出第二条连接,而且这个 bug 在开发环境几乎必然复现不出来。建立连接、订阅、注册监听一律走 useEffect 并配清理函数(见 05 章)。useCallback 只有在下游有 memo、或这个函数要进别人的依赖数组时才有价值。写之前先找一遍它的消费者:找不到就删掉,你只是在给每次渲染增加一次无用的依赖比较。useMemo 同理,外加一个正当例外——为了稳住引用而不是为了算得快。Context 只干一件事:把一个值沿着渲染树往下广播,任意深度的后代都能直接取到。它自己不存状态、不管更新——值从哪来、什么时候变,全是别人的事。理解它只需要三条机制:谁在广播、消费者取谁的值、什么时候会重新通知。
三段式,以及 React 19 的新写法
- 创建:
const LevelContext = createContext(0)。括号里那个默认值只在向上一个 Provider 都找不到时才生效——把一个消费者裸放在树上,取到的正是0。它不是「初始值」,是「兜底值」。 - 提供:React 19 起可以把 context 对象本身当组件用——
<LevelContext value={1}>,取值正确且无任何告警。老写法<LevelContext.Provider value={1}>在 19.2.8 里仍然可用、也未废弃,只是新代码没理由再多打九个字符。 - 消费:
useContext(LevelContext)。它不接收 Provider 的引用,只认那个 context 对象——React 拿着它沿 fiber 树向上找,找到的第一个提供者就是答案。
就近取值:同一个组件能渲染出不同结果
「取最近的那个」不是补充说明,而是 Context 最有用的性质。嵌套 Provider 完全合法,内层天然覆盖外层。一个只有 useContext 的 Heading 组件,套在三层逐级加一的 Section 里,渲染出的 HTML 是:
<h1>一级标题</h1><h2>二级标题</h2><h3>三级标题</h3>
组件源码一个字都没改,输出全靠它在树上的位置决定。局部换主题、局部换语言、表单里嵌一段只读区域,靠的都是这条。
另一个容易想歪的点:决定取到什么值的是「渲染位置」,不是「元素在哪写的」。把 <Probe /> 写在外层、再当 children 传进一个内部提供 「内」 的组件,Probe 读到的是 内——元素虽然在外层创建,最终却是在内层那个 Provider 的位置上被渲染的。
什么时候重新通知消费者
- React 用
Object.is比较 Provider 的新旧value。判定为不同,就把这棵子树里所有读了它的消费者标记为需要更新;判定为相同,一个都不通知。 - 这个通知是全有全无的:value 是个对象时,改的是哪个字段 React 并不关心,读了这个 context 的组件一律重渲染,哪怕它只用到没变的那半边。Context 里没有「选择器」这一层——这是它作为广播通道的结构性事实,不是可以调优掉的缺陷。
- 通知走的是 fiber 上一条独立的订阅链,不经过 props,所以它能穿过中间层直达消费者,中间层是否重渲染与它无关。
(默认值该给什么、value 引用怎么稳住、什么时候该换成真正的状态库,这些工程问题见 07 章。)
import { createContext, useContext } from "react";
// 括号里是兜底值:只在向上找不到任何 Provider 时才生效
const LevelContext = createContext(0);
function Heading({ children }) {
const level = useContext(LevelContext); // 取树上最近的提供者
const Tag = "h" + level;
return <Tag>{children}</Tag>;
}
function Section({ children }) {
// 注意:这里读到的是外层的值,不是下一行自己提供的那个
const level = useContext(LevelContext);
// React 19:context 对象本身就能当 Provider 用
return <LevelContext value={level + 1}>{children}</LevelContext>;
}
// 同一个 Heading,靠嵌套深度渲染成 h1 / h2 / h3
function Page() {
return (
<Section><Heading>一级标题</Heading>
<Section><Heading>二级标题</Heading>
<Section><Heading>三级标题</Heading></Section>
</Section>
</Section>
);
}Section 里先 useContext 再 <LevelContext value={level + 1}>,三层嵌套打印出的是 0、1、2——每层读到的都是上一层的值。因为 useContext 向上查找的起点是当前组件的父级,自己刚创建的那个 Provider 在树上位于自己下方。想在同一层用到新值,得自己算,别指望读回来。import、元素在哪一行写出来通通无关——把元素写在外层、当 children 传进内层 Provider,它读到的仍是内层的值,因为它最终是在内层被渲染的。当一次交互要同时改好几个 state、而且它们之间有约束时,useState 会让「更新逻辑」散落在十几个事件处理函数里。useReducer 把这些逻辑全部收进一个纯函数——组件只负责说「发生了什么」(dispatch 一个 action),怎么变由 reducer 独家决定。
比 useState 强在哪
- 逻辑集中:想知道
items有几种变化方式,看 reducer 的switch就够了,不用翻遍组件; - 可测试:reducer 是不依赖 React 的纯函数,
expect(reducer(旧态, action)).toEqual(新态)直接跑,不用渲染任何东西(见 12 章); - 非法状态更难表达:把「加载中 / 数据 / 错误」三态塞进一个 reducer,就能保证不会同时出现
isLoading: true和error; dispatch的引用跨渲染恒定(为true)。这意味着它可以放心地传给memo子组件、放进依赖数组,完全不需要useCallback——这是它一个被低估的实际好处。
三个进阶用法
| 用法 | 写法 | 解决什么 |
|---|---|---|
| 惰性初始化 | useReducer(reducer, 种子, init) | 初始状态需要计算时,init 只在挂载时调一次;直接传第二参数则每次渲染都会求值 |
| 复用 init 做重置 | case "reset": return init(action.payload) | 初始化和重置共用一份逻辑,不会两处走样 |
| 配合 Context 下发 | 把 state 和 dispatch 分成两个 Context | 只用 dispatch 的组件不会因 state 变化而重渲染(见 07 章) |
用了惰性初始化后挂载只渲染 1 次;一次点击里连 dispatch 两次,自动批处理让它只重渲染 1 次,两个 action 都生效。
reducer 必须是纯函数
「纯」在这里有两层硬要求:不做任何副作用(不发请求、不写 localStorage、不 console.log 之外的事——开发环境 StrictMode 会故意调用它两次来暴露这一点),以及永远返回新对象而不是修改旧的。第二条尤其致命,见下面的 pitfall。
import { useReducer } from "react";
// init 复用于「首次初始化」和「重置」,两处逻辑永不走样
function init(seed) {
return { items: seed ? [seed] : [], dirty: false };
}
function reducer(state, action) {
switch (action.type) {
case "add":
// 必须返回新对象:改 state 再 return state 屏幕不会更新
return { ...state, items: [...state.items, action.item], dirty: true };
case "remove":
return { ...state, items: state.items.filter(i => i !== action.item), dirty: true };
case "reset":
return init(action.payload);
default:
// 写错 type 时当场报错,比静默返回 state 好查得多
throw new Error("未知 action:" + action.type);
}
}
function Cart({ seed }) {
// 第三参数:init 只在挂载时调一次,不是每次渲染都算
const [state, dispatch] = useReducer(reducer, seed, init);
// dispatch 引用跨渲染恒定,传给 memo 子组件不用包 useCallback
return <ItemList items={state.items} dispatch={dispatch} />;
}function bad(s) { s.n++; return s; }:点按钮后内部 n 确实变成了 1,但屏幕上始终显示 0,也没有任何报错。因为 React 用 Object.is 比较新旧 state,返回同一个引用就等于「没变」,整次更新被跳过。永远返回新对象({ ...state }),或者用 Immer 这类库。setXxx,或者两个 state 必须「同时改才对」时,就该上 useReducer 了。另外 default 分支请 throw 而不是 return state——action type 打错字时静默无事发生,是这类代码最难查的一种 bug。并发渲染的核心能力是:让 React 知道哪些更新可以被打断。标记为「非紧急」的渲染一旦被新的用户输入打断,就会被丢掉重来,于是输入框永远跟手,而昂贵的列表慢半拍——这是用延迟换响应,不是让渲染变快。
先纠正一个常见错误
这两个 API 是 React 18 引入的,不是 React 19 新增。React 19 只是给它们加了增量能力:startTransition 开始支持传 async 函数(Actions 的基础),useDeferredValue 新增了第二个参数 initialValue。19 真正新增的是 use、useOptimistic、useActionState 三个(外加 19.2 的 useEffectEvent)。把 useTransition 归到「React 19 新特性」是网上大量文章的通病。
两者的分工:你握着谁
useTransition | useDeferredValue | |
|---|---|---|
| 你包裹的是 | 你自己发起的那次更新(有 setter) | 一个你拿到的值(可能来自 props,够不到 setter) |
| 给你什么 | [isPending, startTransition] | 一个落后一拍的副本 |
| 典型场景 | 切换标签页、提交表单、路由跳转 | 搜索框驱动一个昂贵列表 |
轨迹很直白。useDeferredValue:一次输入产生两次渲染——先 text="a" deferred=""(输入框立刻跟手,列表还是旧的),再 text="a" deferred="a"(列表在后台补上)。startTransition(async () => ...):点击后立刻渲染出 pending=true,异步完成后才落地新值。
两个前提,不满足就白写
- 下游必须
memo(或本身就是纯计算)。useDeferredValue只保证「延迟值晚一拍变」,如果昂贵组件不是memo的,父组件重渲染照样把它一起带上,延迟毫无意义; - 它治的是渲染卡顿,不是网络慢。列表慢是因为要渲染一万个 DOM 节点,用它有效;慢是因为接口要三秒,用它没有任何帮助——那是 Suspense 和取数库的活;
useDeferredValue(value, initialValue):首帧渲染轨迹是["占位值", "真实值"]——首帧先给占位,真实值随后补上。适合首屏想先出骨架的场景。
import { useState, useDeferredValue, useTransition, memo } from "react";
// 必须 memo,否则父组件一重渲染就把它带上,延迟等于没做
const Results = memo(function Results({ query }) {
const rows = hugeSearch(query); // 假设渲染上万行,很慢
return <ul>{rows.map(r => <li key={r.id}>{r.name}</li>)}</ul>;
});
function Search() {
const [text, setText] = useState("");
const deferred = useDeferredValue(text);
const stale = deferred !== text; // 据此把旧结果变灰,别让用户以为卡死
return (
<>
<input value={text} onChange={e => setText(e.target.value)} />
<div style={{ opacity: stale ? 0.5 : 1 }}>
<Results query={deferred} />
</div>
</>
);
}
function Tabs() {
const [isPending, startTransition] = useTransition();
const [tab, setTab] = useState("home");
// React 19 起 startTransition 可以传 async 函数
const go = (n) => startTransition(async () => { await preload(n); setTab(n); });
return <button disabled={isPending} onClick={() => go("posts")}>{tab}</button>;
}value 放进 transition。startTransition(() => setText(e.target.value)) 会让输入本身变成可打断的低优先级更新,用户打字时字符会延迟出现甚至看起来「吞字」。正确做法永远是输入框用普通 state 立即更新,把延迟施加在下游——这正是 useDeferredValue 存在的理由。setState 的)→ useTransition,顺便白拿一个 isPending 做加载态;够不着(值是 props 传下来的,或者来自路由、外部 store)→ useDeferredValue。用 deferred !== value 判断「正在追赶」,把旧内容调低透明度,比转圈更不打断阅读。当数据源不在 React 里(浏览器 API、全局事件、第三方 store),你需要一座桥。手写 useEffect + useState 订阅在同步渲染时代尚可接受,但并发渲染下会撕裂(tearing)——同一次渲染中,先渲染的组件读到旧值、后渲染的读到新值,屏幕上出现自相矛盾的一帧。useSyncExternalStore 就是官方为此提供的唯一正解。
三个参数
| 参数 | 它是什么 | 要求 |
|---|---|---|
subscribe | 接收一个回调,注册监听,返回取消订阅的函数 | 引用要稳定(定义在组件外,或用 useCallback),否则每次渲染都退订重订 |
getSnapshot | 同步读出当前值 | 值没变就必须返回同一个引用——这是唯一的硬性要求 |
getServerSnapshot | 服务端渲染和注水时用的值 | SSR 时不传会抛错(不是告警):有 Suspense 边界则该边界降级为客户端渲染,没有则整次渲染失败。返回值要能让服务端和客户端首帧一致 |
快照选一个原始值(store.getState().count)时,改 store 里无关的 name 字段不会触发重渲染——渲染轨迹是 [0, 1],干净利落。这就是所谓「选择器订阅」,Zustand 的 useStore(s => s.count) 底层就是这套。
日常你会在两个地方遇到它
- 封装浏览器状态:在线/离线、媒体查询、滚动位置、
localStorage——这些都是典型的「React 之外的数据源」,包成自定义 Hook 一劳永逸(见 08 章); - 读状态库源码:Zustand、Jotai、Redux 接入 React 的入口就是这个 Hook。看懂它,你就看懂了这些库为什么能做到「只有订阅了这个字段的组件才重渲染」;
- 反过来说,你自己极少需要直接写它。数据本来就在 React 里,用 state;数据来自服务器,用取数库。真要用它时,多半是在写一个 Hook 而不是写业务组件。
import { useSyncExternalStore } from "react";
// subscribe 定义在组件外:引用天然稳定,不会每帧退订重订
function subscribe(onChange) {
window.addEventListener("online", onChange);
window.addEventListener("offline", onChange);
return () => {
window.removeEventListener("online", onChange);
window.removeEventListener("offline", onChange);
};
}
export function useOnlineStatus() {
return useSyncExternalStore(
subscribe,
() => navigator.onLine, // 返回布尔值:没变就是同一个引用,安全
() => true // 服务端没有 navigator,假定在线
);
}
// ❌ 快照返回新对象:React 每次比较都判定「变了」,无限重渲染
// () => ({ online: navigator.onLine })
function Banner() {
const online = useOnlineStatus();
return online ? null : <p>网络已断开</p>;
}getSnapshot 每次返回新对象会当场把页面打死。写成 () => ({ ...store.getState() }),React 先打出 The result of getSnapshot should be cached to avoid an infinite loop,接着抛出 Error: Maximum update depth exceeded.。新对象永远不等于旧对象,React 便判定「store 又变了」。getSnapshot 要么返回原始值(数字、字符串、布尔),要么返回 store 里已经缓存好的那个对象引用。想派生出一个新形状(比如 { x, y }),就在 store 内部算好存起来再返回,或者拆成两次 useSyncExternalStore 各取一个原始值——后者往往更简单,还顺带获得了更细的重渲染粒度。最后这批 Hook 用得不多,但每个都对应一类躲不开的场景。共同点是:它们都在解决「React 的声明式模型覆盖不到的那一小块」——服务端与客户端要对上号、父组件必须命令子组件干活、渲染过程中要等一个异步值。
速查表
| API | 干什么 | 关键点 |
|---|---|---|
useId() | 生成服务端和客户端一致的唯一 id | 19.2.8 生成的形如 _r_0_、_r_1_——不是 React 18 的 :r0: 格式,老文章里的冒号写法已过时。专为 htmlFor 与 aria-describedby 这类关联而生,不要用它当列表的 key |
useImperativeHandle(ref, fn, deps) | 规定父组件通过 ref 拿到什么 | 把整个 DOM 节点换成一组你挑好的方法(focus、scrollTo、clear),封住其余能力 |
use(资源) | 在渲染中读 Promise 或 Context | React 19 新增。读 Promise 时配合 <Suspense>;可以写在 if 里(条件调用取值正确、无告警) |
useDebugValue(值) | 给自定义 Hook 在 DevTools 里加标签 | 只影响调试面板,生产环境无效 |
useInsertionEffect(fn) | 比 useLayoutEffect 更早,用于动态插入 <style> | CSS-in-JS 库作者专用,业务代码基本不该出现 |
use() 并没有「不受 Hooks 规则约束」
这是流传很广的一句错话。use 只放宽了「必须在顶层调用」这一条,其余规则原封不动:仍然只能在组件或 Hook 内部调用,仍然不能写在 try/catch 里。把它包进 try/catch,React 打出 `use` was called from inside a try/catch block. This is not allowed and can lead to unexpected behavior.,而且 catch 会抓到一个假异常 Error: Suspense Exception: This is not a real error!——因为 Suspense 的挂起机制本来就是靠抛出实现的,你的 catch 把它截胡了。错误处理请用 Error Boundary(见 11 章)。
命令式句柄:能不用就不用
useImperativeHandle 是给「声明式表达不出来」的动作留的后门:让某个输入框获得焦点、让列表滚到某一项、播放一段视频。它不该用来做「让子组件刷新数据」「让子组件改状态」——那些用 props 和状态提升表达更清楚,用 ref 只会把数据流搅浑。暴露 { focus, clear } 后父组件确实只能看到这两个方法,真实 DOM 节点被完全封住——这正是它的价值:收窄而不是扩大能力。React 19 里它直接配合 ref prop 使用,不再需要 forwardRef 包一层。
import { useId, useRef, useImperativeHandle, use, Suspense } from "react";
function Field({ label }) {
const id = useId(); // 形如 _r_0_,服务端与客户端一致
// 一个组件里需要多个 id 时,用一个 useId 加后缀,别调多次
return (
<p>
<label htmlFor={id}>{label}</label>
<input id={id} aria-describedby={id + "-hint"} />
</p>
);
}
// React 19:ref 是普通 prop,不用 forwardRef 包
function FancyInput({ ref }) {
const inner = useRef(null);
// 只把这两个方法交出去,真实 DOM 节点封在里面
useImperativeHandle(ref, () => ({
focus: () => inner.current.focus(),
clear: () => { inner.current.value = ""; },
}), []);
return <input ref={inner} />;
}
// use():Promise 必须在渲染外创建好并缓存,否则永远挂起
function Comments({ commentsPromise }) {
const list = use(commentsPromise); // 不要包 try/catch
return list.map(c => <p key={c.id}>{c.text}</p>);
}use(promise) 里的 Promise 必须被缓存,否则页面永远转圈。在渲染中写 use(new Promise(...)):组件被反复调用,DOM 上始终停在 加载中,而且没有任何报错——因为每次重新渲染都新建了一个还没 resolve 的 Promise,React 永远等不到头。Promise 必须来自 props、服务端组件传下来,或者外部缓存。useId 的用法只有一条正路:一个组件调一次,需要多个 id 时加后缀({id}-first、{id}-hint),同一个 useId 在整个组件内返回同一个值。它不能当列表的 key——key 要能标识「哪条数据」,而 useId 标识的是「树上哪个位置」,用它当 key 就等于 key={index}(后果见 03 章)。Context 与状态管理
Context 解决的是跨层级读值,不是状态管理。看清它的重渲染代价,再回答「服务器状态与客户端状态该分别交给谁」。
Context 不是状态管理方案,它只干一件事:让组件树深处的组件直接读到上层提供的值,省掉一层层往下传 props 的苦差。状态本身仍然住在某个组件的 useState 里,Context 只负责运输。
它治的是逐层透传(prop drilling)
假设主题色存在最外层的 App,而真正要用它的是七层之下的一个按钮。没有 Context,中间那六层组件每一层都得声明一个自己根本不关心的 theme prop,只为了往下递一手。
- 中间层被污染:组件签名里塞满与自己无关的参数,改一个值要动七个文件。
- 复用被绑死:这些中间组件从此离不开这条透传链,搬到别处就编译不过。
- 重构成本高:新增一个「当前语言」,整条链再来一遍。
Context 让提供方和消费方直接握手,中间层完全不必知情。这也是它常被叫作「依赖注入」的原因。
三件套与 React 19 的新写法
createContext(默认值) 造一个通道,在上层用它包住子树来提供值,下层用 useContext 来消费。React 19 起可以直接把 Context 对象本身当组件写:
<Ctx value={v}>——新写法,取值正常且无任何告警。<Ctx.Provider value={v}>——老写法,在 19.2.8 里仍然可用、也未废弃,老代码不必急着改。
消费端只有 useContext(Ctx) 一种姿势。它会沿着组件树向上找最近的一个提供者,找不到才用 createContext 的默认值。
标准做法:包成自定义 Hook
不要把裸的 Context 对象导出去让各处自己 useContext。导出一个 useTheme(),在里面做两件事:调用 useContext,以及校验 Provider 是否存在。
为什么必须校验?因为忘记包 Provider 时 useContext 不会报错,它会安静地把默认值给你——无 Provider 时拿到的就是 null,然后在你解构它的下一行才炸成一句和 Context 毫无关系的 Cannot destructure property。自己抛一个说人话的错,能省下半小时排查。
import { createContext, useContext, useState } from "react";
// 默认值只在「找不到任何 Provider」时生效,这里故意给 null 好做校验
const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState("light");
const toggle = () => setTheme(t => t === "light" ? "dark" : "light");
// React 19:Context 对象本身就能当 Provider 用,无告警
return <ThemeContext value={{ theme, toggle }}>{children}</ThemeContext>;
}
// 导出 Hook 而不是 Context 本身:调用方无从绕过校验
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme 必须用在 ThemeProvider 内部");
return ctx;
}
function ThemeButton() {
const { theme, toggle } = useTheme(); // 中间隔几层都无所谓
return <button onClick={toggle}>当前:{theme}</button>;
}useContext 会安静地返回 createContext 的默认值。无 Provider 时拿到 null,报错要等到下一行解构才炸,且错误信息里根本不提 Context。所以默认值给 null 并在自定义 Hook 里主动抛错。很多人以为在中间套一层 memo 就能把 Context 的重渲染挡在外面——挡不住。memo 比较的是 props,而 Context 的值是从 fiber 树上另开一条线直接送到消费者手里的,根本不经过 props,自然也就不受 memo 管辖。
memo 组件不重渲染,它内部的消费者照样重渲染
结构是 Provider > memo(Middle) > Consumer1 + Consumer2,Middle 不接收任何 props。改变 Provider 的 value 后打日志,输出是:
Consumer1 Consumer2——两个消费者都跑了,而Middle的函数体一次都没跑。
这正说明 memo 生效了(Middle 被跳过),但 React 会穿过被跳过的子树,继续向下找到所有订阅了这个 Context 的消费者,逐个标记为需要更新。memo 能省的只是中间层的渲染开销,救不了消费者。
更常见的坑:value 每次渲染都是新对象
value={{ theme, toggle }} 这一行,每次 Provider 所在组件重渲染都会新建一个对象字面量。React 判断 Context 是否变化用的是 Object.is,新对象和旧对象永远不相等,于是全体消费者无条件重渲染——哪怕 theme 的值一个字都没变。
对照:Provider 组件里点一次无关的计数器按钮,value 是裸对象字面量时消费者Leaf 跟着渲染了;把 value 换成 useMemo(() => ({ theme }), [theme]) 后,同样的点击下消费者一次都没渲染。
正确姿势
- value 一律用
useMemo包住。这是极少数「不加判断地上 useMemo 也不算滥用」的场合——因为它省下的不是一次计算,而是整片消费者子树的渲染。 - 依赖数组里放真正的数据(
theme),不要放函数。函数请用useCallback单独稳住,或者直接用setState的函数式更新写成不依赖任何变量的形式。 - 如果 value 就是一个原始值(数字、字符串),不需要
useMemo——原始值按值比较,没有引用问题。
import { createContext, useContext, useState, useMemo, memo } from "react";
const Ctx = createContext(null);
function App() {
const [n, setN] = useState(0); // 和 Context 无关的状态
const [theme, setTheme] = useState("light");
// 错:每次渲染新对象 → 改 n 也会让全体消费者重渲染
// const value = { theme, setTheme };
// 对:theme 不变则引用不变,改 n 时消费者零渲染
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return (
<Ctx value={value}>
<button onClick={() => setN(x => x + 1)}>{n}</button>
<Middle />
</Ctx>
);
}
// memo 让 Middle 自己不重渲染,但拦不住下面的 Leaf
const Middle = memo(function Middle() { return <Leaf />; });
function Leaf() {
const { theme } = useContext(Ctx); // 订阅关系直连,绕过 props
return <span>{theme}</span>;
}memo 把 Context 的重渲染圈起来。把消费者塞进 memo 组件内部后,日志仍是 Consumer1 Consumer2——memo 组件体本身没跑,两个消费者却都跑了。想减少影响面只有两条路:稳住 value 的引用,或者把 Context 拆细。<Ctx value= 后面直接看到 {{ 或 {[,就是一个待修的性能 bug。value 是对象或数组就必须 useMemo,是原始值则不必。开了 React Compiler 后这层可以交给编译器(见 09 章),但手写时请当成硬规矩。判断一个值该不该放 Context,只看一个指标:它多久变一次。Context 的广播是全有全无的——value 一变,整棵子树里所有消费者一起重渲染,没有「只订阅其中一个字段」这种选项。
适合与不适合
| 值 | 变化频率 | 放 Context |
|---|---|---|
| 主题、语言、地区 | 用户手动切,一天几次 | 非常合适 |
| 当前登录用户 | 登录/登出时变 | 非常合适 |
| 路由信息、依赖注入(API 客户端、日志器) | 几乎不变 | 非常合适 |
| 购物车、多步表单的汇总态 | 中频,消费点少 | 可以,配合拆分 |
| 输入框的每一次按键 | 每帧 | 绝对不要 |
| 鼠标位置、滚动偏移、动画进度 | 每帧 | 绝对不要 |
| 远端接口数据的本地副本 | 不定 | 不要,交给数据层(见本章选型卡) |
第一刀:把「值」和「改值的方法」拆成两个 Context
这是收益最大、成本最低的一次拆分。观察一下:只读值的组件关心 value,而只负责触发修改的组件(按钮、表单)根本不关心当前值是多少,它只要那个 dispatch/setter。把两者塞进同一个对象,等于逼着按钮跟着值一起重渲染。
拆开后,派发方法的那个 Context 的 value 引用永远稳定(用 useState 惰性初始化或 useCallback 固定住),于是只订阅它的组件永远不会因为值变化而重渲染。拆分后点击按钮,只有 Display 重渲染,被 memo 包住的按钮组件一次都没跑。
useReducer 天然适合这个模式——它给你的 dispatch 本身就是 React 保证引用稳定的,连 useCallback 都省了。
第二刀:按变化频率切
- 别造一个包罗万象的
AppContext。它必然是应用里变化最频繁的那个值的频率,全体消费者陪绑。 - 按更新节奏分组:
ThemeContext(极低频)、AuthContext(低频)、CartContext(中频)各自独立。 - 拆分是有成本的:Provider 嵌套变深、模块变多。拆到「同一个 Context 里的值总是一起变」就够了,别再细分。
如果拆完还是卡,说明这个状态的形态已经超出 Context 的能力范围了——它需要的是按字段订阅,那是外部 store 的活儿。
import { createContext, useContext, useReducer, memo } from "react";
// 拆成两个:一个装值,一个装派发方法
const CartValue = createContext(null);
const CartDispatch = createContext(null);
export function CartProvider({ children }) {
const [cart, dispatch] = useReducer(cartReducer, { items: [] });
// dispatch 的引用由 React 保证稳定,不必 useMemo/useCallback
return (
<CartDispatch value={dispatch}>
<CartValue value={cart}>{children}</CartValue>
</CartDispatch>
);
}
function CartBadge() {
const cart = useContext(CartValue); // 值变才重渲染
return <span>{cart.items.length}</span>;
}
// 只订阅 dispatch:购物车怎么变,这个按钮都不重渲染(零渲染)
const AddButton = memo(function AddButton({ item }) {
const dispatch = useContext(CartDispatch);
return <button onClick={() => dispatch({ type: "ADD", item })}>加入</button>;
});AppContext,把用户、主题、购物车、弹窗开关全塞进一个对象。结果是任何一个字段变化都会让全应用的消费者重渲染,而且这个 value 必然是个对象字面量,连 useMemo 都很难稳住(依赖数组里挂了七八项)。useReducer 后 dispatch 引用天然稳定,派发侧的 Provider 连 useMemo 都不用写,纯赚。这一刀通常就解决了 Context 八成的性能问题。选型的第一刀不是切库,而是切状态的种类。远端数据在本地的副本,和纯前端的界面状态,根本不是一类东西——把它们塞进同一个 store 是绝大多数状态管理灾难的起点。
先划这条线:服务器状态 vs 客户端状态
服务器状态(server state)是别人家的数据在你这儿的缓存副本:用户资料、商品列表、订单详情。它的真身在数据库里,你手上这份天生就是过期的。它带来的问题全是缓存问题——何时失效、并发请求怎么去重、切回标签页要不要重拉、失败怎么重试。这些不是 useState 能解决的,也不该由你手写,交给 TanStack Query 或 SWR(见 05 章讲的「别用 useEffect 取数据」)。
客户端状态(client state)是没有远端真身的东西:弹窗开不开、侧边栏折不折叠、表单草稿、当前选中的标签页。它就住在浏览器里,不存在过期一说,这类才轮到 useState 和状态库出场。
划完这条线你会发现,大部分应用里客户端状态少得可怜——多到需要上 Redux 的,往往是把服务器状态错当客户端状态在管。
客户端状态的横向对比
| 方案 | 订阅粒度 | 样板量 | 要 Provider | 什么时候选它 |
|---|---|---|---|---|
useState + 提升 | —— | 零 | 否 | 状态只被一两个相邻组件用。默认选它 |
useState/useReducer + Context | 整个 Context | 很低 | 是 | 低频、跨层级。主题、当前用户、i18n |
| Zustand | 选择器级 | 很低 | 否 | 中高频、消费点多。需要外部库时的默认答案 |
| Jotai/Valtio | 原子/属性级 | 低 | Jotai 建议要 | 状态天然碎片化、组合关系复杂 |
| Redux Toolkit | 选择器级 | 中 | 是 | 大团队要强规范、复杂中间件、时间旅行调试 |
Zustand 靠选择器做到按字段订阅——这正是 Context 做不到的那件事。一个含 items 和 coupon 的 store:只改 coupon 时只有订阅 coupon 的组件重渲染,反之亦然。同样结构换成 Context,两个组件每次都得一起跑。
该怎么选
不存在「各有适用场景」这种和稀泥的答案,路径是明确的。
- 先把服务器状态拿走,再看剩下什么优剩下的通常少到
useState加一两个 Context 就够,八成应用到此为止短多一个数据层依赖,要学它的缓存键与失效模型为何缓存语义是独立的一整套问题,装进通用 store 只会让你手写一遍,且写得更差 - 跨层共享但低频 → Context优零依赖、React 原生、类型推导天然短无订阅粒度,value 一变全体消费者重渲染为何它是广播通道而非 store——没有选择器这一层,所以既免去样板,也失去粒度
- 高频或消费点很多 → Zustand优选择器按需订阅,无 Provider,可在组件外读写短缺强制规范,团队大了易长成一堆随意的 store为何它刻意不做约束——自由带来的轻量,和自由带来的失控是同一件事
- 大型团队、强规范 → Redux Toolkit优分层是行业共识,时间旅行调试与中间件生态无可替代短即便 RTK 已大幅简化,样板仍是 Zustand 的数倍为何它用统一、可被工具理解的结构换取自由度——正因写法被限死,DevTools 才看得懂每一次变更
// 服务器状态:交给数据层,别自己 useState 存
const { data: user } = useQuery({
queryKey: ["user", id],
queryFn: () => fetchUser(id), // 去重、重试、失效都归它管
});
// 客户端状态:Zustand,一个 store hook,无需 Provider
import { create } from "zustand";
const useCart = create((set) => ({
items: [],
coupon: null,
add: (it) => set((s) => ({ items: [...s.items, it] })),
setCoupon: (c) => set({ coupon: c }),
}));
function CartCount() {
// 选择器=订阅粒度:改 coupon 时本组件零渲染
const n = useCart((s) => s.items.length);
return <span>{n}</span>;
}
function CouponTag() {
const c = useCart((s) => s.coupon); // 改 items 时本组件零渲染
return <span>{c ?? "无优惠券"}</span>;
}useEffect 拉下来塞进全局 store,是最费力不讨好的做法:你会亲手重写一遍缓存失效、请求去重、竞态取消和重试,而且每一条都写不过现成的库。全局 store 里出现 loading/error 字段,就是走错路的信号。useState/Zustand。这一问能挡掉大半的选型纠结。状态管理库不是「更好的 useState」。它替你做成的核心只有一件事,而这件事恰恰是 Context 结构上给不了的:让每个组件只订阅自己真正用到的那一小片状态。其余的中间件、持久化、DevTools,都是围着这一件事长出来的配套。
先看手搓版会卡在哪
不用库也能做全局 store:useReducer 攒一份状态,用 Context 广播下去,几十行搞定。问题在下一步——消费端只能整份拿走。
一个装着 user 和 unread 的 Context store,两个消费者各自只读一个字段,还都用 memo 包好了。只改 user,日志是:
[ctx] User 渲染[ctx] Badge 渲染
Badge 一个字节的相关数据都没变,照样跑了一遍。想修好它,你得给自己的 store 加一张监听表、在每次变更时对每个订阅者跑一遍选择器、比较新旧结果、决定要不要触发更新,还得让这套东西在并发渲染下不出错。写到这一步,你已经在重造一个状态管理库了——而且大概率造得更差。
选择器就是订阅声明
库的做法是把「读哪一片」从消费动作里显式提出来。useApp((s) => s.unread) 这一行既是取值,也是在说「只有这一片变了才叫醒我」。同一组结构换成 zustand 的对照:
| 操作 | Context 手搓版 | zustand 选择器版 |
|---|---|---|
只改 user | User、Badge 都重渲染 | 只有 User 重渲染 |
只改 unread | User、Badge 都重渲染 | 只有 Badge 重渲染 |
| 组件外读写状态 | 做不到,值锁在树里 | useApp.getState() 直接读到 1 |
| 要不要包 Provider | 必须包,且必须在消费者上方 | 不需要,store 就是个模块 |
「不需要 Provider」这条比看起来重要:状态不再挂在树上,请求拦截器、路由守卫、埋点回调这些不是组件的地方也能读写它。Context 做不到这件事,因为它的值本来就是靠树的位置传递的。
选择器之外的配套
- 持久化:一行中间件接管本地存储。给 store 包上
persist(..., { name: "app" }),改完状态后localStorage里躺着{"state":{"user":"张三","unread":5},"version":0},刷新自动回填,不用自己写读写与序列化。 - DevTools:
devtools中间件把每一次变更连同 action 名送进浏览器扩展,可以逐条回看、时间旅行。自己手搓的 Context store 在 DevTools 里只是一团 state,看不出「谁在什么时候改了它」。 - 写法糖:
immer中间件让你在set里直接写state.items.push(x),由它负责产出新对象,深层嵌套状态的展开运算符地狱就此消失。 - 测试与 SSR:store 是个普通模块,测试里可以直接重置或注入初值,不必渲染任何组件。
至于它们是怎么接进 React 的——入口统一是 useSyncExternalStore(机制见 06 章),没有任何旁门左道。所以这些库天生兼容并发渲染,这也是「自己手搓一个订阅机制」最难补齐的那一块。
import { createContext, useContext, useState, useMemo } from "react";
import { create } from "zustand";
import { persist } from "zustand/middleware";
// 手搓版:整个 store 塞进一个 Context,消费端只能整份拿走
const StoreCtx = createContext(null);
function StoreProvider({ children }) {
const [state, setState] = useState({ user: "游客", unread: 0 });
const value = useMemo(() => [state, setState], [state]);
return <StoreCtx value={value}>{children}</StoreCtx>;
}
function CtxBadge() {
const [s] = useContext(StoreCtx); // 只用 unread,改 user 也照样重渲染
return <span>{s.unread}</span>;
}
// 库版:选择器就是订阅声明,顺带白拿一个持久化中间件
const useApp = create(persist((set) => ({
user: "游客",
unread: 0,
markRead: () => set({ unread: 0 }),
}), { name: "app" }));
function Badge() {
const unread = useApp((s) => s.unread); // 改 user 时零渲染
return <span>{unread}</span>;
}
// 组件外照样读写:不必是组件,也不必被任何 Provider 包住
useApp.getState().markRead();const s = useApp() 这种一次取全量的写法,只改 user 时那个只显示 unread 的组件照样重渲染,粒度完全作废,退回到 Context 的全量广播。解构写法 const { unread } = useApp() 同样如此——它也是先把整个 store 订阅下来再解构的。自定义 Hook
自定义 Hook 共享的是有状态的逻辑,不是状态本身。本章讲怎么抽、返回值怎么设计,以及 Hooks 规则背后的实现原理。
自定义 Hook 共享的是有状态的逻辑,不是状态本身。两个组件调用同一个 useCounter,各自拿到完全独立的一份状态——这是初学者最容易搞反的一点,也是它和 Context、全局 store 的分水岭。
同一个 Hook,两份互不相干的状态
两个 Box 组件都调用同一个 useCounter(),渲染出 甲:0乙:0。在甲上点两下,界面变成 甲:2乙:0——乙纹丝不动。
原因很朴素:自定义 Hook 只是一个普通函数,没有任何魔法。Box 渲染时调用它,它内部的 useState 在当前这个组件实例的 hook 链表上占一个槽位。两个 Box 是两个 fiber 实例,两条独立的链表,槽位自然不共享。
所以自定义 Hook 抽出去的是「怎么算、怎么订阅、怎么清理」这套行为,而每个使用者都会得到属于自己的一份数据。要真正共享同一份数据,得靠 Context(见 07 章)或外部 store。
它和普通工具函数的区别
- 普通函数只是计算,输入进去输出出来,和渲染无关。
- 自定义 Hook 内部调用了其他 Hook——它能持有状态、能注册副作用、能订阅外部数据,因而与调用它的组件的生命周期绑定。
判据很简单:函数体内出现了 useState/useEffect/useContext/useRef 中任何一个,它就是 Hook,就必须遵守 Hooks 规则、就必须以 use 开头。反之,一个纯粹的 formatPrice(n) 永远不该叫 useFormatPrice。
为什么名字必须以 use 开头
这不是「大家约定俗成图个好看」,而是工具链唯一的识别依据。JavaScript 里没法在运行时判断一个函数是不是 Hook,所以 React 生态一致选择了命名约定作为契约:
eslint-plugin-react-hooks靠use前缀决定「要不要检查这个函数里的 Hooks 规则」。名字不以use开头,你在里面违规调用 Hook,ESLint 一声不吭。- React Compiler(见 09 章)同样靠它来判断函数的语义,从而决定能否自动记忆化。
- React DevTools 靠它在面板上把自定义 Hook 单独列出来。
反过来也成立:不含 Hook 调用的函数不要起 use 开头的名字,否则 ESLint 会按 Hook 的规矩来管它,你在条件分支里调用它就会被误报。
import { useState } from "react";
// 它就是个普通函数,只不过内部用了 Hook
function useCounter(init = 0) {
const [n, setN] = useState(init);
const inc = () => setN(x => x + 1);
return [n, inc];
}
function Box({ label }) {
// 每个 Box 实例在自己的 hook 链表上开一个槽,状态互不相干
const [n, inc] = useCounter();
return <button onClick={inc}>{label}:{n}</button>;
}
function App() {
return (
<>
<Box label="甲" />
<Box label="乙" />
</>
);
}
// 初始「甲:0乙:0」,在甲上点两下 → 「甲:2乙:0」
// 共享的是逻辑,不是状态。要共享数据请用 Context 或外部 storeuseCounter,点其中一个,另一个纹丝不动。发现「怎么改了这边那边没跟着变」时,说明你要的是 Context 或全局 store,抽 Hook 解决不了。use 开头,没调就绝对不要用 use 开头。前缀是给 ESLint 和 React Compiler 看的契约,不是修辞——名字取错,静态检查会整段失效或整段误报。抽取的信号只有一个:组件里有一段代码和这个组件长什么样毫无关系。它只是在管理某种状态、订阅某个数据源、或者协调某段异步流程——把这段搬走,组件就只剩下描述 UI 的部分了。
三个信号
- 成团出现的 state + effect。几个
useState和一个useEffect总是绑在一起改,它们其实是一个概念,只是没有名字。 - 同一段逻辑在第二个组件里出现了。第一次写别急着抽,第二次出现时抽——此时你才真正看清哪些是共性、哪些是差异。
- 组件读起来像流水账。函数体前四十行全是订阅、清理、防抖、解析,最后三行才是 JSX。抽走后组件恢复成「一眼看懂」的状态。
几个真实例子
| Hook | 它封装了什么 | 为什么值得抽 |
|---|---|---|
useLocalStorage | 读初值、写回、JSON 序列化 | 初始化要惰性读、每次变更要同步写,两段逻辑必须成对出现 |
useDebounce | 定时器 + 清理 | 清理函数一忘就漏定时器,封装一次全站受益 |
useMediaQuery | 订阅 matchMedia 变化 | 外部数据源,订阅/退订样板固定 |
useOnlineStatus | 订阅 online/offline 事件 | 同上,且需要服务端快照兜底(见 07 章) |
useLocalStorage 可用:初始渲染读到 初始 且写入了存储,点击后界面与 localStorage 同步变成 已改。useDebounce 把 q 从 a 改成 ab 的瞬间显示 ab|a(原值已变、防抖值还没跟上),等过延迟后变成 ab|ab。
不该抽的情况
- 只有一处使用,且逻辑就三五行。抽出去只是把代码搬到另一个文件,读者从此要跳两个文件才能看懂一件事。
- 抽出来的 Hook 需要一堆参数和开关。签名里出现
mode、enabled、variant这类分支参数,说明两个调用方的需求其实不一样,硬合并只会让两边都难改。宁可有两个各自清晰的 Hook。 - 纯计算逻辑。没有状态、没有副作用的部分抽成普通函数就好,套一层 Hook 是白白背上 Hooks 规则的约束。
- 把整个组件的所有逻辑打包成一个
useXxxPage()。这只是把流水账挪了个地方,没有产生任何可复用的抽象,还多了一层间接。
import { useState, useEffect } from "react";
function useLocalStorage(key, initial) {
// 惰性初始化:读存储只在挂载时发生一次,不是每次渲染
const [value, setValue] = useState(() => {
const raw = localStorage.getItem(key);
return raw ? JSON.parse(raw) : initial;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue]; // 与 useState 同形,调用方零学习成本
}
function useDebounce(value, delay = 300) {
const [v, setV] = useState(value);
useEffect(() => {
const t = setTimeout(() => setV(value), delay);
// 清理是关键:value 连变时前一个定时器必须撤掉
return () => clearTimeout(t);
}, [value, delay]);
return v;
}
// 组合使用:输入即时响应,请求只在停手后发出
const [query, setQuery] = useState("");
const debounced = useDebounce(query, 400);useUserPage() 这种「万能 Hook」。它复用不了任何东西(只有一个调用方),却让组件变成一个空壳,调试时要在两个文件间来回跳。自定义 Hook 的价值来自被多处调用,只有一个调用方就不该存在。自定义 Hook 的返回值就是它的公开 API。两个决定最要紧:用数组还是对象,以及返回的函数引用稳不稳定——后者会直接影响调用方能不能正确写依赖数组。
数组还是对象
数组 [a, b] | 对象 { a, b } | |
|---|---|---|
| 调用方改名 | 解构时自由命名 | 要写 { a: myA },啰嗦 |
| 只取其中几个 | 要靠位置占位 | 直接按名字取 |
| 加新返回值 | 只能往后追加 | 随便加,不影响老调用方 |
| 适合 | 恰好两项、地位对称 | 三项及以上,或有可选项 |
判断很干脆:返回两项、且调用方大概率要改名(因为会在同一组件里用两次)→ 数组,这是 useState 立下的规矩,useToggle、useCounter 都该照做。同一组件里两次调用 useToggle 得到 open/toggleOpen 与 dark/toggleDark,互不干扰。其余一律返回对象——尤其是带 loading、error、refetch 这类可选项的,用数组会逼出 const [, , refetch] = ... 这种鬼东西。
返回不稳定的函数=调用方的依赖数组失控
Hook 里直接 return () => {...},每次渲染都是新函数。调用方一旦把它写进依赖数组,后果是:
- 放进
useEffect的依赖 → 每次渲染都重跑 effect。返回裸函数时,三次渲染 effect 跑了三次(effect@0 effect@1 effect@2);改成useCallback包住后,三次渲染 effect 只跑了一次(effect@0)。 - 传给
memo子组件 → memo 完全失效,props 永远在变。 - 调用方为了绕开,往往把它从依赖数组里删掉,于是引入闭包读到旧值的问题(见 05 章)。
关键在于:调用方修不了这个问题。他拿到的就是个每次都新的函数,除非把它塞进 useRef 自己稳一遍。所以稳定引用是 Hook 作者的责任,不是使用者的。
怎么稳住
- 返回的函数一律
useCallback包住,依赖尽量为空。用setState的函数式更新(setN(x => x + 1))就能不依赖当前值,依赖数组自然空掉。 - 返回的对象用
useMemo包住,否则对象本身就是新引用,等于白稳了里面的函数。 - 要读最新值又不想进依赖数组时,用
useEffectEvent(19.2 已可用)或 ref 转存。 - 开了 React Compiler 后这层可以交给编译器(见 09 章);但只要项目里还有手写代码路径,就按手动规矩办。
import { useState, useCallback, useMemo } from "react";
// 两项且对称 → 返回数组,调用方自由命名
function useToggle(init = false) {
const [on, setOn] = useState(init);
// 函数式更新 → 不依赖 on → 依赖数组为空 → 引用永久稳定
const toggle = useCallback(() => setOn(o => !o), []);
return [on, toggle];
}
function Panel() {
const [open, toggleOpen] = useToggle(); // 同一组件里用两次
const [dark, toggleDark] = useToggle(true); // 数组才好改名
return <button onClick={toggleOpen}>{String(open)}{String(dark)}</button>;
}
// 三项以上或带可选项 → 返回对象,且对象本身也要 useMemo
function useResource(url) {
const [state, setState] = useState({ data: null, loading: true });
const refetch = useCallback(() => load(url, setState), [url]);
// 不包 useMemo 的话,外层对象每次都是新的,白稳了 refetch
return useMemo(
() => ({ ...state, refetch }),
[state, refetch]
);
}useCallback、却忘了给外层返回对象加 useMemo,等于白做——调用方拿到的对象每次渲染都是新引用,解构出来的函数虽稳,但把整个对象写进依赖数组照样每次都变。返回对象时,对象和它里面的函数要一起稳。useEffect 的依赖数组,如果 effect 会每次渲染都重跑,说明你的 API 有问题。Hooks 的两条规则——只在顶层调用、只在组件或自定义 Hook 里调用——不是风格偏好,而是实现机制的直接推论:React 靠调用顺序把每次渲染的 Hook 和它存的状态对上号,顺序一变就全线错位。
为什么是顺序
useState("A") 这行代码里,React 拿不到任何标识——没有名字、没有 key,只有一个初值。它怎么知道这次渲染的 useState 对应上次那个?答案是:按顺序数下标。
每个组件实例(fiber)上挂着一条 hook 链表。渲染开始时指针归零,之后每调用一个 Hook 就往后挪一格,从当前格子里取出上次存的状态。这个设计换来了极简的 API(不用给每个 state 起名字、不用注册),代价就是顺序必须每次渲染完全一致。写在 if 里,条件为假时那一格被跳过,后面所有 Hook 集体前移一格,从此张冠李戴。
条件调用 Hook 的报错原文
组件里第二个 useState 被 if (show) 包着,先用 show=true 渲染再改成 false,报错:
Rendered fewer hooks than expected. This may be caused by an accidental early return statement.- 反过来先
false再true,报的是Rendered more hooks than during the previous render.
同时 React 还会打出 React has detected a change in the order of Hooks called by Bad2.,后面跟着 Previous render 与 Next render 两列对照表,第 2 行是 undefined 对 useState,并用 ^^^^^ 标出错位点。注意「提前 return」也算——它同样会让后面的 Hook 少调用一轮。
要条件逻辑怎么办
- 把条件挪进 Hook 内部。
useEffect(() => { if (!enabled) return; ... }, [enabled])——Hook 永远调用,副作用里才判断。 - 把条件挪到组件边界。让父组件决定渲染
<A />还是<B />,Hook 顺序在各自内部保持一致。 - 装上
eslint-plugin-react-hooks。这两条规则可静态检出,别等运行时才发现。
唯一的例外:use()
use() 放宽了「必须顶层调用」这一条——它能写在 if 或循环里,因为它读的是 Promise 或 Context,不在链表上占槽位。但只放宽了这一条。把它包进 try/catch,React 打出 `use` was called from inside a try/catch block. This is not allowed and can lead to unexpected behavior.,且 catch 会抓到假异常 Error: Suspense Exception: This is not a real error!。所以「use 完全不受 Hooks 规则约束」是错的(见 06 章)。
import { useState, useEffect } from "react";
// 错:show 从 true 变 false 时,第 2 格 Hook 凭空消失
function Bad({ show }) {
const [a] = useState("A");
if (show) {
const [b] = useState("B"); // Rendered fewer hooks than expected.
return <i>{a}{b}</i>;
}
return <i>{a}</i>;
}
// 错:提前 return 同样会少调用后面的 Hook
function AlsoBad({ user }) {
if (!user) return null; // 这一行之后的 Hook 全被跳过
const [name] = useState(user.name);
return <i>{name}</i>;
}
// 对:Hook 永远在顶层调用,把条件搬进 Hook 内部
function Good({ user, enabled }) {
const [name, setName] = useState(user?.name ?? "");
useEffect(() => {
if (!enabled) return; // 判断放里面,调用顺序不受影响
track(name);
}, [enabled, name]);
if (!user) return null; // 提前 return 要放在所有 Hook 之后
return <i>{name}</i>;
}Rendered fewer hooks than expected. 时,别去找 if 包着的 useState——十有八九是某个提前 return(比如 if (loading) return <Spinner />)挡在了后面的 Hook 前面。React 附带的 Previous render / Next render 对照表会直接标出第几格错位。return 之前,必须已经调用完所有 Hook。想早退就把 if (!user) return null 挪到所有 Hook 下面。这条比背「不要在条件里调用」更好执行,因为提前 return 才是实际项目里最常见的出错方式。性能优化与 React Compiler
先测量再优化。memo 的边界在哪、useMemo 什么时候纯属噪音,以及 React Compiler 1.0 到底替你做了什么。
React 应用的「慢」只有两种:渲染的次数太多,和单次渲染太重。两种病的药完全不同,不先分清就到处撒 memo,等于闭着眼睛吃药。
两类问题,两种药
| 症状 | 典型现场 | 该用的药 | 用错药的样子 |
|---|---|---|---|
| 渲染次数太多 | 打一个字,半页组件的函数体都跑了一遍,可吐出来的 JSX 和上次一模一样 | 把 state 下沉到真正用它的子树、拆组件、memo | 只顾着加 useMemo 缓存计算,渲染次数一次没少 |
| 单次渲染太重 | 组件没几个,但一次要排序几万条、跑一遍 Markdown 解析、吐出几千个 DOM 节点 | useMemo 缓存计算、虚拟化、代码分割 | 到处包 memo,可那重的一次照样得跑 |
这两类问题在 Profiler 里长得很不一样:前者是很多根短条,后者是一根特别长的条。看一眼火焰图的形状就能分诊。
React DevTools Profiler 怎么读
- 装上浏览器扩展后会多出 Components 和 Profiler 两个面板。先去 Profiler 的齿轮设置里勾上「Record why each component rendered while profiling」,否则最有价值的那一栏是空的。
- 录制 → 操作一遍 → 停止。Flamegraph 按组件树展开,条越长表示它在这次提交里越贵,灰条表示这次根本没重渲染;Ranked 把同一次提交按耗时降序排平,一眼看到最贵的那个。
- 选中某个组件,右侧会写出
Why did this render?,答案通常是这几种:Props changed (data)、Hook 1 changed、Context changed、The parent component rendered。最后一种最常见,也最容易解——多半是 state 放得太高了。 - 懒得录制就去 Components 面板的设置里开「Highlight updates when components render」。每个重渲染的组件会闪一圈彩色边框,「敲一个字整页都在闪」这种病肉眼可见。
代码里也能量:Profiler 组件
Profiler 是运行时 API,把子树包起来,每次提交都会调用你的 onRender。两个耗时参数是解读的关键:actualDuration 是本次真实耗时,baseDuration 是「假设没有任何跳过、整棵子树全部重渲染」的估算耗时。
一个用 memo 包住的子组件,日志是这样的:
- 挂载时
mount 本次=5.66ms 不优化则=0.86ms——首次渲染本来就没有东西可跳过,缓存机制本身还是净开销。 - 点一次无关按钮后
update 本次=0.29ms 不优化则=0.53ms——到这一步memo才开始赚钱,赚的就是这 0.24ms 的差价。
不要预防性优化
- 重渲染不等于慢。React 重跑一个吐出十来个节点的函数是微秒级的事,跑上一百次用户也感知不到。把「重渲染次数」当成 KPI 去刷,是新手最常见的时间黑洞。
- 每一个
memo和useMemo都是永久的维护成本:多一个依赖数组要跟着代码演进、多一份内存常驻、每次渲染多一轮比较。更严重的是依赖写漏了会变成极难查的陈旧值 bug。 - 唯一站得住的判断标准是用户能不能感知:输入延迟、点击后顿一下、滚动掉帧。感知不到的重渲染不是 bug,是正常工作。
- 动手优化前先问一句「这个 state 是不是放太高了」。把 state 下沉到真正用它的那棵子树里(见 03 章),往往比任何
memo都管用,而且零维护成本。
import { Profiler, memo, useState } from "react";
// actual:本次真实耗时;base:假设不做任何跳过、全子树重渲染的耗时
// 两者的差价,才是你的 memo 真正省下来的钱
function onRender(id, phase, actual, base) {
console.log(`${id} ${phase} 本次=${actual.toFixed(2)}ms 不优化则=${base.toFixed(2)}ms`);
}
const Heavy = memo(function Heavy({ rows }) {
const sorted = [...rows].sort((a, b) => a - b);
return <li>{sorted.length}</li>;
});
function App() {
const [n, setN] = useState(0);
const [rows] = useState([3, 1, 2]); // 引用稳定,memo 才有机会命中
return (
<Profiler id="面板" onRender={onRender}>
<button onClick={() => setN(n + 1)}>{n}</button>
<ul><Heavy rows={rows} /></ul>
</Profiler>
);
}
// 日志:面板 mount 本次=5.66ms 不优化则=0.86ms ← 首渲染缓存是净亏
// 面板 update 本次=0.29ms 不优化则=0.53ms ← 这里才开始赚baseDuration 当「优化前耗时」去汇报收益。挂载阶段 actual 是 5.66ms 而 base 只有 0.86ms——首渲染本来就无可跳过,缓存机制本身还是净开销。只有 update 阶段的 actual 小于 base,才是 memo 真正省下的那部分。memo 只做一件事:重渲染前用 Object.is 把新旧 props 逐个 key 比一遍,全等就跳过。它是浅比较,所以只要你传的是当场写出来的对象、数组或箭头函数,每次渲染都是崭新的引用,memo 必然失效。
它到底改变了什么
- 默认情况下父组件一渲染,子组件必定跟着跑,哪怕 props 一个字没改。这是 React 的设计取向:不做假设,重跑一遍再比对结果。
- 包上
memo后 React 在重渲染前多做一步浅比较。全等就连同整棵子树一起跳过,子组件的函数体一次都不执行。 - 跳过的是渲染,不是卸载。
memo组件的 state、ref、effect 全都原样活着,只是这一轮没被重新计算。
三种让 memo 当场失效的写法
| 写法 | 为什么每次都不相等 | 修法 |
|---|---|---|
style={{ color: "red" }} | 对象字面量每次渲染新建一个 | 提到组件外当模块级常量,或 useMemo |
items={list.filter(fn)} | filter 每次返回新数组 | useMemo 缓存,依赖填 list 和筛选条件 |
onPick={() => pick(id)} | 箭头函数每次是新对象 | useCallback,或者干脆只传 id,让子组件自己去调 |
三个 memo 子组件并排:只传字符串的那个在父组件重渲染后一条日志都没有,传内联对象和传内联函数的那两个照旧全跑了。这就是浅比较的全部真相。
边界一:memo 挡不住 context
结构是 Provider > memo(Middle) > Consumer1 + Consumer2,Middle 不接收任何 props。改变 Provider 的 value 后日志:
- 首次渲染
Middle 跑了 · Consumer1 跑了 · Consumer2 跑了 - 切换主题后
Consumer1 跑了 · Consumer2 跑了——Middle 一次都没跑,两个消费者却都跑了。
原因在机制上:Context 的值是沿 fiber 树另开一条订阅线直送消费者的,根本不经过 props,自然不受浅比较管辖。React 会穿过被 memo 跳过的子树,继续向下把每个订阅者标记为待更新。想减小影响面只有两条路:稳住 value 的引用,或者把 Context 拆细(见 07 章)。
边界二和三
- children 是 prop。
<Memoized><Child /></Memoized>里的 JSX 元素每次渲染都是新对象,props.children必然不等,memo直接白包。反过来,这也是「把子树当 children 传进去」能省渲染的原因——父组件自己重渲染时,从上面传进来的那份 children 引用没变。 - 组件本身要够贵才值。浅比较有成本,props 多的时候比较十几个 key 未必比重跑一遍便宜。
memo该留给「渲染确实重、props 又确实经常不变」的组件,比如图表、编辑器、长列表的行。
import { memo, useState } from "react";
const Child = memo(function Child({ label }) {
console.log("Child 渲染 " + label);
return <div>{label}</div>;
});
function App() {
const [n, setN] = useState(0);
return (
<>
<button onClick={() => setN(n + 1)}>{n}</button>
<Child label="A 只传字符串" />
<Child label="B 多传内联对象" style={{ color: "red" }} />
<Child label="C 多传内联函数" onClick={() => {}} />
</>
);
}
// 点一次按钮后的日志(只有两行):
// Child 渲染 B 多传内联对象
// Child 渲染 C 多传内联函数
// A 被跳过,B 和 C 照跑——多传的那一个 prop 就把 memo 废掉了memo 却一直没生效,还以为已经优化过了」。同一个 memo 组件,只传字符串时被稳稳跳过,多传一个 style={{ color: "red" }} 就每次都重渲染。memo 失效是静默的,React 不会给你任何提示。memo 有没有白包,不用猜:在子组件第一行放一句 console.log,然后去点一个和它无关的按钮。有日志就是白包了,顺着 props 一个个找哪个是当场新建的引用。比在 Profiler 里翻半天快得多。useMemo 和 useCallback 不是免费的:每次渲染都要存一份值、逐项比一遍依赖数组,还要你终身维护那个数组。它们只在下游能省下更大一笔开销时才划算,否则纯属噪音。
先认清它们的成本
- 不省渲染次数。组件该重跑还是重跑,函数体每一行照样执行到
useMemo这一句。它省的只是那个花括号里的计算。 - 常驻内存。缓存的值跟着组件实例活着,列表里一千行就是一千份缓存。
- 依赖数组是负债。今天写对了,明天有人在计算里多引用一个变量却忘了加进依赖,就出现「界面显示的还是三分钟前的值」这种最难查的 bug。ESLint 的
react-hooks/exhaustive-deps是必开项,不是可选项。 useCallback(fn, deps)就是useMemo(() => fn, deps)的语法糖,成本结构完全一样。
三种情况是真收益
| 场景 | 省下的是什么 | 判断信号 |
|---|---|---|
这个引用要作为 memo 子组件的 prop | 整棵子树的渲染 | 子组件外面确实包了 memo,而且它渲染确实重 |
| 这个引用要进另一个 Hook 的依赖数组 | effect 的反复重跑,甚至无限循环 | useEffect 的依赖里出现了对象、数组或函数 |
| 计算本身昂贵 | 纯 CPU 时间 | 几千条数据的排序分组、正则扫全文、解析 Markdown |
注意前两条的共同点:值本身不贵,贵的是引用变化引发的连锁反应。这才是 useMemo 在 React 里最主要的用途——它更像是「稳定引用」的工具,而不是「加速计算」的工具。
什么时候纯属噪音
const total = useMemo(() => a + b, [a, b])——一次加法比一次依赖比较还便宜,纯亏。useCallback包一个只被自己 JSX 上的原生onClick用到的函数。原生 DOM 属性不做浅比较,稳不稳定它根本不在乎。- 包住一个传给没有
memo的子组件的函数。子组件反正每次都重渲染,稳定引用没有任何用武之地。
三条里第二、三条加起来,覆盖了真实项目里绝大多数的 useCallback。
判断口诀
「下游有没有人在乎这个引用变没变?」——没人在乎(原生属性、非 memo 子组件、只在本组件里用),就别包。有人在乎(memo 子组件的 prop、别人的依赖数组),就包。至于「计算本身贵」,那是第三条独立的理由,用毫秒数说话,别用感觉。
开了 React Compiler 之后这三条基本都不用你操心了(见下一卡),但在没开编译器的项目里,这个口诀就是你的全部依据。
import { memo, useState, useMemo, useCallback } from "react";
const Row = memo(function Row({ item, onPick }) {
console.log("Row 渲染 " + item.name);
return <li onClick={() => onPick(item.id)}>{item.name}</li>;
});
function App({ all }) {
const [tick, setTick] = useState(0); // 和列表毫无关系的 state
const [onlyTodo, setOnlyTodo] = useState(false);
// 值得包:结果要当 memo 子组件的 prop,不包则每次是新数组
const visible = useMemo(
() => all.filter(i => !onlyTodo || !i.done), [all, onlyTodo]);
// 值得包:同理,否则箭头函数每次新建,Row 的 memo 全废
const onPick = useCallback((id) => console.log("选中 " + id), []);
// 不值得包:一次加法而已,包了反而多花一次依赖比较
const count = visible.length + tick;
return (
<>
<button onClick={() => setTick(t => t + 1)}>{count}</button>
<ul>{visible.map(i => <Row key={i.id} item={i} onPick={onPick} />)}</ul>
</>
);
}
// 点 tick 按钮:Row 的日志一条都没有,两个包都命中了useCallback 最常见的误用是包了个函数,却传给一个没有 memo 的子组件。子组件反正每次都重渲染,引用稳不稳定它根本不看,你白花了一次依赖比较和一份内存。先给子组件包 memo,useCallback 才有意义,顺序反了等于零。memo 的子组件的、只在本组件内部用的,一律不包。只有 memo 子组件的 prop、别人的依赖数组、以及真的以毫秒计的计算,才值得掏这笔钱。React Compiler 是一个独立的构建期工具,1.0 已经稳定发布。它读懂你的组件在算什么,自动把结果缓存起来——目标是让手写的 useMemo、useCallback、memo 变得不必要。
它做的事,用编译产物说话
把一个连 memo 影子都没有的组件喂给 babel-plugin-react-compiler 1.0.0,吐出来的是这样的东西:
- 顶上多了
import { c as _c } from "react/compiler-runtime"。 - 函数体第一行是
const $ = _c(7)——给这个组件开一个长度 7 的缓存槽数组,挂在 fiber 上。 - 后面每一段计算都被改写成
if ($[0] !== tab || $[1] !== todos) { 重算并存回 } else { 读缓存 }。连返回的 JSX 元素本身都被缓存了。
换句话说,它做的正是你手写 useMemo 时做的事,只是粒度细得多——手写时你只会给显眼的地方包一层,编译器是把整个函数体切成一个个能独立缓存的小块。
版本与接法
- 官方支持 React 17 / 18 / 19。插件的
target选项填别的字符串会报Not a valid target,后面跟一串Validation error: Invalid input: expected "17" or … or expected object, received string.——注意末尾那个expected object,除这三个字符串外它还接受一个对象形式。target 为19时产物导入react/compiler-runtime(React 19 自带);为17或18时导入react-compiler-runtime,这个包要自己额外装。 - Next.js 16 里
reactCompiler已从 experimental 提到顶层配置且稳定,但默认不开。还写在experimental里会收到告警原文⚠ `experimental.reactCompiler` has been moved to `reactCompiler`. - 只在配置里打开而没装插件,构建直接失败:
Failed to resolve package babel-plugin-react-compiler while attempting to resolve React Compiler。装了插件才能构建成功,哪怕 Next 16 默认走的是 Turbopack。
该不该在新项目里打开
它确实好用,但代价是真实存在的,值不值得取决于你的项目形态。
- 打开 React Compiler优手写的 memo 化几乎可以全删。代码回到「就是一段普通 JS」的样子,依赖数组这个长期负债一并消失,缓存粒度还比手写细。短它依赖 Babel,是构建管线里额外的一道。多缓存意味着更多内存和更大的产物体积,而且优化时机从你手里交给了编译器,出问题时多一层要排查的东西。为何同根在于「它是静态分析」:正因为在构建期把每一段计算都想明白了,才能替你写出比手工更细的缓存;也正因为要在构建期做全量分析,才必然要多跑一遍 Babel、多产出一堆缓存代码。
- 继续手写 memo 化优构建链路一个字不动,优化点在哪一眼看得见,团队里谁都看得懂。老项目里不必先去做一轮规则合规改造。短依赖数组要人肉维护,漏一个就是陈旧值 bug。而且人会漏,编译器不会——大量本该缓存的地方长期裸着。为何同根在于「一切靠人判断」:可读、可控、无新工具,是因为决策权在人手里;会漏会错会腐化,也是因为决策权在人手里。
它不是银弹
- 违反 React 规则的组件,它直接跳过。同一个文件里,写
user.name = "x"(给 props 属性赋值)或在渲染期改ref.current的组件原样输出、一行未改,规矩的那个被完整改写成带_c缓存的版本。但边界并不直觉:往 props 数组上push照样会被编译,所以你没法凭感觉判断哪个组件被放过了。跳过是静默的,你不主动去看产物就不知道自己有没有被优化到。 - 所以真正的前置工作是先把代码写规矩:不改 props、不改 state 对象、渲染期不做副作用、Hooks 只在顶层调。配套的 ESLint 规则(
eslint-plugin-react-hooks新版已内置编译器诊断)能在编辑器里就提示哪些组件会被跳过。 - 它不解决单次渲染太重。一万行还是一万行,该虚拟化还得虚拟化。它只治「渲染次数太多」那一类。
// 1) 装:npm i -D babel-plugin-react-compiler 版本 1.0.0
// 2) Next 16:next.config.ts 里写顶层的 reactCompiler: true(不再放 experimental)
// 3) Vite/Babel:把插件加进 babel plugins,target 填 "17" / "18" / "19"
// ── 源码:一个 memo、useMemo 都没写 ──
function TodoList({ todos, tab }) {
const visible = todos.filter(t => t.tab === tab);
return <List items={visible} />;
}
// ── 编译产物(target 19,逐字照抄,未作删改)──
import { c as _c } from "react/compiler-runtime";
function TodoList(t0) {
const $ = _c(7); // 本组件专属的 7 个缓存槽
const { todos, tab } = t0;
let t1;
if ($[0] !== tab || $[1] !== todos) { // 依赖变了才重算
let t2;
if ($[3] !== tab) { // 连 filter 的回调都单独缓存
t2 = t => t.tab === tab;
$[3] = tab; $[4] = t2;
} else { t2 = $[4]; }
t1 = todos.filter(t2);
$[0] = tab; $[1] = todos; $[2] = t1;
} else { t1 = $[2]; } // 否则原样复用上次的数组
const visible = t1;
let t2;
if ($[5] !== visible) { // 返回的元素本身也被缓存
t2 = <List items={visible} />;
$[5] = visible; $[6] = t2;
} else { t2 = $[6]; }
return t2;
}user.name = "x")、渲染期改 ref.current、条件调用 Hook、渲染期 setState 的组件都会被原样输出、一行未改;而往 props 数组上 push 却照样被编译——判定规则比你想的窄。编译器跳过不合规组件时不报错也不告警,务必装上配套 ESLint 规则,否则你会以为自己全站优化过了。memo。先按上一卡的口诀把手写优化删干净,再打开编译器,然后用 Profiler 复测一遍——两边都留着不会更快,只会让你分不清是谁在起作用。老项目请按目录逐步开启,别一次全量。列表的性能几乎全押在两件事上:key 让 React 复用节点还是推倒重建,以及你到底往 DOM 里塞了多少个节点。第二件事到了一定量级就不是优化题,是架构题。
key 的性能含义
- key 是 React 在同一层级里认人的身份证。key 相同则复用已有的 fiber 和 DOM 节点,只更新变化的属性;key 不同则卸载旧的、挂载新的,连同它的 state 和 effect 一起重来。
- 所以用
key={index}给一个会增删或排序的列表,等于告诉 React「第 0 位永远是同一个人」。头部插一条时,所有行的 key 全部错位,React 会把每一行的内容都改一遍——本可以只插一个节点,变成了改 N 个节点,而且行内的非受控状态会跟着串位(见 03 章的)。 - 反过来,用一个随渲染变化的值当 key(比如
Math.random())更糟:每次渲染全表卸载重建,输入焦点丢失、动画重放、state 全清。 - 正确做法只有一条:用数据自身稳定的唯一标识。数据没有 id 就在数据进入应用时补一个,别在渲染里现造。
为什么「渲染一万行」是架构问题
一万行意味着一万个 fiber、几万个 DOM 节点。React 的 diff 只是其中一部分开销,真正压垮浏览器的是布局与绘制——节点越多,每次样式重算和布局的成本越高,这部分你在 React 里做任何优化都碰不到。
而且用户一屏只能看到二三十行。剩下九千九百多行是纯粹的浪费。这不是「渲染得再快一点」能解决的,只能改成不渲染它们。
虚拟化:只渲染看得见的那些
- 原理很朴素:外层容器固定高度并可滚动,里面放一个高度等于「总行数乘行高」的占位层撑出滚动条,然后只渲染滚动位置附近的那几十行,用绝对定位摆到正确位置。
- 一万行的列表用这个手法,一次渲染只吐出 12 行、容器里总共 14 个节点。滚动时换的只是这十几行的内容。
- 生产里别自己写,用 react-window(轻、API 小、定高场景够用)或 TanStack Virtual(无头、支持不定高与动态测量、横向纵向都行)。自己写的版本在不定高、粘性表头、键盘导航、无障碍这些地方会踩不完的坑。
先问要不要,再问怎么做
- 什么时候上虚拟化:行数上千、或者行本身很重(带图表、图片、富文本)。几十上百行老老实实全渲染,虚拟化带来的复杂度反而是负收益。
- 更该先问的是:用户真需要一次看到一万行吗。分页、搜索、按需展开,往往比虚拟化更贴合真实需求,而且实现成本低得多。
- 虚拟化的代价要认:浏览器的 Ctrl+F 找不到没渲染的行,屏幕阅读器读不到,打印会残缺,锚点定位要自己实现。这些都是它换来性能的代价。
import { useState } from "react";
const ROW = 32, VIEW = 320; // 行高与可视区高度
function VirtualList({ items }) {
const [top, setTop] = useState(0);
const start = Math.floor(top / ROW);
// 多渲染两行当缓冲,滚动时不会露白
const end = Math.min(items.length, start + Math.ceil(VIEW / ROW) + 2);
const slice = items.slice(start, end);
return (
<div onScroll={e => setTop(e.currentTarget.scrollTop)}
style={{ height: VIEW, overflow: "auto" }}>
{/* 占位层:撑出真实滚动条高度 */}
<div style={{ height: items.length * ROW, position: "relative" }}>
{/* key 必须用数据 id,绝不能用下标——滚动本身就在改下标 */}
{slice.map((it, i) => (
<div key={it.id} style={{ position: "absolute",
top: (start + i) * ROW, height: ROW }}>{it.name}</div>
))}
</div>
</div>
);
}
// 传入一万条:本次渲染 12 行,容器里总共 14 个 DOM 节点key={it.id}。另外记得虚拟化后浏览器的 Ctrl+F 搜不到没渲染的行,这条要提前跟产品说清楚。前面五卡治的都是「跑起来之后卡」。这一卡治的是「打开就慢」——用户下载并解析的每一个字节都要花时间,而首屏真正用得上的往往只是其中一小部分。
lazy + Suspense
lazy(() => import("./Heavy"))把这个组件切成独立的 chunk,渲染到它的那一刻才发起下载。打包器看到动态import就会自动切分,你不用配任何东西。- 下载期间组件会挂起,必须有一个祖先
Suspense提供fallback,否则报错。点击展开后先渲染出<p>加载中…</p>,加载完成才换成真正的内容。 lazy一定要写在组件外面。写在组件体内的话每次渲染都会新建一个 lazy 组件类型,React 认不出是同一个,会不停卸载重挂。- 嵌套
Suspense可以做出分级的加载体验:外层管整页骨架,内层管某个卡片。粒度越细,用户看到内容越早。
切在哪里最值
| 切分点 | 收益 | 说明 |
|---|---|---|
| 路由级 | 最大 | 用户不去后台页就不必下载后台页的代码。用 Next.js 的话这是自动的,每个路由天生是独立 chunk(见 13 章) |
| 重依赖的组件 | 大 | 富文本编辑器、图表库、地图、代码高亮——这类库动辄几百 KB,且多半不在首屏 |
| 模态框与抽屉 | 中 | 用户不点开就永远不下载 |
| 小组件 | 负 | 切太碎会变成一堆并发的小请求,反而更慢 |
useTransition:别让重渲染卡住输入
典型场景是「搜索框边打字边过滤一个大列表」。默认所有更新优先级相同,React 会在一次同步渲染里既更新输入框又重排整张列表,输入就跟不上手了。
startTransition 把列表那次更新标记成「可以被打断的低优先级」,输入框的更新则立刻生效。在一次输入里的渲染日志是:
text="a" q="" isPending=true——输入框先跟手更新,列表还用着旧值,同时isPending变成true供你显示加载态。text="a" q="a" isPending=false——随后列表才追上。
如果那个值是从 props 或别处来的、你没法包住 setState,就改用 useDeferredValue,效果类似。
先量包体积,再动手切
- 没量过就切分是又一种预防性优化。Vite 用
rollup-plugin-visualizer,webpack 用webpack-bundle-analyzer,Next.js 用@next/bundle-analyzer,都会画出一张按体积铺开的方块图。 - 看图时优先找意外出现的大方块:整包引入的
lodash、被date-fns拖进来的全部 locale、只为一个图标装的整套图标库。换成按需引入通常比代码分割省得更多,而且不引入加载态。 useTransition不会让渲染变快,它只是改变了顺序。如果列表本身重到一次渲染就要八百毫秒,该虚拟化还得虚拟化。
import { lazy, Suspense, useState, useTransition } from "react";
// 必须写在组件外:写在组件体内会每次渲染新建类型,导致反复卸载重挂
const Heavy = lazy(() => import("./Heavy.jsx"));
function App({ all }) {
const [show, setShow] = useState(false);
const [text, setText] = useState("");
const [q, setQ] = useState("");
const [isPending, startTransition] = useTransition();
return (
<>
<input value={text} onChange={e => {
setText(e.target.value); // 高优先级:立刻跟手
startTransition(() => setQ(e.target.value)); // 低优先级:可被打断
}} />
{isPending && <span>筛选中…</span>}
<SlowList all={all} q={q} />
<button onClick={() => setShow(true)}>打开编辑器</button>
<Suspense fallback={<p>加载中…</p>}>{show && <Heavy />}</Suspense>
</>
);
}
// 一次输入的渲染日志:
// text="a" q="" isPending=true ← 输入框先跟手
// text="a" q="a" isPending=false ← 列表随后追上lazy(() => import(...)) 写在组件函数体里面是最隐蔽的坑:每次渲染都产生一个全新的 lazy 类型,React 判定为不同组件,于是不停卸载重挂——表现是加载态一直闪、组件里的 state 每次归零,而控制台一个报错都没有。TypeScript + React
从 props 与 Hooks 的标注,到事件类型、扩展原生元素、泛型组件——把类型这一关补齐,本页的示例才真能抄走就用。
React 组件在类型系统眼里就是一个接收单个对象参数、返回可渲染内容的普通函数。所以标注组件不需要任何 React 专用的写法——直接给 props 参数标一个类型就完了。
标注 props 参数就够了
- 写
function Card({ title, badge }: CardProps),返回值交给 TypeScript 推断。JSX 表达式的类型 TS 自己认识,你标了反而是多余的约束。 - 可选属性用
?。它得到的类型是T | undefined,配上解构默认值badge = 0之后,函数体里就是实打实的number,不用再判空。 - 回调类型直接写函数签名
onClose?: () => void。返回void的位置可以接受任何返回值的函数,所以onClose={() => setOpen(false)}这种能通过,不必写成() => unknown。
为什么不推荐 React.FC
它曾经的最大罪名是「自动往 props 里塞 children」,这条已经不成立了——@types/react 19 里 FunctionComponent<P> 的签名就是 (props: P) => ReactNode,干干净净。给一个 FC<{ title: string }> 传 children,报的是:
error TS2322: ... Property 'children' does not exist on type 'IntrinsicAttributes & { title: string; }'.
真正让社区放弃它的是另外两条:
- 写不了泛型组件。类型标注的位置在等号左边,那里没地方声明类型参数。
const Bad: FC<{ items: T[] }> = ...直接报error TS2304: Cannot find name 'T'.——而直接标注参数的写法const Ok = <T,>({ items }: { items: T[] }) => ...一次通过。 - 它什么也没多给你。返回值本来就能推断,props 本来就能标注。多绕一层
FC只是让签名更难读。
children 与 interface 还是 type
- 要接收子内容就在 props 里显式声明
children: ReactNode。ReactNode是最宽的那个——元素、字符串、数字、数组、null、undefined、布尔全都算。想要「必须传且不能为空」就用children: ReactElement,但这会拒掉纯文本,多数时候管太宽了。 interface和type在这个场景下几乎等价,选哪个都不影响正确性。一条能用的规矩:要和原生元素的 props 做交叉合并(下一卡的&写法)就用type,它对交叉、联合、映射类型更自然;纯粹描述一个对象形状、而且可能被别人extends,就用interface。- 别在 props 类型里用联合类型表达互斥语义时偷懒。
{ href?: string; onClick?: () => void }允许两个都不传,写成{ href: string } | { onClick: () => void }才真的互斥。
import type { ReactNode } from "react";
interface CardProps {
title: string;
badge?: number; // 可选 = number | undefined
onClose?: () => void; // void 位置能接受任何返回值的函数
children: ReactNode; // 想收子内容必须显式声明
}
// 直接标注参数,返回值交给推断——不要 React.FC
export function Card({ title, badge = 0, onClose, children }: CardProps) {
return (
<section>
<h3>{title}{badge > 0 && <em>{badge}</em>}</h3>
{onClose && <button onClick={onClose}>x</button>}
{children}
</section>
);
}
// 泛型组件:这样能写,写成 const Ok: FC<...> 就写不出来了
const Ok = <T,>({ items }: { items: T[] }) => <span>{items.length}</span>;
export function Demo() {
return <Card title="标题"><Ok items={[1, 2, 3]} /></Card>;
}children 却在外面塞了子内容,报错长得很唬人:error TS2322: Type '{ title: string; children: string; }' is not assignable to type 'IntrinsicAttributes & { title: string; }'. 看到 IntrinsicAttributes 这个词就去检查组件多收了什么它没声明的 prop,九成是 children。function C(props: Props) 或 const C = (props: Props) => ...,然后就再也不用想 FC、VFC、FunctionComponent 这些名字了。它们能做的这个写法全能做,反过来不成立。Hooks 的类型几乎全靠初始值推断,所以绝大多数时候你一个类型都不用写。要显式插手的只有三处:初始值撑不起未来的值、ref 的两种用途、以及 reducer 的 action。
useState:只在初始值不够代表全集时写泛型
useState(0)推断出number、useState("")推断出string,这些都别画蛇添足。- 要写泛型的高频场景是初始值为
null。useState(null)推断出的类型就是null,之后 setter 只肯接受null。往里塞对象会报error TS2353: Object literal may only specify known properties, and 'id' does not exist in type '(prevState: null) => null'.——这条报错文案极具迷惑性,它是把你的对象当成函数式更新的回调去比对了。写成useState<User | null>(null)就好。 - 同理,
useState([])推断出never[],往里 push 任何东西都报错。空数组一律写useState<Item[]>([])。 - 初始值是字面量而 state 要在几个值之间切换时也要写:
useState<"idle" | "loading" | "done">("idle"),否则推断成宽泛的string。
useRef 的两种形态
| 用途 | 写法 | current 的类型 | 读的时候 |
|---|---|---|---|
| 指向 DOM 节点 | useRef<HTMLInputElement>(null) | HTMLInputElement | null | 必须 ?. 或判空——挂载前它就是 null |
| 存跨渲染的可变值 | useRef<number>(0) | number | 直接读直接写,没有 null |
差别的根子在初始值:传 null 命中的是「给 ref 属性用」的那个重载,返回 RefObject<T | null>;传真实初始值命中的是普通重载,返回 RefObject<T>。两者的 current 在 @types/react 19 里都是可写的,区别只在类型里带不带 null。
useReducer:action 用可辨识联合,配穷尽性检查
- 把 action 写成以
type字段区分的联合类型。switch (action.type)的每个case里,TS 会自动把action收窄到对应的那一支,action.text、action.id都能安全访问,而在别的分支里访问它们会直接报错。 default分支里写一句const never: never = action。所有分支都处理完时action的类型是never,赋值合法;哪天有人加了新的 action 却忘了写 case,这一行就会在编译期红给你看。这是全书性价比最高的一个类型技巧。- reducer 的两个参数和返回值都标上类型后,
useReducer(reducer, 初值)的state和dispatch全部自动推断,调用处不需要任何标注。
import { useState, useRef, useReducer, useEffect } from "react";
type User = { id: number; name: string };
// action 写成以 type 字段区分的可辨识联合
type Action = { type: "add"; text: string }
| { type: "remove"; id: number };
type State = { items: { id: number; text: string }[] };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "add": return { items: [...state.items, { id: 1, text: action.text }] };
case "remove": return { items: state.items.filter(i => i.id !== action.id) };
default: {
const never: never = action; // 漏写 case 时这一行编译期报错
return never;
}
}
}
export function Panel() {
const [user, setUser] = useState<User | null>(null); // 必须显式
const inputRef = useRef<HTMLInputElement>(null); // current 可能是 null
const timerRef = useRef<number>(0); // current 就是 number
const [state, dispatch] = useReducer(reducer, { items: [] });
useEffect(() => {
inputRef.current?.focus(); // DOM ref 必须走可选链
timerRef.current = timerRef.current + 1; // 可变值 ref 直接读写
dispatch({ type: "add", text: "第 " + state.items.length + " 项" });
}, []);
return <input ref={inputRef} placeholder={user?.name ?? "未登录"} />;
}useState(null) 之后再 setUser({ id: 1, name: "阿伟" }),报错是 error TS2353: Object literal may only specify known properties, and 'id' does not exist in type '(prevState: null) => null'. 千万别顺着 prevState 去改写成函数式更新——真正的病根在初始值把类型钉死成了 null,补上泛型即可。useState(0) 不用写,useState(null)、useState([])、useState("idle") 这三种一律要写。React 的事件对象是合成事件,类型全部带一个泛型参数指明「事件发生在哪种元素上」。真正的分界线是:写在 JSX 里的内联箭头函数能自动推断,抽成独立函数就必须自己标。
能不标就不标
onChange={e => setName(e.target.value)}写在input上时,e的类型由input的onChange属性签名反向推断出来,不用写一个字。这是最常见也最推荐的写法。- 一旦把处理函数抽成
function handleChange(e)放在组件里,它就脱离了 JSX 上下文,TS 无从推断,必须显式标注ChangeEvent<HTMLInputElement>。
三个高频类型和它们的区别
| 类型 | 用在哪 | 关键点 |
|---|---|---|
ChangeEvent<HTMLInputElement> | input / select / textarea 的 onChange | 泛型参数会把 e.target 收窄成该元素,所以 e.target.value 才有类型 |
FormEvent<HTMLFormElement> | form 的 onSubmit | 第一件事永远是 e.preventDefault()(见 04 章) |
MouseEvent<HTMLButtonElement> | 点击类事件 | 它的 e.target 只是 EventTarget,没有 value 也没有 disabled;要拿绑定元素得用 e.currentTarget |
target 与 currentTarget 的差别不是 TS 的怪癖,是 DOM 本来的语义:target 是事件最初发生的那个节点(可能是按钮里的图标),currentTarget 是你挂监听的那个节点。ChangeEvent 之所以能直接用 e.target.value,是因为 React 在它的类型里特意把 target 也收窄了。
不靠记忆:从 JSX 属性反查
- 编辑器里查:在 JSX 的属性名上按住 Ctrl 点进去,或者直接把鼠标悬停在内联箭头函数的
e上,浮层里写的就是你要的类型全名,抄下来即可。这比背表可靠得多,尤其是onKeyDown、onDrop、onPointerMove这种不常写的。 - 代码里抠:用
Parameters<NonNullable<ComponentProps<"input">["onChange"]>>[0]把参数类型直接算出来。这个式子能通过类型检查,并且拿到的e.target.value是string。抽公共处理函数时这招最省事——元素换了,类型自动跟着换。 - 元素类型名本身也不用背:JSX 标签名首字母大写加上
HTML前缀、加Element后缀,input就是HTMLInputElement,a是HTMLAnchorElement(这个不规则的自己查一下)。
import { useState, type ChangeEvent, type FormEvent,
type MouseEvent, type ComponentProps } from "react";
// 反查技巧:不背类型名,直接从元素 props 上把参数类型抠出来
type InputChange = NonNullable<ComponentProps<"input">["onChange"]>;
type E = Parameters<InputChange>[0]; // 就是 ChangeEvent<HTMLInputElement>
export function LoginForm() {
const [name, setName] = useState("");
// 抽出来就脱离了 JSX 上下文,必须自己标
function handleChange(e: ChangeEvent<HTMLInputElement>) {
setName(e.target.value); // 泛型把 target 收窄了才有 value
}
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
}
function handleClick(e: MouseEvent<HTMLButtonElement>) {
console.log(e.currentTarget.disabled); // 这里只能用 currentTarget
}
return (
<form onSubmit={handleSubmit}>
{/* 内联写法 e 自动推断,一个字都不用标 */}
<input value={name} onChange={e => setName(e.target.value)} />
<input value={name} onChange={handleChange} />
<button onClick={handleClick}>提交</button>
</form>
);
}MouseEvent 里写 e.target.value,报 error TS2339: Property 'value' does not exist on type 'EventTarget'. 因为只有 ChangeEvent 的类型收窄了 target。点击类事件一律用 e.currentTarget——它才是你挂监听的那个元素,类型也是准的。e 上,浮层里显示的全名直接抄走。这条对 onKeyDown、onDrop 这些冷门事件尤其管用。自己封装的 Button 应该能像原生 button 一样用——type、disabled、aria-*、各种事件都照收。手写这几百个属性不现实,ComponentProps 一行就把它们全借过来。
三个工具类型
| 写法 | 含义 | 什么时候用 |
|---|---|---|
ComponentProps<"button"> | 原生 button 的全部 props,含 ref | React 19 项目里的默认选择 |
ComponentPropsWithoutRef<"span"> | 同上但去掉 ref | 组件不打算转发 ref 时,用它明确表态 |
ComponentProps<typeof MyBtn> | 另一个组件的 props | 再包一层别人的组件时 |
用交叉类型把自定义属性拼上去:type ButtonProps = ComponentProps<"button"> & { variant?: "primary" | "ghost" }。交叉写法比 interface extends 更顺手,因为 ComponentProps 返回的是类型别名。
透传的姿势
- 解构出自己关心的,剩下的用
...rest原样铺给原生元素。这样使用者传什么都能落到实处,你不用维护一张白名单。 className通常要单独解构出来,因为你自己也要往上拼类名,直接铺过去会被覆盖掉。...rest要放在 JSX 属性的后面还是前面,取决于你想让使用者覆盖你的默认值(放后面)还是不许覆盖(放前面)。这是个有意的设计决定,别随手写。
React 19 之后 ref 简单了一大截
- React 19 里
ref就是一个普通 prop。类型上你什么都不用做——ComponentProps<"button">里已经带着ref?: Ref<HTMLButtonElement>,直接从 props 里解构出来铺到原生元素上就行。 - 对比
forwardRef时代:那时要写forwardRef<HTMLButtonElement, Props>((props, ref) => ...),两个类型参数顺序还和直觉相反(元素在前、props 在后),而且泛型组件配forwardRef需要额外的类型断言技巧。这些现在全都不需要了。 - 本卡的写法通过了
tsc --strict检查,并在 React 19.2.8 里跑通:父组件的useRef<HTMLButtonElement>(null)在 effect 里拿到了真实节点,tagName是BUTTON,disabled是true,渲染出的 HTML 是<button data-variant="primary" type="submit" disabled>保存</button>。 forwardRef在 19.2.8 里仍然可用且不打废弃告警,老代码不用急着改(见 02 章)。但新写的组件没有任何理由再用它。- 需要单独声明一个 ref 类型时用
Ref<HTMLButtonElement>——它同时涵盖回调形式和对象形式两种 ref。别写成RefObject,那会拒掉使用者传回调 ref 这种完全合法的用法。
import { useRef, type ComponentProps,
type ComponentPropsWithoutRef } from "react";
// 借来原生 button 的全部 props,再拼上自己的 variant
type ButtonProps = ComponentProps<"button"> & {
variant?: "primary" | "ghost";
};
// React 19:ref 就是普通 prop,直接解构,不需要 forwardRef
export function Button(
{ variant = "primary", className, ref, ...rest }: ButtonProps
) {
return <button ref={ref}
className={`btn btn-${variant} ${className ?? ""}`} {...rest} />;
}
// 不打算转发 ref 时用 WithoutRef,把意图写进类型里
type TagProps = ComponentPropsWithoutRef<"span"> & { tone: "warn" | "ok" };
export function Tag({ tone, ...rest }: TagProps) {
return <span data-tone={tone} {...rest} />;
}
export function Demo() {
const btnRef = useRef<HTMLButtonElement>(null);
// type、disabled、onClick 全是白借来的,类型一应俱全
return <Button ref={btnRef} type="submit" disabled
onClick={e => console.log(e.currentTarget)}>保存</Button>;
}forwardRef 了。React 19 里 ComponentProps<"button"> 本身就含 ref,解构出来铺上去即可,跑通、类型检查也过。反过来,如果你解构了自定义属性却忘了 ...rest,使用者传的 onClick、disabled 会被静默吞掉——类型全绿,行为全丢。ComponentProps<"元素名"> & { 你自己的属性 },然后解构自己关心的、...rest 铺给原生元素。这个模式能覆盖设计系统里九成的基础组件,而且 aria-* 和 data-* 也一并白拿。泛型组件的价值不在「支持多种类型」,而在把参数之间的关系锁死——items 是什么类型,renderItem 的参数就必须是什么类型,编译器替你盯着。
写一个泛型 List
- 类型参数写在函数上:
function List<T>({ items, renderItem }: ListProps<T>)。调用时不用手动传T,TS 从items的实参推断出来,然后把renderItem的参数一并收窄。 - 传
items={[{ id: 1 }]}后在renderItem里访问一个不存在的字段,直接报error TS2339: Property 'nope' does not exist on type '{ id: number; }'.——这就是泛型带来的真收益:两个 prop 之间的一致性被编译器兜住了。 items标成readonly T[]而不是T[]。既能接受普通数组,又表明组件不会去改它,是个零成本的好习惯。- 在
.tsx文件里写箭头函数泛型要写成<T,>(带个逗号),否则解析器会把<T>当成 JSX 标签。用function声明就没这个问题——这也是泛型组件建议用 function 写的理由。
几个真会用到的实用类型
| 类型 | 用途 |
|---|---|
Dispatch<SetStateAction<T>> | useState 的 setter 类型。把 setter 传给子组件时要这么标,不能只写 (v: T) => void——那样子组件就用不了函数式更新了 |
ReactNode | 任何可渲染的东西。props 里接收「一段内容」时用它 |
ComponentProps<T> | 借用元素或组件的 props,见上一卡 |
Awaited<ReturnType<typeof fetchUser>> | 从已有函数反推数据类型,免得手写一份和后端对不上的接口定义 |
类型体操的边界:什么时候该收手
类型是为了帮你,不是给你出题的。出现下面这些信号就该停:
- 类型代码比运行时代码长。一个二十行的组件配着五十行的条件类型和映射类型,收益早就为负了。拆成两个组件通常比继续凹类型划算得多。
- 你已经开始试错。不断加
infer、加约束、改来改去只为让红线消失,说明你在和类型系统搏斗而不是在描述意图。 - 报错信息长到看不懂。写出来的类型如果在报错时会吐出七层嵌套,你的同事只会绕着它走。
该用 unknown 还是 any:优先 unknown——它逼你在使用前做一次收窄(typeof、in、或者一个类型守卫函数),安全边界仍然完整,只是把检查从编译期挪到了你显式写的那一行。any 是彻底关掉该处的检查,而且会顺着数据流污染下去。真要用 any,就配一句 // eslint-disable-next-line 加注释说清为什么,让它成为一个有据可查的决定,而不是投降。
import { useState, type ReactNode,
type Dispatch, type SetStateAction } from "react";
type ListProps<T> = {
items: readonly T[]; // readonly:表明组件不改它
keyOf: (item: T) => string | number;
renderItem: (item: T, index: number) => ReactNode;
};
// 用 function 声明,避开 .tsx 里 <T> 被当成 JSX 标签的麻烦
function List<T>({ items, keyOf, renderItem }: ListProps<T>) {
if (items.length === 0) return <p>暂无数据</p>;
return <ul>{items.map((it, i) =>
<li key={keyOf(it)}>{renderItem(it, i)}</li>)}</ul>;
}
type User = { id: number; name: string };
// setter 传给子组件必须写全,否则子组件用不了函数式更新
function NameEditor({ set }: { set: Dispatch<SetStateAction<User | null>> }) {
return <button onClick={() =>
set(u => (u ? { ...u, name: "改了" } : u))}>改名</button>;
}
export function App() {
const [users] = useState<User[]>([{ id: 1, name: "阿伟" }]);
const [cur, setCur] = useState<User | null>(null);
// T 从 items 推断出来,renderItem 里的 u 自动就是 User
return <>
<List items={users} keyOf={u => u.id}
renderItem={u => <b onClick={() => setCur(u)}>{u.name}{cur?.id}</b>} />
<NameEditor set={setCur} />
</>;
}.tsx 里写 const List = <T>(props: Props<T>) => ... 会被解析成 JSX 标签,报出一堆看不懂的语法错。修法是加个逗号写成 <T,>,或者干脆改用 function 声明——后者可读性更好,也不会有人在 code review 里问你那个逗号是不是手滑。useState 的 setter 给子组件时,类型一律写 Dispatch<SetStateAction<T>>,别图省事写成 (v: T) => void。后者会让子组件失去函数式更新的能力,而函数式更新恰恰是并发场景下唯一安全的写法(见 03 章)。健壮性:错误边界与 Suspense
错误边界为什么至今仍需 class、Suspense 到底在等什么、Portal 为什么不改变事件冒泡——让界面在出错时优雅降级而不是白屏。
React 的默认失败模式极其残暴:子树里任何一次渲染抛出未捕获的错误,整棵树会被卸载,用户看到的是一片白屏。错误边界(Error Boundary)就是那个「在这一层把错误接住、换成降级 UI」的开关——而它至今只能用 class 写,因为它要挂钩的是渲染失败这件事本身,而失败时函数组件的 Hooks 状态已经不可信了。
两个钩子,分工不同
static getDerivedStateFromError(error)在渲染阶段被调用,只允许返回新的 state,必须是纯函数——React 拿它决定「这次重新渲染要显示什么降级内容」。componentDidCatch(error, info)在提交阶段被调用,允许做副作用,是上报日志的地方。info.componentStack给出出错组件的调用栈。- 只写前者也能工作(能降级但不上报),只写后者也能工作(能上报但不降级)。生产里两个都要。
- 为什么没有 Hooks 版:这两个钩子的语义是「我的子树渲染失败了,请让我这一层用另一份 state 重新渲染」。函数组件没有独立于渲染之外的「实例」可以承载这个状态转移,React 团队多年来一直把它列为待办,到 19.2 仍未提供等价 Hook。所以哪怕整个项目零 class,也必须为它破一次例。
它捕获不到什么
下面四条都在 react-dom 19.2.8 里跑过,用同一个边界包裹四种抛错方式:
| 抛错位置 | 边界是否接住 | 现象 |
|---|---|---|
| 组件渲染期 | 会 | 降级 UI 出现,控制台打印The above error occurred in the <BoomOnRender> component. |
| 事件处理函数 | 不会 | 错误直接冒到 window,边界纹丝不动,页面继续显示原内容 |
| setTimeout 等异步回调 | 不会 | 错误逃出 React 的调用栈,直接抛到全局:浏览器里落到 window.onerror、控制台红一行,页面继续跑;Node 环境下会直接终止进程 |
| 边界自己的 render | 不会 | 宿主节点渲染成空字符串;套一层外部边界后,外层打印using the error boundary you provided, Outer并接住 |
根因是同一条:错误边界拦截的是 React 自己驱动的那次渲染。事件回调和定时器回调虽然是你在组件里写的,但它们执行时 React 的渲染栈早已退出,React 没有任何机会去 try/catch 它们。
react-error-boundary 省掉了什么
- 它仍然是 class 实现,但把 class 藏了起来:你只写
<ErrorBoundary FallbackComponent={Fallback}>。 - 重试:fallback 会收到
resetErrorBoundary,调用它清空错误态并重新渲染子树;配套的onReset回调让你顺手重置查询缓存。点一次「重试」按钮,onReset打印后子树从降级 UI 恢复成正常内容。 - 补捞异步错误:
useErrorBoundary()返回showBoundary(err),让你在事件处理函数或catch块里手动把错误塞给边界——这正是上表那两条「捕获不到」的标准解法。点击按钮后降级 UI 正常出现。 resetKeys让路由变化时自动清错误态,省掉一堆useEffect。
// 手写版:生产可直接抄。两个钩子分工——一个决定显示什么,一个负责上报
class ErrorBoundary extends React.Component {
state = { error: null };
// 渲染阶段调用:必须是纯函数,只能返回新 state
static getDerivedStateFromError(error) {
return { error };
}
// 提交阶段调用:这里才允许做副作用(上报)
componentDidCatch(error, info) {
report(error, info.componentStack);
}
render() {
if (this.state.error) {
// 把 reset 交出去,让降级 UI 能提供「重试」
return this.props.fallback(
this.state.error,
() => this.setState({ error: null })
);
}
return this.props.children;
}
}
// 事件里的错误边界接不住,只能自己捞给它(库版用 showBoundary)
<button onClick={async () => {
try { await submit(); } catch (e) { showBoundary(e); }
}}>提交</button>onClick 抛出的错误直接冒到 window、边界毫无反应,setTimeout 里抛出的错误更是把整个进程打崩——因为这两处执行时 React 的渲染栈已经退出。另一处是让边界自己抛错:边界的 render(包括 fallback 里的错误)它自己接不住,宿主节点直接渲染成空字符串,必须由外层的另一个边界兜底。所以降级 UI 要写得比正常 UI 更朴素,别在 fallback 里再读一遍可能为空的数据。<App> 意味着一个侧边栏小组件出错也会白屏。实用口诀是路由级放一个兜底,加上每个「可独立失败」的区块各放一个——图表、评论区、推荐位。新项目直接上 react-error-boundary,别手写 class。Suspense 不是 try/catch 的异步版,它是一套控制流机制:子组件在渲染中途抛出一个 Promise,React 就中止这次渲染、显示最近一层 <Suspense> 的 fallback,等 Promise 落定后把这个组件从头重新渲染一遍。理解「重跑」这两个字,Suspense 的所有怪脾气就都解释得通了。
它到底在等什么
- 等的是一个被抛出的 Promise。组件函数执行到一半发现数据没准备好,就
throw promise——React 捕获它,向上找最近的<Suspense>,渲染其fallback。 - Promise 落定后,React 重新调用一次这个组件函数,从头再来。一个用
use()读已缓存 Promise 的组件,组件函数被调用了 2 次:第一次挂起,第二次拿到数据渲染成功。 - 所以组件函数必须是纯的、可重入的。挂起之前做过的任何副作用(打点、写全局变量、发第二个请求)都会被重复执行一遍。
- 这也解释了为什么 Suspense 和
useEffect里取数是两条路:useEffect是「先渲染出空壳,再补数据」;Suspense 是「数据没好就干脆不提交这次渲染」。
两种用法
| 用法 | 谁抛的 Promise | 说明 |
|---|---|---|
lazy(() => import("./Chart")) | React 内部包装 import() | 代码分割。未解析时宿主是 <p>加载中…</p>,模块解析后变成组件真身 |
use(promise) 读数据 | 你传进去的 Promise | 见下一卡,Promise 必须缓存 |
两者机制完全一致,区别只在 Promise 从哪来。同一个 <Suspense> 可以同时兜住这两种挂起。
它不是什么
- 不是 try/catch 的异步版。它不处理错误,只处理「还没好」。Promise 被 reject 时 Suspense 完全不管,错误会继续往上抛给错误边界——这是两套独立机制,必须都写。
- 不会让你的请求变快,也不会自动帮你发请求。它只是决定「数据没到之前渲染什么」,取数逻辑仍然是你自己或数据库的事。
- 兜不住
useEffect里的取数。写useEffect(() => { fetch(...).then(setData) }, [])的组件永远不会挂起,因为它第一次渲染就成功返回了空壳。这类组件外面套多少<Suspense>都没有意义,加载态还得靠自己的if (!data)。要用上 Suspense,取数必须发生在渲染期——也就是use()、lazy()或数据库支持 Suspense 的模式。 - 不会因为 Promise 已经落定就省掉那次重跑。传一个当场
Promise.resolve(...)出来的、早已完成的 Promise,组件函数依然被调用了 2 次——挂起与重试是固定流程,不看 Promise 的状态。
fallback 粒度怎么定
Suspense 边界放哪一层,决定了「加载时页面上有多大一块变成骨架」。
- 放得高(整页一个)优写起来省事,只要一份骨架屏,不会出现多个 spinner 各转各的短最慢的那个请求拖住整页;已经能显示的内容也被藏在 fallback 后面为何边界越高,被它「代管」的子树越大——统一的代价就是同步等待最慢的一个。
- 放得低(每块内容一个)优快的先出、慢的后出,首屏可感知速度明显更好短多个区块先后落地会造成布局跳动(layout shift),骨架要写好几份为何细粒度换来的是独立性,而独立落地必然带来先后顺序,也就带来跳动——所以 fallback 的尺寸要和真实内容对齐。
实践判断:按「用户视觉上认为是一块的东西」划边界,并让 fallback 占据和真实内容相同的高度。
import { Suspense, lazy } from "react";
// lazy 把动态 import 包成组件,加载期间自动挂起
const Chart = lazy(() => import("./Chart"));
// 把「边界 + 等高骨架」封成一个组件,业务代码里就不会漏写
function Block({ height, children }) {
return (
<Suspense fallback={<Skeleton height={height} />}>
{children}
</Suspense>
);
}
function Dashboard() {
// Summary 是同步的,不受任何挂起影响,立刻就能看到
// 两个独立边界:图表慢不会拖住评论,反之亦然
// 骨架高度对齐真实内容,才不会在数据落地时抖一下
return (
<main>
<Summary />
<Block height={320}><Chart /></Block>
<Block height={200}><Comments /></Block>
</main>
);
}StrictMode 双渲染还会更多)。副作用一律放进 useEffect 或事件处理函数里。还有一个是把 fallback 写成 null:挂起时那块区域直接消失,用户看到的是内容闪烁塌陷,比 spinner 还糟。<Suspense> 和错误边界当成一对:Suspense 管「还没好」,错误边界管「坏了」,两者都缺就只剩白屏。标准写法是错误边界在外、Suspense 在内——这样数据请求失败时走降级 UI,而不是永远卡在骨架屏上。fallback 用与真实内容等高的骨架,别用居中转圈的 spinner。use(promise) 是把「抛 Promise 给 Suspense」这个动作官方化的 API。它极其好用,也极其容易用错——两个坑都直接源自上一卡那句话:挂起会让组件函数从头重跑。
坑一:Promise 必须被缓存,否则无限挂起
- 组件挂起后会被重新调用。如果 Promise 是在组件函数体里
new出来的,重跑时又会new一个全新的、同样未落定的 Promise——React 于是再次挂起,如此往复,永远到不了成功那一步。 - 把
use(new Promise(...))直接写在组件里:等待多轮之后宿主 HTML 仍然是"<p>加载中…</p>",而组件函数已经被调用了 9 次。作为对照,把 Promise 提到组件外面缓存好,组件函数只调用 2 次就渲染出了数据。 - 这个 bug 的可怕之处在于它不报错:没有告警、没有异常,页面只是安静地永远转圈,看起来像接口慢。
- 怎么缓存:交给数据层。服务端组件里可以直接把
fetch的 Promise 当 prop 传下来(见 14 章);客户端请用 React Query、SWR 这类库,或至少用一个模块级的Map按 key 缓存。不要自己在useMemo里存 Promise——useMemo不保证不被丢弃。
坑二:use() 不能包在 try/catch 里
- 因为
use()的挂起机制就是抛异常,而catch会把这个本该交给 React 的信号截胡。React 为此专门做了检测,报错原文告警:`use` was called from inside a try/catch block. This is not allowed and can lead to unexpected behavior. To handle errors triggered by `use`, wrap your component in a error boundary. - 而你的
catch会抓到一个假异常,原文是:Error: Suspense Exception: This is not a real error! It's an implementation detail of `use` to interrupt the current render. You must either rethrow it immediately, or move the `use` call outside of the try/catch block. - 后果很直观:一个
use了 rejected Promise 的组件被 try/catch 包住后,页面渲染出的是<p>我以为我处理了错误</p>——你以为处理了错误,实际上连数据都还没等到,处理的是 React 的内部控制信号。 - 所以「
use完全不受 Hooks 规则约束」是错的。它只放宽了「必须在顶层调用」这一条(可以写在if和循环里),try/catch 这条反而是它独有的新约束。
它比普通 Hook 松在哪、紧在哪
| 规则 | 普通 Hook | use() |
|---|---|---|
| 必须在组件顶层调用 | 必须 | 不必,可以写在 if 和循环里 |
| 只能在组件或自定义 Hook 里调用 | 是 | 是,同样受限 |
| 可以包在 try/catch 里 | 可以 | 不可以,React 会告警 |
| 参数需要稳定引用 | 看具体 Hook | 必须,否则无限挂起 |
换句话说 use() 是「松一条、紧两条」。之所以能条件调用,是因为它不靠调用顺序去索引 Hook 链表,而是直接读传入对象的状态;之所以不能进 try/catch,恰恰也是因为它借用了异常通道来传递挂起信号。
正确的错误出口:错误边界
- Promise reject 时,React 会把 rejection 原样当成渲染错误往上抛,交给最近的错误边界。用同一个 rejected Promise,外面套上边界后页面渲染成
<p>边界接住: 接口 500</p>,控制台打印using the error boundary you provided, EB。 - 这就把本章前三卡串起来了:加载态归 Suspense,错误态归错误边界,
use只管读值。组件里不该出现任何if (error)分支。 - 如果确实想就地降级而不惊动边界,正确做法是在 Promise 上做处理:
use(fetchUser().catch(() => FALLBACK_USER))——把 catch 挂在 Promise 上,而不是包住use。
import { use, Suspense } from "react";
// 模块级缓存:同一个 id 永远返回同一个 Promise 实例
const cache = new Map();
function fetchUser(id) {
if (!cache.has(id)) {
cache.set(id, fetch("/api/user/" + id).then(r => r.json()));
}
return cache.get(id);
}
function Profile({ id }) {
// 挂起后本函数会整个重跑,此时 fetchUser 返回的是同一个 Promise
const user = use(fetchUser(id));
// 不写 if (loading),也不写 if (error)——两者各有归属
return <h2>{user.name}</h2>;
}
// 边界在外管「坏了」,Suspense 在内管「还没好」
<ErrorBoundary fallback={<p>加载失败</p>}>
<Suspense fallback={<Skeleton />}>
<Profile id={7} />
</Suspense>
</ErrorBoundary><p>加载中…</p>,控制台一个字都不打。还有一个是出于「防御性编程」的习惯给 use() 套 try/catch,React 会告警 `use` was called from inside a try/catch block.,而 catch 抓到的是 Error: Suspense Exception: This is not a real error! 这个假异常——你的错误处理分支会在数据根本没到的时候就执行。use() 代码是否有救,只看一个问题:这个 Promise 在组件重跑时还是不是同一个对象。写在组件函数体里的 new Promise、fetch(...)、somePromise.then(...) 全都不是——每次重跑都会造出新的。把它挪到模块级缓存、服务端组件的 prop,或直接交给 React Query 这类库,才算安全。createPortal(children, domNode) 让一段 JSX 渲染到 DOM 树的别处(通常是 document.body),但在 React 树上它还待在原地。这句「DOM 换了、React 树没换」是 Portal 的全部精髓,也是它最常被误解的地方。
它解决的是 CSS 问题
- 逃离
overflow: hidden:下拉菜单长在一个设了overflow: hidden的卡片里,展开时会被裁掉。Portal 把它挪到body下,裁剪祖先就不存在了。 - 逃离层叠上下文(stacking context):父级只要有
transform、filter、opacity < 1或position: fixed,就会创建新的层叠上下文,子元素的z-index再大也翻不出去。这就是「我 z-index 写了 9999 弹窗还是被盖住」的真正原因——不是数字不够大,是根本不在同一个上下文里比较。 - 所以 Portal 的适用面很窄也很明确:Modal、Drawer、Tooltip、Toast、下拉菜单。除此之外基本用不上。
反直觉的关键点:事件沿 React 树冒泡
配置:一个 <Panel> 组件带 onClick,它内部用 Portal 把一个按钮渲染到 body 下的 #portal-host。点击那个按钮,输出:
- 按钮的 DOM 父节点是
portal-host,container.contains(btn)为 false——它在 DOM 上确实不在 Panel 里面。 - 但
<Panel>的onClick照样被触发了,打印出「React 树上的父组件 Panel 收到了 click」。 - 与此同时,挂在真实 DOM 祖先
#portal-host上的原生监听也触发了,而挂在container上的原生监听没有触发——原生事件老老实实按 DOM 树冒泡,只有 React 的合成事件按 React 树走。 - 机制上很自然:React 的事件是在根容器上统一代理、再按组件树的父子关系派发的,Portal 没有动组件树,所以派发路径不变。Context、状态提升、错误边界的作用范围同理,全部按 React 树算。
好处是写弹窗时 onClose、Context、主题这些照常从逻辑父组件继承,不用做任何特殊处理。代价是一个真实的坑:「点击外部关闭」的实现会失灵——点弹窗内部时事件会冒泡到逻辑父组件,如果父组件上挂着「点我就关闭」,弹窗会自己把自己关掉。
写 Portal 的几条实务
- 宿主节点用
document.body最省事;要更可控就在index.html里预留一个<div id="portal-root">。 - 服务端渲染下
document不存在。Next.js 里这段必须在客户端组件中,并且要等挂载后再渲染(见 pitfall)。 - 无障碍(accessibility)不会因为 Portal 自动变好:焦点陷阱(focus trap)、
Esc关闭、aria-modal、打开时锁定背景滚动都得自己写。这些细节多且易漏,生产里直接用 Radix UI 或 Headless UI——它们内部就是 Portal 加上一整套 ARIA 处理。
import { createPortal } from "react-dom";
function Modal({ open, onClose, children }) {
if (!open) return null;
return createPortal(
// 遮罩点了才关;内容区要挡住冒泡,否则点内容也会关
<div className="overlay" onClick={onClose}>
<div className="panel"
onClick={e => e.stopPropagation()}>
{children}
</div>
</div>,
document.body
);
}
function Card() {
const [open, setOpen] = useState(false);
// Modal 的 DOM 在 body 下,但它在 React 树上仍是 Card 的孩子:
// 所以下面这个 onClick 能收到弹窗内部冒泡上来的点击
return (
<div onClick={() => console.log("Card 收到点击")}>
<button onClick={() => setOpen(true)}>打开</button>
<Modal open={open} onClose={() => setOpen(false)}>
正文
</Modal>
</div>
);
}onClick={e => e.stopPropagation()},或改用挂在 document 上的原生监听(原生事件走 DOM 树,不会经过逻辑父组件)。另一处是在 Next.js 里直接写 createPortal(..., document.body):服务端没有 document,必须放进客户端组件并等 useEffect 里置位 mounted 后再渲染。overflow 裁剪、层叠上下文、原生 addEventListener 的冒泡路径——才是它改变的。判断某个行为会不会变,先问它属于哪一边。健壮性不是「加几个 try/catch」,而是一个结构问题:把「还没好」「坏了」「没有数据」这三种状态从组件逻辑里剥离出去,交给专门的边界处理。剥离得干净,组件里就只剩下成功路径的代码。
三件套:加载态、错误态、空态
- 空态最常被漏掉,而且漏掉时最难看:接口返回了
[],页面上就是一片纯白,用户分不清是加载失败还是真的没数据。任何列表都要显式处理长度为零的分支,并给出下一步动作(「还没有订单,去逛逛」)。 - 加载态和错误态则应该从组件里消失——交给
<Suspense>和错误边界。理想形态是组件里只有一句const data = use(...),然后直接渲染,没有if (loading)也没有if (error)。 - 三者的骨架要等高。加载骨架 320px、真实内容 200px、错误提示 60px,用户会看到页面连跳三次。
错误边界放在哪一层
| 层级 | 放什么 | 作用 |
|---|---|---|
| 应用根 | 一个最朴素的兜底页 | 最后一道防线,只保证「不白屏」。它的降级 UI 不能依赖任何数据、任何 Context |
| 路由级 | 整页错误页 + 「返回首页」 | 一个页面炸了不影响导航栏和其他路由。Next.js 的 error.tsx 就是这一层(见 13 章) |
| 区块级 | 「这块内容加载失败,重试」 | 最有价值的一层。图表、评论区、推荐位各包一个,一块失败其余照常可用 |
判断标准很简单:问「这块东西挂了,页面其余部分还有意义吗」。有意义,就在这里划一道边界。
为什么白屏是最糟的失败模式
- 信息量为零:用户不知道是网断了、是自己点错了、还是产品坏了,唯一能做的就是关掉页面。区块级降级至少保住了导航和其他内容。
- 不可恢复:白屏时整棵树已被卸载,没有任何按钮可点,只能刷新——而刷新会丢掉用户填了一半的表单。降级 UI 带一个
reset按钮,代价是几行代码,收益是不丢上下文。 - 它是静默的:白屏往往伴随着「没人报错」,因为错误已经把页面连同你的埋点脚本一起带走了。
componentDidCatch里的上报是你唯一的可观测入口,务必接上,并带上componentStack。 - 重试要分层:
resetErrorBoundary只是清空边界的错误态并重渲染子树——如果失败原因是缓存里那份 rejected 的数据没换掉,重试会立刻再次失败。所以onReset里要同时让数据层失效(React Query 的invalidateQueries、清掉自己的 Promise 缓存)。的重试之所以成功,正是因为底层数据源变了。
import { ErrorBoundary } from "react-error-boundary";
function Fallback({ error, resetErrorBoundary }) {
// 降级 UI 要比正常 UI 更朴素:不读数据、不依赖 Context
return (
<div role="alert">
<p>这块内容加载失败</p>
<button onClick={resetErrorBoundary}>重试</button>
</div>
);
}
function Block({ children }) {
// onReset 里只清边界状态不够,缓存里那份失败结果也要一起失效,
// 否则子树会立刻再次抛出同一个错误,看起来就像「重试按钮坏了」
const onReset = () => queryClient.invalidateQueries();
return (
<ErrorBoundary FallbackComponent={Fallback} onReset={onReset}>
<Suspense fallback={<Skeleton height={200} />}>
{children}
</Suspense>
</ErrorBoundary>
);
}
// 每个「可独立失败」的区块各包一层,一块挂了其余照常可用
const Page = () => (
<><Block><Chart /></Block><Block><Comments /></Block></>
);resetErrorBoundary 只清空边界自身的错误态并重渲染子树,如果失败的数据还留在缓存里,子树会立刻再次抛出同一个错误、看起来像按钮坏了。必须在 onReset 里同步让数据源失效。第三个坑是把降级 UI 写得太复杂——fallback 里再读一遍可能为空的数据,它自己抛错时边界接不住,宿主节点会直接渲染成空字符串。<Block> 包装组件,把错误边界、Suspense、等高骨架三件事一次封好,业务代码里只写 <Block><Chart /></Block>。这样健壮性就从「每个人都要记得加」变成了「默认就有」——凡是靠自觉的规范,最后都会漏。测试与调试
测行为不测实现。本章给出工具选型、act() 的真正含义,以及几条高频报错的原文与成因。
React Testing Library(简称 RTL)只有一条核心主张:你的测试越像用户使用软件的方式,它给你的信心就越大。推论是——测试只能通过用户能感知的东西(看得见的文字、能点的按钮、读屏软件能念出来的名称)去找元素和下断言,绝不碰组件内部的 state、props 或 class 名。
为什么按「用户能感知的方式」查询
- 把重构和回归分开。把
useState换成useReducer、把<div class="btn">换成<button>、拆分组件——这些都不改变用户看到的东西,测试就不该红。反过来,一个断言内部 state 的测试会在每次重构时报警,久而久之团队就学会了「测试红了先改测试」,测试也就失去了意义。 - 顺带测到了可访问性(accessibility)。
getByRole("button", { name: "提交" })能通过,说明这个元素在无障碍树里确实是个按钮、确实有可访问名称。用div加onClick冒充按钮的写法会直接查不到——测试帮你把 a11y 兜住了。 - 断言的是契约,不是快照。用户不关心
count这个变量等于 2,只关心屏幕上写着「当前 2」。
查询优先级(从上往下挑第一个能用的)
| 查询 | 用在哪 | 说明 |
|---|---|---|
getByRole | 绝大多数情况 | 按钮、链接、输入框、标题都有 role。配 { name } 精确定位 |
getByLabelText | 表单控件 | 正是用户在表单里找输入框的方式,同时验证了 label 与控件的关联 |
getByPlaceholderText / getByText | 没有 label 的文本、纯展示内容 | 次选 |
getByTestId | 最后手段 | 用户感知不到 data-testid。只在动态内容实在无法定位时用 |
永远不要用 container.querySelector(".btn-primary"):class 名是样式实现细节,换个 CSS 方案测试就全红。
Enzyme 那套为什么被淘汰了
- Enzyme 的招牌能力是浅渲染(shallow rendering)加上
wrapper.state()、wrapper.instance()、wrapper.find(MyComponent).props()——全是直接掀开组件内部。 - 技术上先死的:浅渲染依赖组件实例,而函数组件加 Hooks 根本没有实例可供检查;Enzyme 靠适配器(adapter)钩进 React 内部,而官方适配器停在 React 16——npm 上
enzyme-adapter-react-16是 1.15.8,enzyme-adapter-react-17与-18直接 404、从来没有过;17 和 18 一路靠社区适配器吊命,到 React 19 连社区版都没有了。 - 理念上也先死的:浅渲染只渲染一层、子组件全是占位符,测出来的东西和用户实际看到的页面没有关系——测过了也不代表页面能用。
- 结论很干脆:新项目一律 RTL,不要评估 Enzyme。老项目迁移时把
wrapper.find(...).simulate("click")换成await user.click(screen.getByRole(...)),把断言内部 state 的用例直接删掉重写成断言 DOM。
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
test("点两次按钮,屏幕上应显示当前 2", async () => {
const user = userEvent.setup();
render(<Counter />);
// 按可访问名称找,不按 class 也不按 testid
const btn = screen.getByRole("button", { name: "加一" });
expect(screen.getByLabelText("备注")).toBeInTheDocument();
await user.click(btn);
await user.click(btn);
// 断言用户看到的文字,而不是组件内部的 count 变量
expect(screen.getByText("当前 2")).toBeInTheDocument();
});
// 反面教材:一改样式类名或换个内部实现就全红
// container.querySelector(".btn-primary").click();
// expect(wrapper.state().count).toBe(2);getBy* 查不到会直接抛错,queryBy* 才返回 null。想断言「某元素不存在」必须用 expect(screen.queryByText("…")).toBeNull(),写成 getByText 会在元素真的消失时把测试炸掉,而这恰恰是你期望的情况。另一个高频坑是拿 getBy* 去查异步才出现的内容——渲染那一刻它还不在,必须换成 findBy*(见本章后面讲异步的那一卡)。getByRole,逼到没办法才用 getByTestId。2026 年的 React 测试栈答案很收敛:单元与组件测试用 Vitest + React Testing Library + user-event,端到端用 Playwright。下面每一条都给出为什么,以及什么情况下才该选别的。
Vitest 还是 Jest
| 维度 | Vitest | Jest |
|---|---|---|
| 配置成本 | 几乎为零:直接复用 vite.config 的 alias、插件、环境变量 | 要单独配 babel-jest 或 ts-jest、moduleNameMapper、transformIgnorePatterns |
| ESM | 原生支持 | 长期实验性,遇到纯 ESM 依赖容易卡住 |
| 速度 | 基于 Vite 的转换与 HMR,watch 模式重跑极快 | 较慢,尤其 TypeScript 项目 |
| API | 与 Jest 基本兼容(describe/it/expect/vi.fn) | 事实标准,文章和答案最多 |
| 该选谁 | 新项目一律 Vitest | 只有既有 Jest 配置庞大、迁移不划算时才留着 |
迁移成本低到值得一提:jest.fn() 改成 vi.fn()、jest.mock 改成 vi.mock,大部分用例原样能跑。本页的示例都在 Vitest 4.1.10 + @testing-library/react 16.3.2 + user-event 14.6.1 + React 19.2.8 上实跑通过。
user-event 还是 fireEvent
fireEvent.click(el)就是派发一个 click 事件,仅此而已。真实用户点击按钮会先pointerdown→mousedown→focus→pointerup→mouseup→click,fireEvent一个都不管。- 差别在输入框上最致命:
fireEvent.change(input, { target: { value: "abc" } })一次性把值设成abc,而user.type(input, "abc")会逐字符触发keydown/keypress/input/keyup。任何依赖逐键逻辑的功能(输入长度限制、防抖搜索、格式化)用fireEvent测都是假通过。 user-event还会检查元素是否真的可交互:disabled的按钮点不动、被遮挡的元素点不到——这些正是用户会遇到的。fireEvent照点不误。- 结论:默认一律 user-event,只有要模拟用户造不出来的事件(比如直接派发一个
scroll或自定义事件)时才降级到fireEvent。注意 v14 起所有 API 都是异步的,必须await,而且要先userEvent.setup()。
网络请求怎么假造
- 别 mock
fetch,也别 mock 组件里的取数函数。两者都是实现细节:换成axios、把请求挪进自定义 Hook、改用服务端组件,测试就全废了——这又回到了上一卡说的「测实现」。 - 正确做法是用 MSW(Mock Service Worker)在网络层拦截:按 URL 和方法声明返回什么,组件里用什么请求库、请求发在哪一层都不影响。同一套 handler 还能复用到 Playwright 和本地开发环境。
- 要测错误态和加载态时,MSW 也是唯一顺手的办法:临时覆盖某个 handler 让它返回 500,就能验证错误边界确实降级了(见 11 章)。
单测、组件测试、E2E 的分工
| 层级 | 工具 | 测什么 | 数量 |
|---|---|---|---|
| 纯函数单测 | Vitest | 工具函数、reducer、格式化、自定义 Hook 的纯逻辑部分 | 随手就写,多多益善 |
| 组件测试 | Vitest + RTL + jsdom | 一个组件或一小片界面的交互行为,网络请求用 MSW 拦掉 | 主战场,占比最大 |
| 端到端 | Playwright | 真实浏览器里的关键业务流程:登录、下单、支付 | 贵且慢,只覆盖几条主干路径 |
E2E 里 Playwright 优先于 Cypress:多浏览器(Chromium/Firefox/WebKit)、原生并行、自动等待更稳、codegen 录制、trace viewer 排错。Cypress 的交互式调试体验仍然好,但只在已有大量 Cypress 用例的项目里才值得留。
一个务实的比例:七成组件测试、两成纯函数单测、一成 E2E。不要反过来——E2E 写多了必然变成没人敢信的「随机失败的测试」。
// vitest.config.ts —— 和 vite.config 共用配置,这就是选它的理由
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom", // 组件测试必须有 DOM
globals: true, // 免去到处 import,也让 RTL 自动清理
setupFiles: ["./src/setupTests.ts"],
},
});
// src/setupTests.ts —— 只需要这一行,装上 toBeInTheDocument 等断言
import "@testing-library/jest-dom/vitest";
// 依赖:vitest / @testing-library/react / @testing-library/user-event
// @testing-library/jest-dom / jsdom,全部装在 devDependencies
// user-event 从 v14 起全是异步的,且必须先 setup()
const user = userEvent.setup();
await user.type(input, "abc"); // 逐字符触发 keydown/input/keyup
// fireEvent.change(input, { target: { value: "abc" } }) ← 一步到位,测不出逐键逻辑user-event v14 起所有方法都返回 Promise,忘了 await 是最高发的一处:点击还没走完断言就跑了,测试随机红随机绿。另一处是只装了 @testing-library/jest-dom 却没在 setup 里引入,结果 toBeInTheDocument is not a function;在 Vitest 里通常引 @testing-library/jest-dom/vitest 这个子路径,它只是替你显式 import { expect } from "vitest";若已开 globals: true,直接引主入口一样能注册断言(通过),而没开 globals 时引主入口会当场抛 expect is not defined——不存在「静默失效」这种中间态。第三个坑是用 E2E 覆盖细节分支——每条 E2E 都要跑真实浏览器和真实后端,几十条之后就慢到没人愿意等,然后整套测试被跳过。globals: true 一定要开——除了少写 import,RTL 靠它自动注册每个用例后的 cleanup,否则上一个用例渲染的 DOM 会留到下一个,getByRole 会莫名其妙报「找到多个匹配元素」。网络请求统一用 MSW 在网络层拦,别去 mock fetch 或组件内部的取数函数。act() 划出一个边界:在它返回之前,所有排队的状态更新、重渲染和 effect 都已经跑完并提交到 DOM。它存在的唯一理由是——React 的更新是异步调度的,而你的断言是同步执行的,没有 act 你会在 DOM 还没更新时就去读它。
act 到底在保证什么
- React 把状态更新放进调度队列,effect 也要等到提交阶段之后才执行。测试里如果直接
setState()然后expect(container.innerHTML),读到的多半是旧内容。 act做的事是:执行你传入的回调 → 把队列排干(flush 所有更新与 effect)→ 才返回。所以await act(async () => { ... })之后的断言看到的是稳定状态。- 它只在测试环境生效,靠全局变量
IS_REACT_ACT_ENVIRONMENT开关——RTL 会自动帮你置位。生产代码里不该出现act。
那句著名告警:为什么出现、怎么修
在测试环境里于 act 之外触发一次 setState,React 打出的原文是:
An update to Timer inside a test was not wrapped in act(...).
后面跟着When testing, code that causes React state updates should be wrapped into act(...):和一段示例,以及This ensures that you're testing the behavior the user would see in the browser.- 它在说什么:有一次状态更新发生在 React 的测试边界之外,React 无法保证你断言时 DOM 已经更新——注意它是告警不是错误,测试可能照样通过,然后在别的机器上随机失败。
- 最常见的成因不是你忘了包,而是异步回调迟到了。一个组件在
useEffect里setTimeout(() => setTxt("数据到了"), 10):await act(...)刚结束时 DOM 还是<p>加载中</p>,等待之后才变成<p>数据到了</p>,而那次迟到的setState正好落在 act 之外,于是告警。 - Suspense 也有专属版本,报错原文:
A suspended resource finished loading inside a test, but the event was not wrapped in act(...). - 怎么修:几乎不需要手写
act。用await screen.findByText(...)或await waitFor(...)等到内容出现即可——RTL 内部已经把这些包在act里了。如果告警来自组件卸载后仍在跑的定时器,那是真 bug,请在 effect 的清理函数里取消它。
为什么 findBy* 和 waitFor 比手写 act 更好
| 写法 | 语义 | 问题 |
|---|---|---|
await act(async () => {}) 空转 | 「排干一次队列」 | 你在猜要排几次。请求多一跳就失效 |
await new Promise(r => setTimeout(r, 100)) | 「等 100 毫秒」 | 慢机器上不够、快机器上白等,是不稳定测试的头号来源 |
await screen.findByText("…") | 「等到这个东西出现」 | 无——直接表达意图,默认超时 1 秒内轮询 |
await waitFor(() => expect(...)) | 「等到这个断言成立」 | 适合等「消失」或等非 DOM 的副作用 |
三个用例(findByText 等异步数据、waitFor 等加载态消失、user.click 后断言计数)全部通过,没有出现一次 act 告警,也没有手写一行 act。findBy* 就是 getBy* 加 waitFor 的组合。
顺带一句:StrictMode 对测试的影响
- 开发环境的
<StrictMode>会故意双跑组件渲染和 effect(挂载 → 卸载 → 再挂载),用来暴露没写清理函数的副作用。日志是render 0 | render 0 | render 1 | render 1。 - 对测试的直接影响:断言「某个请求只发了一次」「某个埋点只上报了一次」这类调用次数时,在 StrictMode 下会翻倍而失败。
- 正确的态度是别在测试里关掉 StrictMode 来让用例变绿——次数翻倍恰恰说明那个副作用没有做幂等或没写清理函数,是真 bug。要么修组件,要么把断言从「调用了几次」改成「最终 DOM 是什么样」,后者本来也更符合上一卡的原则。
import { render, screen, waitFor } from "@testing-library/react";
function LateData() {
const [txt, setTxt] = useState(null);
useEffect(() => {
let alive = true;
fetchName().then(n => alive && setTxt(n));
// 没有这行清理,卸载后迟到的 setState 会触发 act 告警
return () => { alive = false; };
}, []);
return txt ? <p>{txt}</p> : <p>加载中…</p>;
}
test("异步数据到达后显示用户名", async () => {
render(<LateData />);
expect(screen.getByText("加载中…")).toBeInTheDocument();
// findBy* = getBy* + waitFor,内部已包在 act 里,无需手写
expect(await screen.findByText("小明")).toBeInTheDocument();
// 等「消失」用 waitFor + queryBy(getBy 查不到会抛错)
await waitFor(() =>
expect(screen.queryByText("加载中…")).toBeNull());
});
// 别这么写:等固定毫秒数是不稳定测试的头号来源
// await new Promise(r => setTimeout(r, 100));await new Promise(r => setTimeout(r, 100)) 代替 waitFor。这会造出经典的不稳定测试(flaky test):本机秒过,CI 上机器一慢就红,然后有人把超时改成 500 毫秒,整套测试越跑越慢。还有一个是在测试里去掉 <StrictMode> 好让「只调用一次」的断言通过——StrictMode 下渲染日志是 render 0 | render 0 | render 1 | render 1,次数翻倍暴露的是副作用没做幂等这个真问题,关掉它只是把 bug 藏进生产环境。not wrapped in act(...) 告警时,先别急着加 act,先问「是哪次更新迟到了」。九成情况下答案是某个异步请求或定时器在断言之后才回来——正确修法是把断言换成 await screen.findBy* 或 await waitFor(...),让测试等到它。剩下一成是组件卸载后仍在 setState,那是真 bug,去 effect 的清理函数里取消。手写 act 基本只在不用 RTL、直接操作 createRoot 时才需要。React 的报错信息质量其实很高——绝大多数报错的第一句话就直接说清了成因。真正的问题是没人逐字读它。下面五条都在 react-dom 19.2.8 里实际触发过,贴的是控制台原文。
React DevTools 怎么用
- Components 面板:选中任一组件,右侧直接显示它当前的 props、state、Hooks 值和所属 Context,还能就地改值观察界面反应——比到处插
console.log快得多。顶部的眼睛图标可以定位到对应 DOM 节点。 - 查「为什么这个组件重渲染了」:在 Components 面板的设置里勾上
Highlight updates when components render,重渲染的组件会闪一圈边框,一眼看出哪片区域在无谓重绘。要精确原因就勾上Record why each component rendered while profiling,之后 Profiler 里每个组件会标明是Props changed (onSelect)、Hook 1 changed还是Parent rendered——括号里就是罪魁祸首的那个 prop 名。 - Profiler 面板:点录制,操作页面,停止。火焰图(flamegraph)里越宽的条耗时越长;排序图(ranked)直接按耗时降序列出。先看有没有本来就不该渲染的组件,再看单个组件慢不慢——绝大多数 React 性能问题是前者。优化手段见 09 章。
- 看到组件名全是
Anonymous,是因为用了匿名箭头函数组件;给函数起个名字,Profiler 立刻可读。
五条高频报错(均为报错原文)
- 1) 条件调用 Hooks
React has detected a change in the order of Hooks called by Order. This will lead to bugs and errors if not fixed.(后面附一张Previous render与Next render的逐行对照表,第 1 行是useState变成了useRef)
如果是 Hook 数量变了,报错换成Rendered fewer hooks than expected. This may be caused by an accidental early return statement.
成因:Hooks 靠调用顺序索引内部链表,把 Hook 写进if、循环或提前return之后,顺序就对不上了。修法是把条件挪到 Hook 内部。 - 2) 受控转非受控
A component is changing a controlled input to be uncontrolled. This is likely caused by the value changing from a defined to undefined, which should not happen.
成因:<input value={x} />里x从有值变成了undefined——通常是异步数据还没回来,或对象上少了那个字段。修法是value={x ?? ""},并给 state 一个非undefined的初值(见 04 章)。 - 3) 列表缺 key
Each child in a list should have a unique "key" prop.
后面还会指出位置,而且分两种:像右侧那样map写在组件里,追加的是Check the render method of `L`.;只有元素数组在模块顶层就建好、组件只负责塞进去时,才是Check the top-level render call using <ul>.
成因:map出来的元素没给key,React 无法在重排时对上号。别拿数组下标当 key(见 03 章)。 - 4) 渲染期 setState
Too many re-renders. React limits the number of renders to prevent an infinite loop.
成因:在组件函数体里直接调了setState,于是渲染触发更新、更新又触发渲染。最隐蔽的变体是onClick={handle()}——少写一层箭头函数,渲染时就把handle执行了。 - 5) 把对象当子节点
Objects are not valid as a React child (found: object with keys {name}). If you meant to render a collection of children, use an array instead.
成因:JSX 里插了一个普通对象。括号里的{name}就是那个对象的键名,照着它去找是哪个变量最快。常见于接口返回结构变了,或漏写了.name。
读报错的通用套路
- 只读第一句。React 的报错第一句就是结论,后面全是解释和链接。
- 抓组件名。报错里几乎总会点名(
called by Order、using <ul>、occurred in the <BoomOnRender> component),直接定位到文件。 - 分清告警和错误。上面 1、2、3 是
console.error打的告警,页面照常跑(Hook 只是类型变了、数量没变时,切换后照样渲染出内容,一次都不抛);只有 1 的数量变体、以及 4、5 会真的抛出并卸载子树。告警不致命但通常预示着数据层的真问题,别长期忽略。 - 生产构建下报错会被压缩成编号,只剩一个
react.dev/errors/…链接。所以线上排错前,务必先在开发构建下复现一次。
// 1) 条件调用 Hooks —— 顺序变了
if (on) { useState(0); } else { useRef(0); }
// → React has detected a change in the order of Hooks called by Order.
// 修法:Hook 永远无条件调用,把条件挪进 Hook 内部
const [n, setN] = useState(0);
// 2) 受控转非受控 —— value 从有值变 undefined
// → A component is changing a controlled input to be uncontrolled.
const bad = <input value={user.name} />;
const ok = <input value={user.name ?? ""} />;
// 3) 列表缺 key
// → Each child in a list should have a unique "key" prop.
const list = items.map(it =>
<li key={it.id}>{it.text}</li>);
// 4) 渲染期 setState —— 少写一层箭头函数就会遇到
// → Too many re-renders. React limits the number of renders…
// <button onClick={handle()}> ← 渲染时就把 handle 执行了
const btn = <button onClick={handle}>提交</button>;
// 5) 把对象当子节点:括号里的键名就是线索
// → Objects are not valid as a React child (found: object with keys {name})
const p = <p>{user.name}</p>; // 而不是 {user}console.error 打出来的告警当成「不影响功能」而长期忽略。A component is changing a controlled input to be uncontrolled. 和 Each child in a list should have a unique "key" prop. 都不会让页面崩溃,但前者意味着输入框在某个时刻脱离了受控、用户的输入可能丢失,后者意味着列表重排时组件状态会错位——都是会在演示当天暴露的真 bug。还有一个是只在生产构建下排错:那时报错已被压缩成一个 react.dev/errors/… 编号链接,上面这些说清成因的原文全都看不到了。memo:打开 React DevTools 设置勾上 Record why each component rendered while profiling,录一段 Profiler,它会直接写明是 Props changed (onSelect) 还是 Parent rendered——括号里就是那个每次渲染都换新引用的 prop。先拿到原因再动手,比按经验撒 useMemo 有效十倍。Next.js 上手与路由
元框架多给了什么、App Router 的约定怎么读、以及 Next 15 起 params 变成 Promise 后那个静默的迁移坑。
React 官方文档把自己定义成「用于构建用户界面的库」,这句话的重点在库:它只负责把状态变成 UI,对「URL 长什么样」「数据从哪来」「第一屏 HTML 谁吐出来」一概不表态。元框架(meta-framework)就是把这三件悬空的事按一套约定钉死的那一层。
纯 React 应用缺的到底是什么
用 Vite 起一个 React 项目,你会依次撞上四堵墙,而它们都不是 React 打算解决的问题:
- 路由:浏览器地址栏变了,React 不知道。你得装 React Router,手写一张路由表,还要自己处理代码分割。
- 首屏:服务器返回的 HTML 里只有一个空的
<div id="root">,内容要等 JS 下载、解析、执行完才出现。搜索引擎和分享卡片抓到的就是那个空 div。 - 数据:取数只能在浏览器里发生,于是变成「加载页面→加载 JS→发请求→再渲染」的瀑布。想在服务端提前取好,没有地方放这段代码。
- 构建:图片优化、字体自托管、按路由分包、静态资源指纹,每一样都要自己配。
Next.js 做的事情,本质上是提供一个同时跑在服务端和客户端的运行时,再用文件系统约定把上面四件事全部接管。
Next 16 的构建长什么样
npx next build,第一行输出是 ▲ Next.js 16.2.11 (Turbopack)——Next 16 的生产构建默认就走 Turbopack,不再需要任何 flag。构建结束会打印一张路由表,每条路由前面的符号说明了它的渲染时机:
| 符号 | 含义 | 什么时候渲染 |
|---|---|---|
○ | Static | 构建时就渲染好,之后每个请求都发同一份 |
ƒ | Dynamic | 每次请求现场渲染 |
这张表是你排查「为什么数据不更新」时第一个该看的东西,本章和 14 章会反复回到它。
Vite + React 还是 Next.js
这不是「新的比旧的好」,两条路各自换来了不同的东西。
- Vite + React(纯客户端单页应用)优心智模型只有一个:所有代码都在浏览器里跑。调试直观,部署就是一堆静态文件,扔到任意 CDN 即可,没有服务器要养。短首屏必然是空的,SEO 与分享预览要额外方案;取数瀑布难避免;路由、分包、图片优化全是自己的活。为何同根在「没有服务端」。没有服务端,所以部署最简单,也所以什么都得等 JS 到位才能开始。
- Next.js(App Router)优首屏直接是内容;取数可以贴着数据库跑;路由、分包、图片、元数据都有默认解法;组件默认不进客户端包,包体积天然更小。短你必须时刻回答「这段代码在哪一侧跑」;缓存层次变多,出问题时要分辨是数据缓存、路由缓存还是构建期定格;部署需要一个 Node 运行时(或适配过的边缘运行时)。为何同根在「引入了服务端」。多出来的那一侧带来了全部能力,也带来了全部复杂度。
什么项目不该上 Next.js
- 登录后才能看的后台:内容本来就不给搜索引擎看,首屏慢半秒无人在意,SSR 的收益接近零,却要付出全部的双端复杂度。
- 嵌进别人页面的挂件、浏览器扩展、Electron 内页:根本没有「服务端」这个概念。
- 纯静态的文档站或博客:Astro、VitePress 这类内容优先的框架默认零 JS,比 Next 更贴题。
- 一个页面的小工具:一个
index.html加一段脚本就够了,别为它引入一整套约定。
判断标准很简单:如果你的页面不需要被陌生人在没登录的情况下打开,就先别考虑元框架。
// ① 纯 React(Vite):服务器发出去的是一个空壳
// index.html 里只有 <div id="root"></div>,内容全靠 JS 补
createRoot(document.getElementById("root")).render(<App />);
// ② Next.js:同样的 UI,服务端先渲染好再流给浏览器
// app/layout.tsx —— 根布局,html 和 body 要自己写
export default function RootLayout({ children }) {
return <html lang="zh"><body>{children}</body></html>;
}
// app/page.tsx —— 对应 URL "/",默认是服务端组件
export default async function Home() {
const posts = await getPosts(); // 直接 await,不需要 useEffect
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}
// $ npx next build —— 输出(Next 16 默认 Turbopack,无需任何 flag)
// ▲ Next.js 16.2.11 (Turbopack)
// Route (app)
// ┌ ○ / ← 构建时定格,每个请求发同一份
// └ ƒ /blog/[slug] ← 每次请求现场渲染app/,然后在构建期被一堆 window is not defined 和 一句「这个 API 只在客户端组件里可用」的构建错误拦住(完整原文见 14 章),根因就是没意识到默认那一侧变了。「文件夹即路由段」不是为了少写一份配置,而是让框架在构建期就能静态地推导出整棵路由树——正因为不用运行任何代码就知道有哪些路由、每条路由套了哪几层布局,Next 才能做预渲染、按路由分包和预取。
app 目录下的角色分工
App Router 只认几个保留文件名,其余文件放在 app/ 里不会产生任何路由:
| 文件 | 作用 | 关键点 |
|---|---|---|
page.tsx | 这一段的页面 UI | 只有它能让一个文件夹变成可访问的 URL |
layout.tsx | 包住本段及其所有子段的共享外壳 | 导航切换时不重新挂载,状态保留 |
route.ts | 接口处理函数 | 和 page.tsx 不能同时存在于同一段 |
loading.tsxerror.tsxnot-found.tsx | 加载态、错误边界、404 | 见本章后面的「特殊文件」卡 |
根布局(app/layout.tsx)是唯一必需的文件,而且它必须亲手渲染 <html> 和 <body>——Next 不会替你生成外层文档结构。
布局嵌套的真实渲染结构
布局是累加的,不是覆盖的。路径上每一段的 layout.tsx 会由外向内层层包住页面。在 app/t13/layout.tsx 里放一个 <section><nav>,访问 /t13/inside 时返回的 HTML 结构是:
html▸body(来自根布局)▸section▸nav(来自 t13 段布局)▸ 页面内容
这个「由外向内」的顺序有一个很实用的推论:布局在客户端导航时不会重新挂载。侧边栏里展开的折叠项、滚动位置、播放中的音频,在同一布局下的页面之间跳转时都会原样保留——这是 App Router 相比传统多页应用最直观的体验优势。
怎么放不产生路由的文件
组件、工具函数、样式当然可以直接放在 app/ 里,只要那个文件夹没有 page.tsx 就不会变成 URL。但更明确的做法是用私有文件夹:以下划线开头的目录名会被整个排除在路由之外。
这一条我踩过一次的坑:一开始把测试路由建在 app/_t13/ 下,next build 一切正常、零报错,但路由表里一条 t13 的路由都没有。改名成 app/t13/ 后立刻全部出现。下划线前缀是静默生效的,不会有任何提示。
默认那一侧变了
app/ 下的组件默认是服务端组件(Server Component)。这意味着 useState、onClick、window、localStorage 默认都用不了,要用得先在文件顶部写 'use client'。这是从 Vite 项目迁移过来时撞得最多的一堵墙,也是 14 章要整章展开的主题。
// app/
// layout.tsx → 根布局,必须自己写 html/body(唯一必需文件)
// page.tsx → /
// blog/layout.tsx → 只包住 /blog 及其子路由
// blog/page.tsx → /blog
// blog/[slug]/page.tsx → /blog/hello
// _lib/format.ts → 下划线开头=私有文件夹,不产出任何路由
// app/layout.tsx
export default function RootLayout({ children }) {
return (
<html lang="zh">
<body>{children}</body>
</html>
);
}
// app/blog/layout.tsx —— 嵌套生效,不会替换根布局
export default function BlogLayout({ children }) {
// 导航到别的 /blog/** 页面时,这个 nav 不会重新挂载,状态保留
return <section><nav>博客导航</nav>{children}</section>;
}
// 访问 /blog/hello,HTML 由外向内是:
// html ▸ body ▸ section ▸ nav + 页面内容page.tsx 能让文件夹变成 URL。新建了 app/about/index.tsx 或 app/about/About.tsx 然后访问 /about 得到 404,是最常见的第一个坑——App Router 不认 index 这个名字。另外以下划线开头的目录(如 app/_admin/)会被静默排除出路由,构建零报错但路由表里什么都没有,命名时留意。page.tsx,把「跨页面不该重来的」放进 layout.tsx。判据不是「它长得像不像框架」,而是用户在这个布局覆盖的几个页面之间跳转时,这块 UI 的状态该不该保留。侧边栏展开状态、播放器、筛选面板放布局里能白拿状态保持;反之放页面里。动态段把 URL 的一部分变成参数交给页面。而 Next 15 起 params 和 searchParams 都变成了 Promise——这个改动真正棘手的地方不在于要多写一个 await,而在于忘了写完全不会报错。
四种目录命名
下面每一行的结果都是跑出来的:
| 目录名 | 匹配 | await params 的结果 |
|---|---|---|
[id] | /p/abc | { id: "abc" },只吃一段 |
[...slug] | /docs/a/b/c | { slug: ["a","b","c"] },但不匹配 /docs 本身 |
[[...slug]] | /docs 和 /docs/a/b | 访问 /docs 时 slug 是 undefined,不是空数组 |
(group) | —— | 圆括号目录名不进 URL,app/(grp)/inside/page.tsx 的路由是 /inside |
路由组的用处是在不改 URL 的前提下多套一层布局:把营销页放进 (marketing)/、把后台放进 (app)/,两组各有自己的 layout.tsx,而用户看到的 URL 干干净净。
异步参数:一个完全静默的坑
Next 15 起,params、searchParams 以及 cookies()、headers()、draftMode() 全部改成了异步。原因是这些值都属于「请求相关信息」,把它们变成 Promise,框架就能在真正需要之前先把页面的静态部分渲染出去。
问题是漏掉 await 时的表现。我在实验台里故意写了 const id = (params as any).id,结果是:TypeScript 通过、next build 通过、生产模式访问页面正常返回 200,页面上老老实实渲染出 undefined,服务端日志里一条告警都没有。
换句话说,这个 bug 不会以「报错」的形式出现,只会以「数据莫名其妙是空的」出现。迁移旧项目时,它是最费时间的一类问题。
让动态路由重新变回静态
带动态段的路由默认是 ƒ Dynamic——每次请求都要现场渲染。导出 generateStaticParams 列出已知的参数值,Next 就会在构建期把这些页面全部预渲染成 ○ Static。博客、文档、商品详情这类「内容有限且更新不频繁」的场景,这一步能把每请求一次的服务端渲染直接省成一个静态文件。
没被列进去的参数值,默认仍然会在首次访问时按需渲染并缓存下来。
// app/blog/[slug]/page.tsx → /blog/hello
export default async function Post({ params, searchParams }) {
const { slug } = await params; // Next 15+ 起是 Promise
const { page } = await searchParams; // searchParams 同理
const post = await getPost(slug);
return <h1>{post.title}(第 {page ?? 1} 页)</h1>;
}
// 构建期预生成,把 ƒ Dynamic 变成 ○ Static
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map(p => ({ slug: p.slug })); // 键名要和 [slug] 对上
}
// ── 对照 ────────────────────────────────
// app/docs/[...slug]/page.tsx 访问 /docs/a/b/c
// → slug 是 ["a", "b", "c"];访问 /docs 则 404
// app/docs/[[...slug]]/page.tsx 访问 /docs
// → slug 是 undefined(不是 []),记得给默认值
// app/(marketing)/about/page.tsx
// → 路由是 /about,圆括号那层不进 URLawait params 是完全静默的。把类型断言成 any 后直接读 params.id:TypeScript 编译通过、next build 通过、生产模式返回 200,页面上渲染出 undefined,服务端一条告警都没有。所以「动态路由拿到的值是空的」时,先别怀疑数据源,回去数一遍 await。await 写进肌肉记忆:在 App Router 里,凡是「和这次请求有关」的东西都是异步的——params、searchParams、cookies()、headers()、draftMode()。给页面参数写上 params: Promise<{ slug: string }> 类型,TypeScript 就会在你直接点属性时拦住你,这是唯一能自动发现这个错的手段。App Router 的导航 API 全部住在 next/navigation。老教程里的 next/router 是 Pages Router 的遗产,在 App Router 里调用它会在构建期直接把构建炸掉——这是从旧资料迁移时最高频的一个错误。
该用哪一个
| 要做的事 | 用什么 | 在哪一侧 |
|---|---|---|
| 用户点击跳转 | <Link href> | 服务端组件里就能用 |
| 代码里主动跳转 | useRouter().push/replace | 只能在客户端组件 |
| 服务端条件跳转(如未登录) | redirect() / permanentRedirect() | 服务端组件、Server Action |
| 读当前路径 / 查询串 | usePathname() / useSearchParams() | 只能在客户端组件 |
| 重新拉取当前路由的服务端数据 | router.refresh() | 客户端组件 |
它们全部从 next/navigation 导入,只有 Link 来自 next/link。
Link 比 a 标签多做了什么
- 预取:生产环境下,
<Link>进入视口时 Next 会提前把目标路由的数据拉回来。等用户真的点击,切换几乎是瞬时的。 - 客户端导航:不走整页刷新,因此共享的布局不会重新挂载,React 状态、滚动位置、正在播放的媒体都保住了。
- 只换该换的部分:框架知道新旧路由共享哪几层布局,只请求并替换差异的那一段。
反过来说,用原生 <a href> 做站内跳转会触发整页刷新,把上面三条全部作废。站内一律 <Link>,站外才用 <a>。
redirect 是靠抛异常实现的
redirect("/login") 内部会抛出一个特殊异常,由框架捕获后转成跳转响应。两个直接后果:它后面的代码不会执行(所以不用写 return redirect(...)),以及绝对不要把它包在 try/catch 里——你的 catch 会把这个控制流异常吞掉,跳转就失效了,而且排查起来毫无线索。
useSearchParams 必须包在 Suspense 里
查询串只有在真实请求到达时才知道,所以用了 useSearchParams() 的客户端组件没法参与构建期预渲染。不加 <Suspense> 时 next build 直接失败,原文是:useSearchParams() should be wrapped in a suspense boundary at page "/t13/sp"。包一层 <Suspense> 后,页面外壳照常静态预渲染,只有读查询串的那一小块留到客户端补齐。
// app/nav.tsx —— 服务端组件里就能用 Link,无需任何客户端 JS
import Link from "next/link";
export function Nav({ slug }) {
// 自动预取 + 客户端导航,共享布局不会重新挂载
return (
<nav>
<Link href="/about">关于</Link>
<Link href={`/blog/${slug}`}>文章</Link>
</nav>
);
}
// ── 编程式导航:只能在客户端组件里 ──────────────
"use client";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
export function SearchBox() {
const router = useRouter();
const path = usePathname(); // "/blog"
const sp = useSearchParams(); // 用它就必须被 <Suspense> 包住
return <input defaultValue={sp.get("q") ?? ""}
onChange={e => router.replace(`${path}?q=${e.target.value}`)} />;
}
// ── 服务端跳转:靠抛异常实现,后面的代码不会执行 ──
import { redirect } from "next/navigation";
if (!user) redirect("/login"); // 千万别包在 try/catch 里next/router 导入 useRouter 会直接炸掉构建。在 App Router 的客户端组件里这么写,next build 报 Error: NextRouter was not mounted.——那是 Pages Router 的 Hook,App Router 从没挂载过它的 context。App Router 的一切都从 next/navigation 来。另外 router.push 的返回值不是 Promise,别 await 它然后指望跳转已经完成。redirect(),必须等用户动作的跳转才用 useRouter()。前者不需要任何客户端 JS,后者会把整个组件推进客户端 bundle。表单提交后的跳转优先放进 Server Action 里用 redirect(),比在 onSubmit 里 router.push 更省包体积(见 15 章)。这些保留文件名不是命名规范,而是一种声明方式:你把组件放进去,框架就自动把它塞进预留好的 <Suspense> 或错误边界插槽里。你不写 <Suspense>,但确实用上了它。
五个文件各管什么
| 文件 | 框架替你做的事 | 要点 |
|---|---|---|
loading.tsx | 自动用 <Suspense fallback> 包住同级 page.tsx | 页面里 await 慢数据时先显示它 |
error.tsx | 给本段套一个 React 错误边界 | 必须 'use client',收到 error 和 reset |
not-found.tsx | 接住 notFound() 抛出的信号 | 仍渲染在本段布局内部 |
template.tsx | 和 layout 同位,但每次导航都重新挂载 | 嵌套顺序是 layout 在外、template 在内 |
global-error.tsx | 兜住根布局自身抛的错 | 它要自己渲染 <html> 和 <body> |
loading.tsx 换来的是流式响应
它的价值不只是「显示一个转圈」。一个故意 sleep 1.5 秒的页面:加上 loading.tsx 后,服务器立刻发出包含加载占位符的 HTML,1.5 秒后再把真实内容追加到同一个响应流里。用 curl 抓完整响应可以同时看到占位符和最终内容——这就是流式服务端渲染,用户不用盯着白屏等。
layout 与 template 的差别
两者位置一样、写法一样,区别只有一条:导航到同一布局下的另一个页面时,layout 的实例保持不变,template 会被销毁重建。所以 template 里的 useState 会重置、useEffect 会重跑、进场动画会重新播放。
同时放 layout.tsx 和 template.tsx,渲染结构是 layout 在外、template 在内。绝大多数时候你要的是 layout;只有「每次进页面都要重播的动画」「每次进页面都要重新上报的埋点」这类需求才用 template。
生产环境的 error.tsx 拿不到真实错误信息
这条一定要知道,否则线上排查会一头雾水。让服务端组件抛出 new Error("故意炸的"),然后 curl 生产构建的页面:
- 返回的 HTML 里搜不到「故意炸的」这五个字,一次都没有——错误信息不会泄漏给浏览器。
- 客户端只拿到一个
digest(是'1308777229'),服务端日志里同一个 digest 旁边才是真实的错误堆栈。 - 更意外的是首屏 HTML:它是一个
<html id="__next_error__">的空壳,你写的 error.tsx 内容并不在初始 HTML 里,要等客户端 JS 加载完才渲染出来。
所以 error.tsx 里请把 error.digest 显示给用户(「错误编号 XXX」),它是把用户反馈和服务端日志对上的唯一线索。
// app/dashboard/loading.tsx —— 框架自动用 <Suspense> 包住同级 page
export default function Loading() {
return <p>加载中…</p>; // HTML 立刻发出,慢内容随后追加进同一响应流
}
// app/dashboard/error.tsx —— 错误边界只能在客户端,必须加指令
"use client";
export default function Error({ error, reset }) {
// 生产环境 error.message 被抹掉,只有 digest 能和服务端日志对上
return (
<div>
<p>出错了,错误编号 {error.digest}</p>
<button onClick={reset}>重试</button>
</div>
);
}
// app/blog/[slug]/page.tsx —— notFound() 同样是抛出,交给最近的 not-found.tsx
import { notFound } from "next/navigation";
const post = await getPost(slug);
if (!post) notFound(); // 404 页仍渲染在本段布局内部
// app/dashboard/template.tsx —— 每次导航重新挂载(layout 在外,它在内)
export default function Template({ children }) {
return <div className="fade-in">{children}</div>; // 动画每次重播
}error.tsx 接不住同级 layout.tsx 抛出的错误——错误边界在布局内侧,管不到把自己包起来的那一层。布局里的错误会往上冒到父段的 error.tsx,而根布局的错误只有 global-error.tsx 能接(它必须自己渲染 <html> 和 <body>,因为出错的正是提供这两个标签的那个文件)。error.tsx 里一定要把 error.digest 显示出来,比如「出错了,错误编号 1308777229」。生产环境下客户端拿不到真实错误信息,这个 digest 是用户截图反馈和你服务端日志之间唯一的桥。同理,loading.tsx 请画成和真实内容同尺寸的骨架屏而不是居中转圈,否则内容到位时会跳一下版。元数据是从服务端组件树上「收集」上来的,不是在浏览器里补写进 <head> 的。正因为它和页面在同一次服务端渲染里产生,它才能天然用上路由参数和刚取到的数据——这恰恰是客户端单页应用补 SEO 时最难做对的一件事。
两种写法,一个判据
- 静态
export const metadata:值在构建期就定了,不依赖任何请求信息。适合根布局的站点名、固定页面的标题。 - 异步
generateMetadata:需要params或需要取数时用它。它拿到的参数和页面组件完全一样(同样是 Promise,同样要await)。
判据只有一条:标题里有没有一个值是这个请求才知道的。有就用 generateMetadata,没有就用静态导出。
关键的一点是:generateMetadata 里对同一个数据源的取数和页面里的取数会自动去重,只真的请求一次。所以放心在两处都写 await getPost(slug),不用为了省一次请求把数据硬塞进某个全局变量。
元数据是层层合并的
根布局定义 title.template(如 "%s · 我的站"),子页面只写自己的 title,框架会自动套上模板。访问一个用 generateMetadata 返回 { title: "文章 9" } 的页面,HTML 里拿到的是 <title>文章 9</title>,同时自动生成了对应的 og:title 和 og:description。
合并规则是逐字段浅覆盖:子级写了某个字段就整个替换掉父级的同名字段,没写的沿用父级。
三个文件约定
下面三个都是我在实验台里跑通并 curl 验证过的:
| 文件 | 产出 | 结果 |
|---|---|---|
opengraph-image.tsx | 动态社交分享图 | 返回 200 image/png,21808 字节;og:image 及宽高 meta 全部自动注入 |
sitemap.ts | /sitemap.xml | 返回标准 <urlset> XML,含 loc / lastmod / priority |
robots.ts | /robots.txt | 返回纯文本,含 User-Agent / Allow / Disallow / Sitemap |
opengraph-image.tsx 值得特别说一句:它用 next/og 的 ImageResponse,让你用 JSX 和一个 CSS 子集画图,服务端渲染成 PNG。放在 [slug] 目录下时它同样能 await params,于是每篇文章都能有一张带自己标题的分享图,而你一行 Canvas 代码都不用写。
// app/layout.tsx —— 静态元数据,构建期就定下来
export const metadata = {
title: { default: "我的站", template: "%s · 我的站" }, // 子页自动套模板
description: "一个示例站点",
};
// app/blog/[slug]/page.tsx —— 依赖路由参数时用 generateMetadata
export async function generateMetadata({ params }) {
const { slug } = await params; // 这里同样是 Promise
const post = await getPost(slug); // 和页面里的同一次取数会自动去重
return { title: post.title, description: post.excerpt };
}
// app/blog/[slug]/opengraph-image.tsx —— 用 JSX 画分享图
import { ImageResponse } from "next/og";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default async function OG({ params }) {
const { slug } = await params;
// 返回 200 image/png,og:image 与宽高 meta 由框架自动注入
return new ImageResponse(<div style={{ fontSize: 90 }}>{slug}</div>, size);
}
// app/sitemap.ts → /sitemap.xml(app/robots.ts → /robots.txt 同理)
export default function sitemap() {
return [{ url: "https://ex.com/", lastModified: new Date(), priority: 1 }];
}metadata 和 generateMetadata 只在服务端组件里有效。在标了 'use client' 的文件里导出 metadata 会直接构建失败——这条反而是本章少数几个有明确报错的地方,原文是 You are attempting to export "metadata" from a component marked with "use client", which is disallowed. "metadata" must be resolved on the server before the page component is rendered.,照着它把页面拆成服务端外壳 + 客户端子组件即可。同理,别在客户端组件里用 document.title = ... 补救——那时 HTML 早就发给爬虫了,改的只是浏览器里看到的那一份。metadataBase(比如 new URL("https://你的域名")),之后所有 og:image、canonical 都能写相对路径,框架自动补成绝对 URL。不配它,社交平台抓到的图片地址会是相对路径而抓取失败,本地开发时完全看不出问题——发上线前用真实域名验一遍分享卡片。服务端与客户端组件
'use client' 标记的是一条边界而不是渲染位置。这是 Next.js 部分最难也最重要的一章。
服务端组件(Server Component)在服务器上跑完之后,产出的不是 HTML 字符串,而是一段可序列化的 UI 描述。浏览器里的 React 拿到这段描述,把它当成自己渲染出来的结果接着往下用。这一句话就是 RSC 和传统服务端渲染的全部差别所在。
先看传统 SSR 做了什么
传统 SSR(包括 Next 的 Pages Router)的流程是:服务器把整棵组件树渲染成 HTML 字符串发给浏览器;浏览器显示出来,同时下载同一棵树的全部组件代码;React 在浏览器里再跑一遍这些组件,把事件绑定接上去,这一步叫水合(hydration)。
注意其中的重复:每个组件的代码都被下载了两遍的量——一遍以 HTML 的形式,一遍以 JS 的形式。一个纯展示的 Markdown 渲染器、一个日期格式化库、一整套语法高亮规则,明明在服务端已经用完了,还是得原样打进客户端 bundle,只因为水合需要它们。
RSC 改了什么
服务端组件的渲染结果被序列化成一种叫 RSC Payload 的格式,大致是一棵「元素类型 + props + 子节点」的树。其中:
- 纯服务端的部分已经被求值完了,Payload 里躺着的是结果(一段文本、一个
li列表),而不是产生它的代码。 - 客户端组件的位置留着一个引用:「这里放编号 39756 那个组件,props 是这些」。浏览器按引用去加载对应的 chunk。
结果就是服务端组件的代码根本不需要下载到浏览器。一个用 node:fs 读文件的服务端组件,构建后在 .next/static/chunks/(客户端产物)里搜它的字符串是 0 处,在 .next/server/ 里是 6 处。它从来没去过浏览器。
为什么必须是「描述」而不是 HTML
如果服务端只发 HTML,那客户端 React 就无法把它和自己的虚拟树对上,也就没法在不刷新整页的情况下更新某一部分。而 Payload 是 React 认识的结构,于是可以做到:客户端导航时只请求新路由的 Payload,React 把它合并进当前树,共享的布局连同其中的状态原地不动。
| 传统 SSR | 服务端组件 | |
|---|---|---|
| 服务端产出 | HTML 字符串 | 可序列化的 UI 描述(RSC Payload) |
| 组件代码进 bundle | 全部要进(水合需要) | 不进 |
| 执行次数 | 服务端一次,客户端再一次 | 只在服务端执行一次 |
| 能否直接查库 | 只能在 getServerSideProps 这类特定函数里 | 任意组件内部都可以 |
| 更新粒度 | 整页 | 可以只换某一段 |
顺带说清一个常见混淆:SSR 并没有被取代。服务端组件负责「哪些代码不用给浏览器」,SSR 负责「首屏 HTML 从哪来」。Next 两件事同时做——服务端组件先渲染出 Payload,再由 SSR 把它连同客户端组件一起转成首屏 HTML。
// app/page.tsx —— 没有任何指令,默认就是服务端组件
import { readFile } from "node:fs/promises";
import db from "@/lib/db";
import { marked } from "marked"; // 几十 KB,但一个字节都不会进浏览器
export default async function Page() {
// 组件本身可以是 async —— 服务端组件不会重跑,所以没有闭包快照问题
const md = await readFile("./content/home.md", "utf8");
const users = await db.user.findMany(); // 直接查库,凭据不外泄
return (
<main>
<article dangerouslySetInnerHTML={{ __html: marked(md) }} />
<ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>
</main>
);
}
// 服务端渲染的产物不是 HTML 字符串,而是一段可序列化的 UI 描述。
// next build 后搜这个组件产生的字符串:
// .next/static/chunks/ (浏览器要下载的)→ 0 处
// .next/server/ (只在服务器上) → 6 处
// 它从头到尾没去过浏览器。next build,然后在 .next/static/chunks/ 里全文搜这个组件里的一个独特字符串。搜得到就是进了客户端 bundle,搜不到就是纯服务端。这个笨办法比读任何文档都可靠,也是排查「包怎么这么大」最快的手段。'use client' 的意思不是「这个组件在浏览器里渲染」,而是「从这个模块开始,模块依赖图往下的一切都划给客户端」。它标记的是一条边界,不是一个组件——这个区别是 RSC 里最容易理解错、也最容易在包体积上付出代价的地方。
边界是按模块图算的,不是按组件树
很多人以为「我只在这个组件上写了指令,所以只有它是客户端组件」。实际规则是:一个标了 'use client' 的文件,它 import 的每一个模块都会被一起打进客户端 bundle,无论那些模块自己有没有写指令、也无论它们看起来多像服务端组件。
我在实验台里验证了这一点:写一个没有任何指令的 Plain.tsx(看起来完全是服务端组件),然后在一个 'use client' 的组件里 import 它。结果 next build 完全正常,零报错零告警,但在 .next/static/chunks/ 里能搜到 Plain 的源码——它被静默地变成了客户端组件。
反过来,同一个组件如果是通过 children 传进去的(下一张卡讲这个手法),在客户端产物里是 0 处。决定归属的是 import,不是 JSX 里的嵌套关系。
什么时候会撞上明确的报错
只有当被牵连的模块用了浏览器里根本不存在的东西,才会炸出来。在客户端组件里 import 一个用了 node:fs 的组件,Turbopack 直接失败:the chunking context (unknown) does not support external modules (request: node:fs)。
换句话说,报错是幸运的情况——它意味着有个 Node API 挡在那里替你把关。真正危险的是那些「纯 JS、没用任何 Node API」的模块:一个几十 KB 的日期库、一份完整的国家列表、一套 Markdown 解析规则,它们会一声不吭地进浏览器,只在你哪天看构建产物体积时才被发现。
永远不要把 'use client' 写在根布局
根布局 import 了整个应用的入口,一旦给它加上指令,整棵树全部变成客户端组件,RSC 的全部好处(代码不进 bundle、直接查库、服务端取数)一次性归零,你得到的就是一个套着 Next 外壳的普通单页应用。
正确做法是把边界往叶子方向推:不要标记「有交互的那个页面」,只标记「真正需要 useState 的那个按钮」。多写几个 'use client' 文件不是坏事,边界位置越靠下,留在服务端的代码越多。
一些容易忽略的细节
- 指令必须在文件最顶部,任何
import之前。写在函数里或者第二行都无效。 - 不需要在每个客户端组件文件里都写。边界只需要标在入口处,边界内部的模块自动继承。
- 客户端组件依然会在服务端预渲染一次以产生首屏 HTML。所以直接在组件体里读
window仍然会炸——那段代码得放进useEffect。 - 第三方库如果没有标
'use client'却用了 hooks,你需要自己写一个薄薄的包装文件加上指令再import。
// app/ui/Panel.tsx
"use client"; // 必须在最顶部,import 之前
import { useState } from "react";
import { formatDate } from "./date"; // 跟着一起进客户端 bundle
import COUNTRIES from "./countries"; // 哪怕只用一个字段,整个模块也进去
export default function Panel({ children }) {
const [open, setOpen] = useState(true);
return <div onClick={() => setOpen(!open)}>{open && children}</div>;
}
// app/ui/Plain.tsx —— 没写 'use client',看起来像服务端组件
export default function Plain() {
return <p>{renderHugeMarkdownRules()}</p>;
}
// ── 对照:同一个 Plain,两种用法,归属完全不同 ──────
// ① 在 Panel.tsx 里 import Plain
// → 构建零报错零告警,但 Plain 的源码出现在 1 个 static/chunks 文件里
// → 它被静默地变成了客户端组件
// ② 在服务端组件里写 <Panel><Plain /></Panel>
// → Plain 在 static/chunks 里是 0 处,纯服务端
// 决定归属的是 import,不是 JSX 里的嵌套关系。const w = window.innerWidth 照样会炸 ReferenceError: window is not defined——'use client' 只是说「这个组件也要送到浏览器」,不是「这个组件只在浏览器跑」。所有访问 window、document、localStorage 的代码都必须放进 useEffect 里,因为 effect 只在客户端执行(见 05 章)。'use client' 当成「从这里开始要付流量费」的标记,然后一个问题问到底:这个文件顶上的 import 列表,我愿意让用户下载吗?愿意就留着,不愿意就把那个依赖挪到边界外面、通过 props 或 children 送进来。边界画得越靠近叶子,留在服务端的代码越多。两种组件的能力差异不是框架随手划的规矩,全部能追到一个原因:服务端组件只执行一次且执行完就没了,客户端组件会随状态反复重跑。凡是需要「跨多次渲染记住点什么」的能力,服务端组件天然都没有。
能力对照
| 能力 | 服务端组件 | 客户端组件 | 为什么 |
|---|---|---|---|
组件可以是 async | 可以 | 不可以 | 客户端组件要重跑,异步函数没法在重跑时接上原来的进度 |
useState / useReducer | 不可以 | 可以 | 状态是「跨多次渲染保留的东西」,服务端只渲染一次 |
useEffect / useRef | 不可以 | 可以 | 没有真实 DOM,也没有「渲染之后」这个时刻 |
onClick 等事件 | 不可以 | 可以 | 函数没法序列化着发给浏览器 |
window / localStorage | 不可以 | 可以(限 useEffect 内) | 服务端没有这些全局对象 |
| 直接查数据库 / 读文件 | 可以 | 不可以 | 浏览器没有 node:fs,也不该拿到数据库凭据 |
读 process.env 里的密钥 | 可以 | bundle 里只有 NEXT_PUBLIC_ 的会被替换成字面量;但它 SSR 那一遍在服务端跑,密钥会被渲染进 HTML,详见 16 章 | 其余的会在打包时被抹掉 |
用 Context | 不可以 | 可以 | Context 依赖渲染树上的运行时状态 |
违规时的报错
在服务端组件里 import { useState },next build 会在编译阶段直接失败,原文是:You're importing a module that depends on `useState` into a React Server Component module. This API is only available in Client Components. To fix, mark the file (or its parent) with the "use client" directive.
这个报错指向的是 import 那一行,不是使用那一行——再次印证了边界是按模块图算的。
怎么选:一条决策链
默认全部留在服务端,只在撞到下面这些情况时才往下切一刀:
- 需要
useState/useReducer记住用户操作的中间状态。 - 需要绑事件:
onClick、onChange、onScroll。 - 需要浏览器专属 API:
localStorage、IntersectionObserver、matchMedia。 - 需要用一个本身就是客户端组件的第三方库(大部分动画库、图表库、富文本编辑器)。
并且切的时候只切最小的那一块。一个「带筛选框的列表页」的正确拆法是:页面留在服务端负责查库,把筛选框单独做成客户端组件,列表数据以 props 形式传进去。而不是给整个页面加指令。
// ❌ 服务端组件里 import hook —— 构建期就失败,报错指向 import 那一行
import { useState } from "react";
// You're importing a module that depends on `useState` into a React Server
// Component module. This API is only available in Client Components.
// ❌ 客户端组件不能是 async
"use client";
export default async function Bad() { return <p>不行</p>; }
// ✅ 正确拆法:服务端管取数,客户端只管那一小块交互
// app/posts/page.tsx(服务端,默认)
import Filter from "./Filter";
export default async function Page() {
const posts = await db.post.findMany(); // 只有服务端能做
return (
<main>
<Filter posts={posts} /> // 传纯数据,不传函数
</main>
);
}
// app/posts/Filter.tsx(客户端,边界只包住输入框这一小块)
"use client";
import { useState } from "react";
export default function Filter({ posts }) {
const [q, setQ] = useState(""); // 只有客户端能做
const hit = posts.filter(p => p.title.includes(q));
return <input value={q} onChange={e => setQ(e.target.value)} />;
}'use client'——整棵树瞬间全变客户端组件,包体积翻几倍,而且构建不会有任何提示。正确做法是把 Provider 单独抽成一个客户端组件文件,在根布局里用它包住 {children}:Provider 是客户端的,children 仍然是服务端渲染的(原理见下一张卡)。客户端组件不能 import 服务端组件,却可以接收它。原因只有一句话:import 决定的是打包归属,而 children 拿到的是已经在服务端渲染好的结果——一个洞,不是一段代码。理解这一条,你才真正会写 RSC 应用。
为什么 import 不行
打包器在处理一个标了 'use client' 的文件时,会沿着它的 import 把整条依赖链拉进客户端 chunk。服务端组件被这样拉进去之后,它就不再是服务端组件了——它变成了一个普通的客户端组件,里面的 await db.query() 会在浏览器里执行,当然行不通。
证据在上一张卡:客户端组件 import 一个用 node:fs 的组件,Turbopack 报 the chunking context (unknown) does not support external modules (request: node:fs)。而如果那个组件恰好没用 Node API,就会一声不响地被打进客户端。
为什么 children 就行
渲染顺序是关键:服务端组件先渲染。当 Next 渲染 <Panel><Heavy /></Panel> 时,Heavy 在服务端就被求值成了一段 UI 描述,然后作为 children 这个 prop 的值放进 Payload 里。Panel 到了浏览器只收到「一坨已经渲染好的东西」,它可以决定显示或隐藏、包在什么容器里,但它永远不需要知道那坨东西是怎么来的,也就不需要那段代码。
把 Heavy(读 package.json)通过 children 传进客户端的 Panel:构建成功,页面正常显示文件长度,而 Heavy 的字符串在客户端 chunk 里是 0 处。
这个手法叫「插槽模式」,它是 RSC 里最重要的一个结构性技巧。所有形如「客户端负责壳、服务端负责内容」的需求都靠它:标签页、折叠面板、模态框、主题 Provider、拖拽容器。
跨边界的 props 必须可序列化
服务端传给客户端组件的 props 要被序列化进 Payload,所以只能是能序列化的东西:字符串、数字、布尔、null、数组、普通对象、Date、Map、Set,以及 JSX 元素(children 能工作正是因为这一条)。
不能传的是函数和 class 实例。传一个 onPick={() => ...},构建直接失败:
Error: Event handlers cannot be passed to Client Component props.{onPick: function onPick}If you need interactivity, consider converting part of this to a Client Component.
唯一的例外是 Server Action——它是特殊标记过的函数,可以跨边界传,因为传过去的其实是一个调用凭证而不是函数体(见 15 章)。
// app/ui/Panel.tsx —— 客户端组件,只知道 children 是「一个已经渲染好的洞」
"use client";
import { useState } from "react";
export default function Panel({ children }) {
const [open, setOpen] = useState(true);
return <div>
<button onClick={() => setOpen(o => !o)}>切换</button>
{open ? children : null}
</div>;
}
// app/ui/Heavy.tsx —— 服务端组件,用了浏览器里根本没有的 API
import { readFileSync } from "node:fs";
export default function Heavy() {
return <p>{readFileSync("package.json", "utf8").length}</p>;
}
// app/page.tsx —— 服务端组件负责把渲染好的洞塞进去
import Panel from "./ui/Panel";
import Heavy from "./ui/Heavy";
export default function Page() {
// 构建通过,页面正常,Heavy 在客户端 chunk 里 0 处
return <Panel><Heavy /></Panel>;
}
// 反例:改成在 Panel.tsx 里 import Heavy,构建立刻失败
// the chunking context does not support external modules (request: node:fs)
// 反例:传函数过边界,构建也立刻失败
// Error: Event handlers cannot be passed to Client Component props.Event handlers cannot be passed to Client Component props.,并且会把出问题的 prop 名字用 ^^^ 标出来。这个错在从普通 React 迁移时几乎必踩——习惯性写 <Button onClick={handle} />,而 Page 是服务端组件。解法不是给页面加 'use client',而是把那个回调挪进客户端组件内部定义,或者改用 Server Action。children,不准出现「要去取什么」的逻辑。需要展示服务端数据的地方,一律做成 children 或者具名的 JSX prop(如 <Panel header={<ServerHeader />} />)——具名 prop 和 children 一样能跨边界传 JSX,很多人不知道这一点。服务端组件里取数就是一句 await,没有 useEffect、没有加载态、没有竞态。但缓存这一层有个几乎人人误解的地方:「Next 15 起 fetch 默认不缓存」是对的,「所以每次请求都拿新数据」是错的。
先把误解拆开
这里有两层缓存,常被混为一谈:
- 数据缓存:
fetch的结果要不要存起来跨请求复用。Next 15 起这一层默认关闭。 - 整页的渲染时机:这条路由是构建时渲染一次(
○ Static),还是每次请求现渲染(ƒ Dynamic)。默认是静态。
「默认不缓存」说的是第一层,可决定你看到什么的是第二层。路由是静态的,整页在构建时就定格了,那次构建时取到的数据被写死进 HTML,之后每个请求都发同一份——数据缓存关不关根本没机会起作用。
数据
我用一个「每被调用一次就自增」的接口验证了这一点。生产构建后各连发三次请求:
| 写法 | 构建产物标记 | 三次请求返回 |
|---|---|---|
fetch(url) | ○ Static | 3、3、3(构建时取的那一次) |
fetch(url, { cache: "force-cache" }) | ○ Static | 2、2、2 |
fetch(url, { cache: "no-store" }) | ƒ Dynamic | 12、13、14 |
看第一行:不写任何选项,三次请求拿到的是同一个值。要保证每个请求都新鲜,必须显式写 cache: "no-store",或者用别的方式让这条路由变成动态(比如读 cookies()、headers()、searchParams)。
顺带纠正另一个说法:force-cache 不是「永久缓存」。准确说法是「优先读服务端缓存,条目过期(stale)时回源重取」。它受 revalidate 和标签失效的支配,不是一劳永逸。
四种写法怎么选
| 场景 | 写法 |
|---|---|
| 内容基本不变(文档、营销页) | 什么都不写,享受静态 |
| 内容会变但可以慢几分钟(列表、榜单) | next: { revalidate: 60 },构建后每 60 秒后台重验证 |
| 写操作后要立刻反映(发表、编辑) | next: { tags: ["posts"] },在 Server Action 里按标签失效(见 15 章) |
| 每次都必须最新(余额、库存、个人页) | cache: "no-store" |
另外,同一次渲染里对同一个 URL 发起的相同请求会自动去重,只真的发出去一次。所以布局和页面各自 await getUser() 完全没问题,不必为了省一次请求把数据往上提。
// 同一个「每被调用就自增」的接口,生产构建后各连发三次请求
// ① 不带选项 → 路由判定为 ○ Static,三次都是 3、3、3
// 数据缓存确实没开,但整页在构建时就定格了
const a = await fetch(url);
// ② force-cache → 同样是 ○ Static,三次都是 2、2、2
// 注意它不是「永久缓存」,而是「优先读缓存,stale 时回源」
const b = await fetch(url, { cache: "force-cache" });
// ③ no-store → 路由变成 ƒ Dynamic,三次是 12、13、14
const c = await fetch(url, { cache: "no-store" });
// ④ ISR:构建时取一次,之后每 60 秒后台重新验证
const d = await fetch(url, { next: { revalidate: 60 } });
// ⑤ 打标签,写操作后精确失效(配合 Server Action)
const e = await fetch(url, { next: { tags: ["posts"] } });
// 同一次渲染里的相同请求自动去重,只真的发一次
export default async function Page() {
const [u1, u2] = await Promise.all([getUser(), getUser()]);
return <p>{u1.name}</p>; // 布局和页面各取一次也不用担心
}next dev 下每次刷新都重新渲染,数据永远是新的,一切正常;一上生产环境,某个页面的数字就永远停在部署那一刻——因为它被判成了 ○ Static。「本地好好的,线上数据不更新」十有八九是这个。别靠 next dev 验证缓存行为,一律用 next build 加 next start 复现。ƒ;标着 ○ 就说明它已经在构建时定格了,之后再怎么改数据库页面都不会变。这一眼比任何调试都快,是判断「缓存有没有按预期生效」的第一手段。服务端组件是 await 到底的,一个慢查询就能拖住整页。<Suspense> 的作用是把「慢」隔离在一个盒子里:盒子外的内容立刻发出去,盒子里的渲染好之后再追加进同一个响应流。
流式渲染是怎么发生的
一个 <Suspense> 包住 sleep 1.2 秒组件的页面:服务器立刻发出包含页面外壳和 fallback 的 HTML,1.2 秒后把真实内容追加到同一个响应里。用 curl 抓完整响应,能同时看到 FAST-SHELL、SLOW-FALLBACK 和最终的 SLOW-PART 三段。这不需要任何客户端 JS 参与。
loading.tsx(见 13 章)本质就是框架替你在页面外面自动包了一层 <Suspense>。手写的优势是粒度:loading.tsx 是整页要么全等要么全不等,手写可以让页面头部、侧栏立刻出来,只让那块慢的评论区转圈。
Next 16 的 Cache Components
Next 16 把之前的部分预渲染(PPR)和一堆缓存配置合并成了统一模型:Cache Components。核心思路是把「缓存」从 fetch 的一个选项,提升成组件级别的声明——用 'use cache' 标记一个可以被缓存的函数或组件。
它默认是关闭的,必须在 next.config.ts 里显式写 cacheComponents: true。不开这个开关直接写 'use cache',构建报 To use "use cache", please enable the feature flag `cacheComponents` in your Next.js config.。开启后构建头部会多打印一行 - Cache Components enabled。
关于 PPR 要更新一个说法:Next 16 已经移除了 experimental.ppr 配置项和路由级的 export const experimental_ppr。写 experimental: { ppr: true } 直接报错:`experimental.ppr` has been merged into `cacheComponents`. 所以别再说「趋势是 PPR」——它已经并入 Cache Components,是这个模型的运行结果。
一个「缓存外壳 + Suspense 包住实时数据」的页面,构建产物标成 ◐ (Partial Prerender),路由表多出 Revalidate / Expire 两列(默认 15m / 1y)。连发三次请求,外壳纹丝不动,实时部分依次是 7、8、9——同一页里两种时效性并存。
开启的代价
它会强制你把动态性显式标出来,两条硬约束都会让构建失败:
export const dynamic被整个禁用:Route segment config "dynamic" is not compatible with `nextConfig.cacheComponents`. Please remove it.- 任何未缓存的数据访问都必须在
<Suspense>里面:Uncached data was accessed outside of <Suspense>. This delays the entire page from rendering.注意await searchParams也算,同样被拦下。
所以这不是「打开就变快」的开关,而是一次需要动手改造的迁移。新项目建议一开始就开着写,老项目要按路由逐个改。
// next.config.ts —— Next 16 里 Cache Components 默认关闭,必须显式打开
import type { NextConfig } from "next";
const config: NextConfig = { cacheComponents: true };
export default config;
// 注意:experimental.ppr 在 16 里已移除,写了会直接报错
// app/page.tsx
import { Suspense } from "react";
async function Shell() {
"use cache"; // 没开上面那个开关时,这一行就是构建错误
return <h1>标题(构建时算好,可缓存)</h1>;
}
async function Live() {
const r = await fetch(url, { cache: "no-store" });
return <p>实时数据 {(await r.json()).n}</p>;
}
export default function Page() {
return (
<main>
<Shell /> // 静态部分,立刻发出
<Suspense fallback={<p>加载中…</p>}><Live /></Suspense>
</main>
);
}
// 构建产物标记 ◐ (Partial Prerender),Revalidate/Expire 列显示 15m / 1y
// 连发三次请求:外壳纹丝不动,实时部分依次是 7、8、9cacheComponents: true 之后,await searchParams 也算「未缓存的数据访问」。一个只是读了查询串的普通页面,构建就直接失败:Uncached data was accessed outside of <Suspense>。同时 export const dynamic = "force-dynamic" 被整个禁用,报 not compatible with `nextConfig.cacheComponents`。所以别在老项目里随手打开这个开关,它是一次需要逐路由改造的迁移,不是性能优化按钮。await 慢数据的那部分单独抽成一个小组件再用 <Suspense> 包住它——如果 await 写在页面组件顶层,包在外面的 Suspense 一点用都没有,因为整个页面都在那个 await 后面。fallback 请画成和真实内容同尺寸的骨架屏,否则内容到位时会跳版。数据变更、Server Actions 与缓存
从表单提交到乐观更新,再到四层缓存与重新验证——把「写数据」这条完整链路走通。
Server Action 不是「把函数发到客户端执行」。它在编译期被分配一个稳定 id,服务端按这个 id 注册了一个 POST 端点;客户端拿到的只是一个同名的空壳函数,调用它等于发一次网络请求。想清楚这一点,安全和限制就都顺理成章了。
它被编译成了什么
'use server'是给打包器的指令:写在文件顶部标记整个模块,写在函数体第一行标记单个函数。- 编译后,服务端保留真实函数体并按 id 登记;客户端得到的是一层
fetch包装,把 Action id 和序列化后的参数 POST 到当前路由。 - 所以数据库连接、密钥、
node:fs永远进不了浏览器包——它们从头到尾没被打进客户端图。这也是为什么服务端组件里可以直连数据库(见 14 章)。 - 参数与返回值必须能被 React 的序列化协议处理:普通对象、数组、字符串、
Date、FormData、Promise都行;类实例、函数、DOM 节点不行。
两种调用方式
- 表单:
<form action={createPost}>,React 自动把表单字段打包成FormData作为唯一参数传进去。这条路自带渐进增强。 - 客户端组件里当普通异步函数调:
await createPost(data)。参数就不再限于FormData了,但也就没有渐进增强。 - 需要额外参数时用
fn.bind(null, id)预绑定——注意绑定值会被序列化后发给客户端再发回来,同样不可信。
它是公开端点,不是私有函数
- Action id 稳定且可被重放。攻击者根本不需要打开你的页面,直接构造 POST 就行。
- 因此鉴权必须写在 Action 体内,不能依赖「这个按钮只对管理员显示」——按钮是客户端的,端点是公开的。
- 入参一律当作敌意输入校验。尤其是隐藏字段里的
userId、role:正确做法是无视它,从服务端 session 里重新取身份。 - Action 抛出的错误在生产环境只会给客户端一个 digest 字符串,真实信息留在服务端日志里;想让用户看到提示,要主动
return一个错误对象而不是throw。
// app/posts/actions.ts —— 顶部指令:整个模块都是 Server Action
"use server";
import { auth } from "@/lib/auth";
import { z } from "zod";
const Schema = z.object({ title: z.string().min(1).max(80) });
export async function createPost(prevState, formData) { // 第一参是上次的返回值
// 1) 鉴权:这是公开 POST 端点,别人可以绕过你的表单直接打
const session = await auth();
if (!session) return { error: "请先登录" };
// 2) 校验:formData 里的一切都是不可信输入
const r = Schema.safeParse({ title: formData.get("title") });
if (!r.success) return { error: "标题需为 1-80 字" };
// 3) 作者一律取服务端 session,绝不用表单传来的 userId
await db.post.create({
data: { title: r.data.title, authorId: session.user.id },
});
return { ok: true }; // 返回值会进 useActionState 的 state
}<button> 只对管理员显示挡不住任何人,Action id 是稳定的,直接 POST 就能触发。同理,别信 <input type="hidden" name="userId">——那是用户可以随手改的字段,身份只能从服务端 session 取。useActionState 把「提交表单 → 等服务端 → 拿回结果」压成一个 Hook:它替你保管上一次的返回值、给你一个能直接塞进 action 的函数、外加一个 pending 布尔。你不用再手写 loading 和 error 两个 state。
签名与三元组
const [state, formAction, isPending] = useActionState(fn, initialState)。fn的签名是(prevState, formData) => newState——第一个参数是上一次的返回值,不是 formData。这是最容易搞反的地方。- React 19.2.8 的时序:提交期间
state仍是旧值、isPending为true;Action resolve 之后state换成返回值、isPending转false。也就是说 pending 期间你渲染的是「上一次的结果」,不是空。 initialState决定首屏那一次渲染的state,给个有结构的对象(如{ error: null }),别给undefined,否则下游到处要判空。
渐进增强是怎么来的
- 把
formAction交给<form action={...}>,Next 会给这个表单渲染出真实的action属性和隐藏字段。JS 还没加载完时,浏览器原生提交也能把数据送到服务端。 - 代价:只有
<form action>这一条路径有渐进增强。你若改成onSubmit里手动await createPost(),就退回成必须有 JS 才能用的纯客户端逻辑了。 - 另一个代价:原生提交没有
preventDefault的机会,所以「提交后清空输入框」要靠非受控defaultValue加key重置,而不是清 state。
useFormStatus 补在哪一环
useFormStatus()从react-dom(不是react)引入,返回的 key 是action、data、method、pending四个。- 它读的是最近的祖先 form 的状态,所以必须写在 form 的子组件里。把它写在渲染
<form>的那个组件自己身上,提交时pending全程是false。 - 什么时候用它而不是
isPending:当提交按钮被抽成通用组件、拿不到useActionState的返回值时。同一个页面两者可以并存。
"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom"; // 注意是 react-dom
import { createPost } from "./actions";
// 必须是 form 的「子组件」,写在 Form 自己身上 pending 永远是 false
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? "发布中" : "发布"}</button>;
}
export default function Form() {
// 必须直接把 Server Action 传进去。包一层客户端箭头函数的话,
// React 就生成不出真实端点,渐进增强当场失效(见下方警告)
const [state, formAction, isPending] = useActionState(
createPost,
{ error: null }, // 给结构,别给 undefined
);
return (
<form action={formAction}>
<input name="title" defaultValue="" />
<SubmitButton />
{state.error && <p>{state.error}</p>}
{isPending && <p>提交中</p>}
</form>
);
}useFormStatus 写在渲染 <form> 的那个组件里,提交全程 pending 都是 false——它只能读到祖先 form 的状态。把按钮抽成子组件才有效。另一个高频错:从 react 里 import 它,它在 react-dom 里。formData 的原值一起 return 出来,再用 defaultValue={state.values?.title} 回填。这比改成受控表单省事得多,也不破坏渐进增强。useOptimistic 返回的不是「加了一条的列表」,而是「把你给的 reducer 应用到真值上的临时结果」。不给 reducer,它用的默认实现就是把整个状态替换成你传进去的那个东西——这一条几乎所有教程都写错了。
签名与机制
const [optimistic, addOptimistic] = useOptimistic(realValue, updateFn?)。- 没有进行中的 Action 时,
optimistic严格等于realValue。 updateFn是(currentState, payload) => newState,必须是纯函数:React 会在 Action 进行期间的每次重渲染里,拿最新的真值重放它。这就是为什么服务端数据一到,乐观态能自动退场而不需要你清理。- Action 结束(无论成功失败)时,React 丢弃全部乐观更新,UI 回到真值。失败自动回滚是白送的,不用写 catch。
不传 reducer 当追加用,必崩
- React 19.2.8:真值是
["原有1","原有2"],调addOptimistic({text:"新的"})之后optimistic变成{"text":"新的"}——一个对象,不是数组;Action 结束后才回到原数组。 - 下游只要有一个
.map(),立刻抛TypeError: optim.map is not a function。 - 更阴的是:React 会丢弃这次渲染失败的乐观状态、把界面回滚到旧值。页面看起来只是「乐观更新没生效」,错误边界都不一定弹,只有 console 里躺着那行 TypeError。查起来极费劲。
- 正确写法只有一种:
useOptimistic(msgs, (state, m) => [...state, m])。
setter 必须在 Action 或 transition 里调
- 裸调用(比如写在
onClick顶层)告警原文:An optimistic state update occurred outside a transition or action. To fix, move the update to an action, or wrap with startTransition. - 机制上讲得通:乐观状态的生命周期就是「这次 transition 的生命周期」。没有 transition,React 就没有一个明确的时刻去回滚它。
- 所以要么写在
useActionState的 Action 函数体里,要么自己startTransition(() => { ... })包一层。
"use client";
import { useOptimistic, useActionState } from "react";
import { sendMessage } from "./actions";
export default function Thread({ messages }) {
// 第二个参数不能省:省了就是「整体替换」,下游 map 会崩
const [optimistic, addOptimistic] = useOptimistic(
messages,
(state, text) => [...state, { id: "tmp", text, sending: true }],
);
const [, formAction] = useActionState(async (prev, fd) => {
const text = fd.get("text");
addOptimistic(text); // 在 Action 体内调,才有回滚时机
return sendMessage(text); // 失败时乐观项自动消失
}, null);
return (
<form action={formAction}>
{optimistic.map((m) => (
<p key={m.id} style={{ opacity: m.sending ? 0.5 : 1 }}>{m.text}</p>
))}
<input name="text" /><button>发送</button>
</form>
);
}useOptimistic(list) 不传 reducer 却当追加用。乐观值会变成你传进去的那个对象本身,.map() 直接抛 TypeError: optim.map is not a function;而且 React 会静默回滚,界面只表现为「乐观更新没生效」,报错藏在 console 里。sending: true)并降低透明度,用户就能分清「已发出」和「已确认」。别指望用 id 区分——乐观项还没有真 id,列表 key 用临时值也没关系,它活不过这次 transition(key 的意义见 03 章)。Next 的缓存不是一个开关,是四层生命周期完全不同的东西叠在一起。绝大多数「我明明清了缓存怎么还是旧数据」,都是因为你动的是第二层,问题却出在第三层或第四层。
四层对照
| 缓存层 | 存在哪 | 缓存什么 | 生命周期 | 怎么绕开 |
|---|---|---|---|---|
| Request Memoization | 服务端内存 | 同一次渲染里重复的 fetch | 单次请求,渲染结束即丢 | 基本不需要绕 |
| Data Cache | 服务端持久层 | fetch 的响应数据 | 跨请求存在,直到被重新验证 | cache: "no-store" |
| Full Route Cache | 服务端持久层 | 静态路由的 HTML 与 RSC payload | 直到重新验证或重新部署 | 让路由变成动态 |
| Router Cache | 浏览器内存 | 访问过的路由段 payload | 一次会话内,刷新即清 | router.refresh() |
别把 force-cache 说成「永久缓存」
- 准确的语义是:优先读服务端缓存,命中且未过期就直接返回;被标记为 stale 之后,下一次请求会回源取新值再写回。
- 它承诺的是「尽量不打后端」,不是「永远不打后端」。把它理解成「永久」,你就会去找一个并不存在的「清空按钮」。
- 反过来
no-store也不是「关掉全部缓存」——它只作用于第二层,还会顺带把整个路由拽成动态(见 16 章讲的构建输出)。
四层是怎么互相坑的
- Action 里
revalidateTag清了 Data Cache,用户却还看到旧列表 → 是 Router Cache 在浏览器里兜着,需要一次导航或refresh()。 - 本地
next dev一切正常,上线后数据永远不更新 → 是 Full Route Cache:这个页面在构建时就定格了,跟你的 fetch 选项无关。 - 一次渲染里三个组件各自
fetch同一个 URL,后端只收到一次请求 → 那是 Request Memoization 干的,不是 Data Cache,所以它不会跨请求生效。 - 排查顺序永远是从外往里:先看构建输出这个路由是 ○ 还是 ƒ,再看 fetch 的缓存选项,最后才怀疑客户端。
// 第二层 Data Cache:靠 tag 分组,方便按业务维度失效
const res = await fetch("https://api.example.com/posts", {
next: { tags: ["posts"], revalidate: 3600 },
});
// 完全不进 Data Cache,同时把整个路由拽成动态
const live = await fetch(url, { cache: "no-store" });
// 第三层 Full Route Cache:由路由段配置控制
// app/posts/page.tsx
export const revalidate = 60; // 静态但每 60 秒后台重生成
// 第四层 Router Cache:只能在客户端清
"use client";
import { useRouter } from "next/navigation";
const router = useRouter();
router.refresh(); // 丢弃当前路由的客户端缓存,重新拉 RSC payloadrevalidateTag 之后所有人立刻看到新数据。它只清服务端的 Data Cache 与 Full Route Cache;浏览器里的 Router Cache 得等一次导航或 router.refresh()。同一个用户在 SPA 内来回切页时看到旧数据,八成是这一层。next build 看那张路由表:如果目标路由是 ○ Static,那不管你怎么调 fetch 选项都不会有新数据——问题在第三层,得靠重新验证或让它变动态来解决。在 Next 16 里「让缓存失效」已经分裂成三个动作:revalidateTag 只标脏、updateTag 标脏并保证本次响应就能读到新值、refresh 谁都不动只刷新动态数据。选错不会报错,只表现为「刷新一下才出现」。
revalidateTag 现在要两个参数
- next 16.2.11 的类型签名里第二个参数是必填的。TypeScript 项目下写单参直接编译不过:
Type error: Expected 2 arguments, but got 1. - JS 项目里构建不拦(
next build零告警),要等运行到那一行时服务端日志才打印原文:"revalidateTag" without the second argument is now deprecated, add second argument of "max" or use "updateTag". - 改成
revalidateTag("posts", "max")后告警消失。第二参是 cacheLife profile,表示「这条缓存最多还能活多久」。
Next 16 新增的两个 Server-Action-only API
updateTag(tag):标脏 + read-your-writes。发布文章后要立刻在列表里看到它就用它。revalidateTag(tag, "max")做不到——它只把缓存标成 stale,本次响应带回的可能还是旧数据。refresh():不动任何缓存,只让客户端重新拉当前路由的未缓存部分。适合「数据本来就是动态的,只是想重新查一遍」。- 两者都只能在 Server Action 里调。在 Route Handler 里调
updateTag的运行时报错原文:updateTag can only be called from within a Server Action. To invalidate cache tags in Route Handlers or other contexts, use revalidateTag instead. - 限制的由来:只有 Server Action 的响应体里有位置捎回刷新后的页面数据,Route Handler 返回的是普通 HTTP 响应,没这个通道。
在渲染期调用会抛错,不是空操作
- 老文章常说「在服务端组件里调
revalidatePath是 no-op」。这是错的。报错原文:Route /rv used "revalidatePath /p/1" during render which is unsupported. To ensure revalidation is performed consistently it must always happen outside of renders and cached functions. - 它会让
next build直接失败,而不是悄悄什么都不做。 - 这对调试是好消息:有明确报错比「没生效又找不到原因」好查一百倍。在
"use cache"函数体内、在generateStaticParams里调用,也各有一条专门的报错。
"use server";
import { revalidateTag, updateTag, revalidatePath } from "next/cache";
export async function publishPost(id) {
await db.post.update({ where: { id }, data: { live: true } });
// 要「写完立刻能读到」:用 updateTag,仅限 Server Action
updateTag("posts");
// 动态路径必须带第二个参数,否则告警「无效果」
revalidatePath("/blog/[id]", "page");
}
export async function bumpViewCount(id) {
await db.post.increment(id);
// 后台慢慢过期即可,不需要本次就读到 —— 第二参必填
revalidateTag("posts", "max");
}
// ✗ 反例:写在服务端组件的渲染流程里 —— 构建直接失败
// export default function Page() { revalidatePath("/x"); ... }updateTag,运行时抛 updateTag can only be called from within a Server Action.——那里只能用 revalidateTag。还有个反向的坑:在服务端组件渲染期调 revalidatePath 不是 no-op,它会让 next build 直接失败。updateTag(发布、下单、改资料);只是让别人下次别读到旧的就用 revalidateTag(tag, "max")(浏览量、点赞数);数据本来就没缓存、只想重查一遍就用 refresh()。Next 里其实没有「选一种渲染模式」这回事,只有一条判定:这个路由在渲染时碰过只有请求发生时才知道的东西吗。碰了就是动态,没碰就是静态。所有配置项都只是在影响这条判定。
三种结果与构建输出
- 静态:构建时渲染一次,结果进 Full Route Cache,之后每个请求都发同一份。构建表里是
○。 - 动态:每次请求现渲染。构建表里是
ƒ。 - ISR:静态 + 到期后台重生成,靠
export const revalidate = 60打开。16.2.11 的构建表里 ISR 路由仍然显示○,只是表头多出 Revalidate 与 Expire 两列:○ /isr 1m 1y。老文章里那个●符号已经不存在了,别照着找。 - 判定是按路由做的,不是按组件。一个动态组件会把整条路由拽成动态,除非你用
<Suspense>把它隔开(见 14 章的流式渲染)。
什么会意外把页面变成动态的
- request-time API:
cookies()、headers()、draftMode()、connection(),以及页面参数里的searchParams。一个只调了cookies()的页面,构建输出从○变成了ƒ。 fetch(url, { cache: "no-store" }),或路由段配置export const dynamic = "force-dynamic"。- 隐蔽之处在于它常常藏在第三方库或某个共享组件里——你根本没写过
cookies(),是分析埋点的 SDK 写了。发现页面莫名变 ƒ 时,先查依赖。
别把「不缓存」和「拿得到新数据」当成一回事
- 默认不写入 Data Cache,不等于每次请求都取新数据——路由若被判定为静态,整页会被 Full Route Cache 定格在构建那一刻。这两句听起来矛盾,是因为它们说的是两层不同的缓存。
- 这一条有完整的对照(同一个自增计数接口,默认 fetch 与
no-store各请求三次的结果),见 14 章的数据获取卡,这里不重复。 - 本卡关心的是它的后果:路由是静态还是动态,直接决定了下面那张策略表该怎么选。
// app/dashboard/page.tsx —— 路由段配置放在文件顶层导出
export const dynamic = "force-dynamic"; // 强制每次请求现渲染
export const revalidate = 60; // ISR:60 秒后台重生成
// export const dynamic = "force-static"; // 反向:禁止转动态
export default async function Page() {
// 只要碰了 cookies(),整条路由从 ○ 变 ƒ ——
const jar = await cookies();
const theme = jar.get("theme")?.value ?? "light";
// 不带选项 ≠ 每次新数据:静态路由会把这次结果定格到构建时
const stats = await (await fetch(api)).json();
return <main data-theme={theme}>{stats.count}</main>;
}
// 构建输出里核对结论:
// ○ /posts 静态,构建时定格
// ○ /isr 1m 1y ISR 也是 ○,多出 Revalidate / Expire 两列
// ƒ /dashboard 动态,每次请求现渲染fetch 会让路由判定为 ○ Static,三次请求全返回构建时那一个值。要实时必须显式 cache: "no-store",或让路由因别的原因转成 ƒ Dynamic。force-dynamic。把那一小块抽成子组件、用 <Suspense> 包起来,静态外壳照样能预渲染,动态部分流式补上。整页转动态是最贵的做法。把前面六张卡接起来。一次「发帖」要穿过四道边界:浏览器的乐观状态、Server Action、数据库、缓存层。每一道断掉的表现都不一样,认得出症状就不用瞎猜。
五步与各自的职责
- 1 乐观入列:客户端
addOptimistic(text)(带 reducer 的那种)把新帖先塞进列表,UI 在同一帧就有反馈。 - 2 提交:
<form action={formAction}>触发 Server Action,浏览器 POST 到当前路由。注意这里有个取舍:要调addOptimistic就必须包一层客户端函数,而一旦包了,React 就生成不出真实端点(表单的action变成javascript:throw new Error('React form unexpectedly submitted.')),渐进增强当场失效。乐观更新与「没 JS 也能提交」不可兼得——要后者就照本章前面那张卡直接传 Server Action,别用乐观更新。 - 3 服务端处理:Action 里鉴权、校验、写库,出错就
return { error }而不是throw。 - 4 失效缓存:
updateTag("posts")。用它而不是revalidateTag,是因为这次响应就要把新列表带回去。 - 5 收敛:Action 返回,React 用服务端发回的新数据替换子树,乐观项自动退场——你不需要手动删掉它。
少了哪一步会怎样
| 省掉的环节 | 用户看到的症状 |
|---|---|
| 乐观更新 | 点了没反应,要等一整个往返才出现 |
| 缓存失效 | 「明明写进去了」——刷新才出现,最常见的一类 bug |
| 用了 revalidateTag 而非 updateTag | 本次响应还是旧数据,要再导航一次才刷新 |
| Action 没有返回值 | 校验失败时表单一片死寂,用户不知道哪儿错了 |
| 没做鉴权 | 没有症状——直到有人直接 POST 那个端点 |
别在 Action 里手动改真值
- 如果 Action 体内既
addOptimistic又直接setState改了真值数组,中间会渲染出["原有1","原有2","新的","新的"]——乐观项叠在已经更新过的真值上,用户看到一帧重复。 - 原因见卡三:reducer 会基于最新真值重放。真值一变,乐观项就重复计入了。
- 结论:真值只交给服务端数据和缓存失效去驱动,客户端不要再插一手。这也是「服务端是唯一真相源」在代码里的具体样子。
// app/posts/actions.ts
"use server";
import { updateTag } from "next/cache";
export async function addPost(prev, formData) {
const session = await auth();
if (!session) return { error: "请先登录" };
const title = String(formData.get("title") ?? "").trim();
if (!title) return { error: "标题不能为空" };
await db.post.create({ data: { title, authorId: session.user.id } });
updateTag("posts"); // 本次响应就要带回新列表
return { ok: true };
}
// app/posts/PostForm.tsx(客户端组件)
const [optimistic, addOptimistic] = useOptimistic(
posts, (s, title) => [{ id: "tmp", title, pending: true }, ...s],
);
const [state, formAction] = useActionState(async (prev, fd) => {
addOptimistic(fd.get("title")); // 先乐观,再走网络
return addPost(prev, fd); // 真值不要自己动
}, { error: null });updateTag 写在 redirect() 后面。redirect 是靠抛出一个特殊错误实现的(Next 源码里就是 throw getRedirectError(...)),它后面的代码一行都不会执行,缓存永远没被失效。顺序必须是先失效、后跳转。部署与工程化
Route Handlers、取代了 middleware 的 proxy.ts、环境变量的泄密风险,以及 Vercel、自托管与静态导出怎么选。
Route Handler 是 App Router 里唯一一种不返回 UI 的路由文件。你导出的不是组件而是 HTTP 方法函数,收的是浏览器原生的 Request,还的是原生 Response——它就是一个普通的 HTTP 端点,只是恰好住在 Next 的路由树里。
写法与约束
- 文件叫
route.ts,放在app/api/posts/route.ts这样的目录下,导出GET、POST、PUT、PATCH、DELETE、HEAD、OPTIONS中的任意几个。 - 第一个参数是原生
Request:new URL(request.url).searchParams读查询串,await request.json()读请求体。第二个参数是{ params },Next 15 起params是 Promise,要await(见 13 章)。 - 返回原生
Response或Response.json(data, { status })。NextResponse只是它的子类,多了 cookies、redirect、rewrite 这些便利方法,不是必需品。 - 同一个目录里不能同时有
page.tsx和route.ts——它们抢同一个 URL。
GET 默认不缓存
- Next 15 起 GET Route Handler 默认是动态的。16.2.11:一个连
request都不读、只返回Date.now()的GET,构建表里仍然是ƒ Dynamic,连续两次curl拿到不同时间戳。 - 要让它缓存得自己写
export const dynamic = "force-static"或export const revalidate = 60。 - 这和老版本相反:Next 13/14 里 GET 默认缓存,很多老教程教你「记得加 no-store」,现在那句话已经多余了。
什么时候用它,什么时候用 Server Action
两者都能改数据,但它们服务的对象完全不同。
- Server Action优不用手写端点和序列化,参数返回值自带类型;
<form action>有渐进增强;能用updateTag拿到 read-your-writes(见 15 章)。短方法固定是 POST,端点 id 由编译器生成、每次构建可能变,没法当对外契约;只有你自己的 React 前端调用顺手。为何它的「省事」正来自把端点藏起来交给框架管——藏起来的东西就没法给别人用。 - Route Handler优就是标准 HTTP:URL 稳定、方法和状态码你说了算,curl、webhook、iOS 客户端、别的服务都能调。短序列化、校验、错误码全要自己写;没有渐进增强;在里面调
updateTag直接抛错,只能用revalidateTag。为何它的「通用」正来自不绑定 React,代价就是拿不到 React 那套配套设施。
// app/api/posts/route.ts —— 用的全是 Web 标准 API
export async function GET(request) {
const q = new URL(request.url).searchParams.get("q");
const posts = await db.post.search(q);
return Response.json(posts); // 原生 Response,不必用 NextResponse
}
// GET 默认动态(Next 15+)。要缓存必须自己声明:
// export const revalidate = 60;
export async function POST(request) {
const sig = request.headers.get("x-webhook-signature");
if (!verify(sig)) return new Response("bad signature", { status: 401 });
const body = await request.json();
const post = await db.post.create({ data: body });
return Response.json(post, { status: 201 });
}
// app/api/posts/[id]/route.ts —— params 是 Promise,要 await
export async function DELETE(request, { params }) {
const { id } = await params;
await db.post.delete({ where: { id } });
return new Response(null, { status: 204 });
}updateTag 或 refresh 会在运行时抛错,webhook 里刷新缓存只能用 revalidateTag(tag, "max")。两者的分工、以及反向那个「服务端组件渲染期调 revalidatePath 会让构建失败」的坑,都在 15 章。Next 16 把 middleware.ts 改名成了 proxy.ts。这不只是换个文件名——运行时从 Edge 换成了 Node.js,于是「它跑在轻量 Edge 里,要快、别做重活」这条流传多年的建议,前提已经不成立了。
迁移只有三步
- 把根目录(或
src/下)的middleware.ts改名为proxy.ts,导出的函数改成export default function proxy(request)。 NextResponse.next()/redirect()/rewrite()和export const config = { matcher: [...] }全部照旧,一个字都不用改。- 还留着
middleware.ts的话,next build打印告警原文:⚠ The "middleware" file convention is deprecated. Please use "proxy" instead.(附链接nextjs.org/docs/messages/middleware-to-proxy)。两个文件同时存在会直接构建报错,要求你只保留 proxy。 - 改完之后构建产物里那一行两者都显示成
ƒ Proxy (Middleware),说明底层是同一套机制。
运行时真的变了
- 在
proxy.ts里import { readFileSync } from "node:fs"并真去读磁盘文件:构建通过,next start后请求正常返回,响应头里带回x-node: v22.22.2。这段代码在 Edge 运行时下根本跑不起来。 - Next 内部对 proxy 文件的校验信息也写死了这一点:
Route segment config is not allowed in Proxy file ... Proxy always runs on Node.js runtime.——你甚至不能在proxy.ts里写export const runtime去改它。 middleware.ts仅为仍需 Edge 的场景保留,并且已标记 deprecated。新项目不必再纠结「这段代码 Edge 支不支持」。
能做重活 ≠ 该做重活
- 限制解除的是能力,不是预算:matcher 命中的每一个请求都要排队等它跑完,它仍然在关键路径上。
- 新的分工原则:把「要不要放行」放在 proxy 里(有没有 token、要不要重写路径、语言前缀是什么),把「这个人有什么权限、数据长什么样」放到页面或 Server Action 里。
config.matcher一定要写窄。默认它会命中包括静态资源在内的所有路径,白白给每张图片加一次函数调用。- proxy 里做的鉴权只是第一道门,不能当成唯一一道。真正的权限校验必须在数据入口(Server Action、Route Handler)里再做一遍——见 15 章讲的公开端点问题。
// proxy.ts(项目根目录,Next 16 取代 middleware.ts)
import { NextResponse } from "next/server";
// 跑在 Node.js 运行时:node: 开头的内置模块可以正常用
export default function proxy(request) {
const { pathname } = new URL(request.url);
// 只做「要不要放行」这一件事,别在这里查数据库
const token = request.cookies.get("session");
if (!token) {
const url = new URL("/login", request.url);
url.searchParams.set("from", pathname); // 登录后跳回来
return NextResponse.redirect(url);
}
const res = NextResponse.next();
res.headers.set("x-node", process.version); // 能读到 v22.x
return res;
}
// matcher 写窄,别让静态资源也过一遍
export const config = { matcher: ["/dashboard/:path*", "/settings/:path*"] };middleware.ts,构建告警 ⚠ The "middleware" file convention is deprecated. Please use "proxy" instead.;若两个文件都在,构建直接报错要求只留 proxy。另外别再沿用「Edge 里不能用 Node API」那套限制去写 proxy,它跑在 Node.js 上。config.matcher 支持排除式写法,把静态资源一次性挡在外面:"/((?!_next/static|_next/image|favicon.ico).*)"。matcher 是构建期解析的,必须写成字面量常量——用变量拼出来的 matcher 不会生效。NEXT_PUBLIC_ 不是一个「权限开关」,它是一条编译期文本替换指令:带这个前缀的变量在构建时会被字面量替换进客户端 JS,跟你把值直接写死在源码里没有任何区别。想明白这一点,泄密的边界就清楚了。
.env 家族的加载顺序
- 优先级从高到低:
.env.local→.env.development/.env.production(按NODE_ENV选) →.env。先加载的赢,后面的同名变量不会覆盖它。 .env.local存放本机密钥、不进版本库;.env存放可公开的默认值、可以进版本库。- 构建时终端会打印实际读了哪些文件,形如
- Environments: .env.local。发现变量没生效时,先看这一行。 - 框架配置写在
next.config.ts(支持 TypeScript):output、images.remotePatterns、redirects()等。它跑在 Node 里,是构建期代码。
泄密是怎么发生的
- 往
.env.local放一个NEXT_PUBLIC_T16_TOKEN=PUBLICVALUE_ZZQQ1234,在客户端组件里读它,构建后直接 grep 客户端产物:.next/static/chunks/…js里赫然是"公开变量=","PUBLICVALUE_ZZQQ1234"。任何人打开 DevTools 都拿得到。 - 还有一条更隐蔽的:同一个客户端组件里读不带前缀的
T16_SECRET。客户端 chunk 里确实找不到它(被替换成了undefined),但服务端预渲染那一遍读到的是真值,密钥被写进了 HTML——curl拿回的页面里就有私有=<!-- -->SECRETVALUE_ZZQQ9876。 - 所以「客户端组件里读不到就等于安全」是错的。客户端组件也要在服务端跑一遍 SSR,那一遍
process.env是全的。
正确的防线
- 密钥只在服务端组件、Server Action、Route Handler 里读,永远不出现在标了
"use client"的文件里,哪怕只是想「打个日志看看」。 - 给放密钥的模块加
import "server-only":一旦它被客户端依赖图引用到,构建直接失败。这是唯一能在编译期挡住误引的手段。 - 需要给浏览器的配置(后端地址、公开 key)才加
NEXT_PUBLIC_,并且心里清楚它等同于公开。 - 改了
.env里的NEXT_PUBLIC_变量必须重新构建才生效——它是构建期烧进产物的,改环境变量再重启服务没用。
# .env.local —— 不进版本库,优先级最高
DATABASE_URL=postgres://user:pw@host/db # 仅服务端
STRIPE_SECRET_KEY=sk_live_xxx # 仅服务端
NEXT_PUBLIC_API_BASE=https://api.example.com # 会被烧进浏览器
// lib/db.ts —— 一旦被客户端图引用,构建立刻失败
import "server-only";
export const db = createClient(process.env.DATABASE_URL);
// app/page.tsx(服务端组件)—— 读密钥安全
const key = process.env.STRIPE_SECRET_KEY;
// app/Widget.tsx —— ✗ 客户端组件里读密钥:chunk 里没有,
// 但 SSR 那一遍会把真值渲染进 HTML,curl 能看到
"use client";
export default function Widget() {
return <p>{process.env.STRIPE_SECRET_KEY}</p>; // 泄密
}
// next.config.ts
import type { NextConfig } from "next";
const config: NextConfig = { output: "standalone" };
export default config;NEXT_PUBLIC_ 前缀,客户端组件就读不到」。客户端 bundle 里确实是 undefined,但客户端组件的 SSR 那一遍在服务端跑,读到的是真值并渲染进了 HTML——curl 页面就能看到密钥明文。防线是 import "server-only",不是前缀。next build 之后在 .next/static 里 grep 你的密钥前缀(如 sk_live、postgres://)。命中就说明它进了浏览器包。这个检查十秒钟,比任何 code review 都可靠,值得写进 CI。next build 最后打印的那张路由表,就是你的性能体检报告:每行前面那个符号回答了「这个路由的每一次请求要不要跑服务器」。看懂它,比装任何监控都先一步发现问题。
怎么读这张表
| 符号/列 | 含义 | 什么时候出现 |
|---|---|---|
○ | Static,prerendered as static content | 路由没碰过任何请求时才知道的东西 |
ƒ | Dynamic,server-rendered on demand | 用了 cookies()、headers()、searchParams、no-store 等 |
ƒ Proxy (Middleware) | 单独一行,表示存在请求前拦截 | 项目里有 proxy.ts(或旧的 middleware.ts) |
| Revalidate / Expire 两列 | ISR 的重生成间隔与过期时间 | 有路由配了 revalidate 时才出现,如 ○ /isr 1m 1y |
要注意:16.2.11 的默认输出里已经没有 Size 与 First Load JS 两列了。老教程教你「盯着 First Load JS 别超过 100KB」的读法已经对不上,要看体积得用 next build --experimental-analyze(Turbopack 专用)。
Turbopack 已经是默认
- 构建首行打印
▲ Next.js 16.2.11 (Turbopack)——不用加任何 flag,next build和next dev都默认走 Turbopack。 - 要退回 webpack 得显式写
next build --webpack。遇到某个只支持 webpack 的老插件时才需要。 next build -d打开更详细的构建输出,排查「为什么这个路由变成 ƒ 了」时有用。
next/image 与 next/font 各自解决什么
next/image解决的是布局抖动和流量:强制你提供width/height(或fill)从而预留出位置,按视口发不同尺寸,自动转 AVIF/WebP,默认懒加载。首屏大图要加priority关掉懒加载,否则反而拖慢 LCP。next/font解决的是第三方请求和字体跳动:构建时把字体文件拉进自己的产物、生成@font-face,零外部请求,并自动算size-adjust让回退字体和目标字体度量对齐。- 两者的共同思路是一致的:把只有运行时才暴露的性能问题,搬到构建期强制你解决。这也是为什么它们都带着一点「用起来别扭」——那正是约束在起作用。
# 构建:首行打印 ▲ Next.js 16.2.11 (Turbopack)
npx next build
npx next build --webpack # 需要时退回 webpack
npx next build --experimental-analyze # 看包体积,Turbopack 专用
# 输出(节选):
# Route (app) Revalidate Expire
# ┌ ○ / ← 静态,构建时定格
# ├ ○ /isr 1m 1y ← ISR 也是 ○
# ├ ƒ /dashboard ← 用了 cookies()
# └ ƒ /api/posts ← GET 默认不缓存
# ƒ Proxy (Middleware) ← 存在 proxy.ts
// 首屏大图:给 priority,别让它被懒加载拖慢 LCP
import Image from "next/image";
<Image src="/hero.jpg" width={1200} height={630}
alt="封面" priority />
// 字体在模块顶层调用一次,构建期自托管
import { Inter } from "next/font/google";
const inter = Inter({ subsets: ["latin"], display: "swap" });next build 输出里找 First Load JS 那一列——16.2.11 默认输出已经没有 Size 与 First Load JS 了。想看体积用 --experimental-analyze。同理,ISR 路由显示的是 ○ 而不是老版的 ●,别以为 ISR 没生效。next build 的路由表当成回归测试:本来是 ○ 的页面某天变成了 ƒ,说明有人(或某个新装的库)引入了 request-time API。在 CI 里 diff 这张表,比等线上账单涨了再查便宜得多。三种部署形态的差别不在难易,而在一个问题:你愿不愿意跑一个 Node 进程。这个答案直接决定了你还能用 Next 的哪些功能——不是「配置麻烦一点」,是有些东西根本不存在了。
三条路的取舍
先想清楚要什么,再选平台。
- Vercel(官方平台)优零配置,ISR、按需重新验证、图片优化、流式渲染、proxy 全都开箱即用;连上 Git 就能部署。短供应商绑定;函数时长、带宽、图片优化都按量计费,流量一起来成本非线性上涨。为何「免配置」正是因为平台替你实现了那些框架原语——你享受的和你被绑定的是同一样东西。
- 自托管:
output: "standalone"+ Docker优一个 Node 进程跑全部功能,产物只包含真正用到的依赖、镜像很小;成本可控,可以放进自己的内网。短ISR 缓存与图片优化默认落在本地磁盘,多实例部署时各存各的,要自己接共享cacheHandler和 CDN。为何你拿回了全部控制权,也就一并拿回了平台原本替你做的那些运维。 - 静态导出:
output: "export"优产物是一堆 HTML 文件(落在out/),任何静态托管、对象存储、CDN 都能放,几乎零成本零运维。短大量功能直接不可用,而且多数是构建期硬失败(见下)。为何没有服务器,所有需要「请求发生时才能算」的东西就没有地方安放。
静态导出会失效哪些功能
- 项目里有 Route Handler 就构建失败:
Error: export const dynamic = "force-static"/export const revalidate not configured on route "/api/x" with "output: export". - 页面里用了
cookies()也构建失败:Route /dyn with dynamic = "error" couldn't be rendered statically because it used cookies(). proxy.ts不报错,但被静默忽略,构建末尾提示:⚠ Statically exporting a Next.js application via next export disables API routes and middleware.——这条最危险,鉴权拦截会悄无声息地消失。- 连带失效的还有 Server Actions、ISR、按需重新验证、
next/image的默认优化 loader。等于本章和 15 章讲的大半内容都用不了。
上线之后看什么
- 错误上报接在两处:UI 侧的
error.tsx/global-error.tsx(文件约定见 13 章,React 通用错误边界见 11 章),数据侧的 Server Action 与 Route Handler 的 catch 里。 - Next 生产环境不会把错误详情发给客户端,只发一个 digest(形如
digest: "2227243102@E7")。务必把 digest 一起写进服务端日志,否则用户截图里的那串数字对不回任何一条异常。 - 日志里带上路由名与部署版本号,配合构建输出的 ○/ƒ 表,才能判断一个慢请求是「本该静态却变动态了」还是「后端真的慢」。
// next.config.ts —— 自托管:产出最小可运行产物
import type { NextConfig } from "next";
const config: NextConfig = { output: "standalone" };
export default config;
# Dockerfile:两阶段构建,运行镜像里不带 devDependencies
FROM node:22-alpine AS build
WORKDIR /app
COPY . .
RUN npm ci && npm run build
FROM node:22-alpine
WORKDIR /app
# standalone 已经把用到的依赖打进去了,不需要再 npm i
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
// 静态导出:产物落在 out/,同时失去 Server Actions、ISR、
// Route Handlers、proxy —— 上面那些报错就是证据
// const config: NextConfig = { output: "export" };proxy.ts 的项目上改成 output: "export":构建不会报错,只在末尾打印 ⚠ Statically exporting a Next.js application via next export disables API routes and middleware.——你的登录拦截就这么静默消失了,全站变成公开访问。改这个配置后一定要手测一次未登录访问。output: "export"。别先选了静态导出再回头砍功能。从这里到精通:路线图
地图铺完了,剩下的路要亲手写出来。最后给出难度递进的动手项目、按阶段的资料,以及一份能验出真懂假懂的自测清单。
React 的知识点已经铺完,从「看懂」到「精通」之间隔着的,是几个亲手做完的项目。
动手项目(难度递进)
- ① Todo 应用:useState + 列表渲染 + 受控表单(见 04 章),产出支持增删改、过滤和 localStorage 持久化的单页 Todo。做完自问一句:列表项的
key你用的是数组下标还是稳定 id?——这一条就能验出你有没有真读懂 03 章。 - ② 多页数据应用:React Router(页外自学) + TanStack Query,做一个带路由、搜索与详情页的电影/图书浏览站。重点不在功能而在三种态都要认真做:加载态、错误态、空态(见 11 章),再给搜索框加上防抖与竞态处理(见 05 章)。
- ③ Next.js 全栈项目:App Router + 服务端组件 + Server Actions,做一个含 SSR、表单提交与登录鉴权的博客或留言板,并真的部署上线。鉴权那一步别跳过——它会逼你面对「Server Action 是公开端点」这件事(见 15 章)。
- ④ 深入原理:把 useDebounce/useFetch 等自定义 Hook 抽成一个小库发布,或精读 React 渲染与调度相关源码/文章,能讲清一次更新的完整流程。
资料(按阶段)
- 入门到进阶:react.dev 官方文档(Learn 教程 + API 参考),新版质量极高,值得通读。它的「You Might Not Need an Effect」一节尤其值得反复看。
- 全栈阶段:nextjs.org/learn 官方交互课程,从零搭一个完整应用。
- 原理阶段:react.dev/blog 与 nextjs.org/blog 跟进版本动向——这两个生态每年都在动,本页写的是 2026 年年中的现状,一年后请自行核对。
- 体系查漏:roadmap.sh 的 React 路线图,对照检查知识盲区。
# ① Todo:纯前端,Vite 就够
npm create vite@latest my-todo -- --template react-ts
# ② 多页数据应用:加路由与取数库
npm i react-router @tanstack/react-query
# ③ 全栈项目:Next.js,App Router
npx create-next-app@latest my-blog
# ④ 把自定义 Hook 抽成库发出去
npm init -y && npm i -D typescript tsup vitest
npx tsup src/index.ts --format esm,cjs --dts
npm publish --access public
# 全程别忘了这两条:类型检查 + 测试
npx tsc --noEmit
npx vitest run如果只能带走一样东西,带走这条链路——本页十八章讲的每件事,都是这条链路上的某一环。
从点击到屏幕更新的完整流程
- ① 事件触发:React 在根容器上做事件委托,你的
onClick被调用。 - ② 排队:
setState不是赋值,而是往队列里放一次更新。同一轮里的多次调用会被批处理成一次(见 03 章),setTimeout和 Promise 回调里的也一样。 - ③ 重渲染:组件函数整个重新执行,产出新的元素树。这一步是纯计算,还没碰 DOM。
memo/useMemo影响的就是这一步跑多少(见 09 章)。 - ④ 对账:React 拿新旧两棵树比对,
key决定谁和谁是「同一个」——这一步决定了状态跟着谁走(见 03 章)。 - ⑤ 提交:把差异写进真实 DOM。
useLayoutEffect在这之后、浏览器绘制之前同步跑。 - ⑥ 绘制后:浏览器画完这一帧,
useEffect才异步跑,先执行上一次的清理函数(见 05 章)。
一份自测清单
下面每一条你都能不查资料答上来,这一页就算毕业了:
- 为什么
setCount(count + 1)连写两次只加一次,而setCount(c => c + 1)会加两次? - 为什么
key={index}会让输入框的内容串位?串到哪一行去? memo包住的组件,为什么它内部用了useContext的子组件还是重渲染了?- 依赖数组里少写一个值,会发生什么?
useEffectEvent是怎么解决这个矛盾的? 'use client'标记的是「这个组件在客户端渲染」还是别的什么?- 服务端组件为什么不能被客户端组件
import,却可以当children传进去?
// 把这段贴进任何一个 React 项目,点两下按钮,对照上面的六步看日志
function Trace() {
const [n, setN] = useState(0);
console.log("③ 重渲染,此刻 n =", n);
useLayoutEffect(() => console.log("⑤ 提交后、绘制前"));
useEffect(() => {
console.log("⑥ 绘制后");
return () => console.log("⑥' 下次重跑前的清理");
});
return <button onClick={() => {
console.log("① 事件触发");
setN(c => c + 1); // ② 排队
setN(c => c + 1); // ② 同一轮,批处理成一次重渲染
console.log("② 排完队了,但 n 还是", n);
}}>{n}</button>;
}memo。真实项目里九成的卡顿来自「渲染了不该渲染的东西」或「一次渲染里干了太重的活」,这两类都得先用 Profiler 量出来再动手(见 09 章)。凭直觉优化的结果通常是代码变丑、性能没变。n 是这一次渲染捕获的常量快照,不是一个会变的变量;新值要到下一次渲染才存在。想通了这句,依赖数组、stale closure、为什么要用函数式更新,全都会一起想通。