React 与 Next.js 完整知识体系交互讲解

全景: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>
  );
}
别从旧教程入门。React 在 18→19 之间换过一轮血:类组件与生命周期、componentWillMount 系列、Enzyme、create-react-app、Pages Router 的 getServerSideProps——这些在新项目里都不该再出现,却仍占据着搜索结果的前排。
版本基准 React 19.2 + Next.js 16,服务端组件、Server Actions、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)。
把「函数重跑」误解成「组件被销毁重建」。重跑只是函数体又执行了一遍,DOM 节点和 state 都好端端在原地。新手看到 console.log 反复打印就以为组件在疯狂重建,于是到处套 memo——绝大多数情况下重跑一次函数的开销可以忽略不计,过早优化换来的是一堆看不懂的代码(见 09 章)。
写组件时反复问自己一句:这个变量能不能从已有的 state 算出来?能算出来的(总价、筛选结果、是否显示按钮、表单是否合法)就当场算,绝不给它 useState。这一条能预防掉新手一半以上的状态 bug——两份数据只要存在,迟早会对不上。

上手:跑通第一个 React 应用

先把东西跑起来再谈原理。这一章解决建项目、看懂入口文件,以及配齐能救命的开发工具。

React 官方自己不提供脚手架了——它把「怎么建项目」整个交给了框架。所以写第一行代码之前只需要做一个决定:这个应用需不需要服务端

两条路,先选一条

  • 纯前端应用(后台管理、内部工具)→ Vitenpm create vite@latest my-app -- --template react,冷启动到可用是秒级;
  • 要 SEO、要服务端渲染、要在同一仓库写后端接口Next.jsnpx 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 年起就不再推荐它,仓库也已归档。但它仍占据着大量搜索结果与旧教程的前排;照着建出来的项目一上手就是过时的构建链,看到这个命令直接换一篇
选框架的口诀:把页面链接发给一个没登录的人,他打开时必须立刻看到内容吗?必须——用 Next.js;不必须(后台、编辑器、仪表盘)——Vite + React Router 就够,构建快得多。

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 标签处理
「JSX 是 React.createElement 的语法糖」——这句话在 React 17 之前是对的,今天的产物里根本没有 createElement。真正会咬人的是反过来的情况:在一个 automatic runtime 的项目里手写 React.createElement(...),而文件顶上没有 import React,直接抛 ReferenceError: React is not defined
想弄明白某段 JSX 到底是什么,别猜,一行命令就能看到产物:npx esbuild --jsx=automatic 文件.jsx(可用,也支持从标准输入读)。属性名怎么变、children 放在哪、Fragment 变成什么,一眼就清楚了。遇到「这个写法到底合不合法」的争论,这是最快的裁判。

JSX 长得像 HTML,但它是 JS。凡是和 HTML 不一样的地方,原因都指向同一件事:属性名最终要变成一个 JS 对象的 key,而 classfor 在 JS 里是保留字。

五条硬规则

  • class 写成 classNamefor 写成 htmlForReact 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"iffor 是语句,没有值,而 {} 要的是一个值。
  • 非字符串的属性值要用 {}width={100} 传数字,width="100" 传字符串。布尔属性 disabled={true} 可简写成 disabled

最高频的坑:0 会被渲染出来

{} 里的 falsenullundefinedtrue 都会被跳过,但0NaN 不会。一次渲染的结果:

你写的页面上实际出现
{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,因为开发时手头的测试数据往往从来不是空的。
一条不用动脑就能执行的规则:只要 && 左边是数字.lengthcountindextotal),当场把它改成比较式 > 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.jsObject.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.titlenull——渲染出的是空白,不是「无标题」。后端字段可空时别指望默认参数,要在组件里写 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 同理。刚从模板语言转过来的人几乎人人踩一次。
组件内部按固定顺序排版:所有 Hook → 提前 return 处理异常态 → 计算派生数据 → 主干 JSX。这个顺序同时满足了 Hook 规则和可读性,照着排几乎不用再思考。看到一个组件读不懂时,第一件事往往就是把它重排成这个顺序。

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 = 99setObj(obj),界面还是 0

请注意这不是「渲染了但看不出变化」,而是根本没有渲染。React 在 setter 内部就发现新旧值全等,直接退出,连排队都不排。这也是这个 bug 极难排查的原因——你明明调了 setter,没有任何报错,界面就是不动。

正确写法速查

要做的事不要(原地改)要(造新值)
改对象字段user.name = nsetUser(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 = truesetArr(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 } },
}));
最隐蔽的一种是先原地改再调 settertodos[0].done = true; setTodos(todos)。因为 setter 确实调了,你会觉得写法没毛病,但组件函数一次都没重跑。同类陷阱还有 sortreversesplice 这些原地方法——它们看着像在算新值,其实改的是原数组,顺带把 state 也污染了。
写不可变更新写到第三层嵌套就该停手了,那是 state 结构有问题的信号。两条出路:把 state 拆平(用 id 做索引的扁平表,而不是层层嵌套的树),或者上 Immer 的 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=0a=1 b=1a=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。
一次交互里更新五个 state 也只渲染一次,所以不要为了「减少渲染次数」把不相关的 state 硬塞进一个对象——那只会让不可变更新写得更啰嗦。真正该合并的信号是它们必须同时变、单独变就是非法状态,而那种情况其实更适合 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]} />
    </>
  );
}
用 props 给 state 当初始值——useState(props.value)——是最常见的失同步来源,prop 从 2 变 5 之后界面纹丝不动。如果你确实想要「prop 变了就重置内部状态」,正解是换 key(见下面那张卡),而不是加一个 useEffect 去手工同步;后者会多渲染一轮,还容易写出死循环。
一条决策链,从左往右走,能停就停:能算的不存 → 就近存放 → 兄弟共享才提升 → 只是穿层就用 children 组合 → 跨很多层才上 Context。别一上来就把 state 都堆到顶层组件,那会让整棵树跟着一起重渲染,而且每个子组件都不再能独立复用。

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} />
「我这列表只是展示,用 index 没事」是最常见的自我说服。只要行里有输入框、有展开折叠,或子组件内部有 useState,错位就会发生,而且静默失败,通常要等用户报 bug 才发现。React 只在你完全不写 key 时才提醒 Each child in a list should have a unique "key" prop.,写了个错的它不管。
id 要在数据产生的那一刻就确定下来,而不是渲染时现算。后端返回的数据用后端主键;本地新增的项在调 setter 那一步就 crypto.randomUUID() 生成好、存进对象里。判断一个 key 合不合格只有一个标准:同一条数据在两次渲染之间,key 是不是同一个值

既然 key 决定的是身份,那么换掉 key 就等于对 React 说「这是另一个组件了」。旧的整个卸载,新的从初始 state 重新挂载。这是重置组件状态最省事、也最不容易写错的手段。

切换用户时重置表单

一个 EditForm 内部存着草稿 state,父组件在 u1u2 之间切换。两种写法:

  • 不加 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)} />
key 加错层级是最常见的失败:想重置的是 EditForm,key 就必须加在 <EditForm> 这个元素上;加在它内部的 <div> 上,React 重建的只是那个 div,组件的 state 一点没动,而且没有任何报错,你只会看到「重置不生效」。
一句口诀分清两种重置:要清空一两个字段就调 setState;要让这个组件忘掉它的全部记忆就换 key。另外 key 换成什么值也有讲究——用那个「决定身份的东西」(用户 id、文档 id、会话 id),不要用时间戳或随机数,否则每次重渲染都会重建。

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>;
}
两个高频坑。一是在 reducer 里原地改 state(state.items.push(...) 然后 return state),和 useState 一样会被 Object.is 挡下来,组件一次都不重渲染。二是 default 分支写成 return statetype 打错一个字母时界面什么反应都没有,排查半天;改成抛错后立刻能看到 未知 action:什么鬼
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 仍然是字符串typeofstring),要算数就自己转,别指望它给你数字。
  • <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"}
表单 state 用一个对象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 datafalse,取出来是 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 执行期间isPendingtrue、子组件里的 pending 也为 true,结束后双双回到 false

两个必须知道的行为

  • useFormStatus 必须放在 form 的子组件里。把它写在渲染 <form> 的那个组件里,pending 全程是 false,连一次重渲染都不会发生;挪进 <form> 内部的子组件后才正常读到 truefalse。它读的是「我上方最近的那个 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
在 action 里把错误当返回值return { ok: false, msg })而不是抛异常。抛出去会冒到错误边界,整块界面被替换掉;作为返回值它会稳稳落进 state,用户看到的是表单原地报错、内容还在(见 11 章)。useActionState 的第三个返回值 isPending 已经够用,只有当提交按钮被拆进独立子组件时才需要 useFormStatus

校验难的从来不是判断对错,而是决定什么时候告诉用户他错了。太早唠叨(刚敲第一个字符就红一片)和太晚才说(填完二十项一起爆)一样让人放弃。

时机:一条经过验证的默认策略

时机体验建议
onChange还没打完就报错,体验最差不要作为首次校验时机
onBlur离开字段才说,符合直觉单字段校验的默认选择
onSubmit最晚,但跨字段校验只能在这兜底,且必做

综合起来的策略是:首次校验放在 onBluronSubmit;某个字段一旦报过错,就把它切换成 onChange 实时校验——用户在改错时能立刻看到错误消失,这个正反馈很值钱。原则一句话:错了才实时,没错前别催。

原生校验属性:免费但不好看

requiredtype="email"min / maxminLengthpattern 这些属性浏览器直接支持,白送你一个 validity 对象。type="email" 填「不是邮箱」→ validity.typeMismatchtruemin="18" 填 12 → rangeUnderflowtruepattern="[A-Z]{3}"abpatternMismatchtrueform.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("邮箱格式不对");
把前端校验当成安全边界。它只是体验优化,绕过它只需要一条 curlrequiredpatterndisabled 全都在用户那一侧,改一改 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=1layout cleanup n=0layout n=1effect cleanup n=0effect n=1

关键在两点:清理一定跑在同名 effect 重跑之前useLayoutEffect 整个跑完之后,useEffect 才开始。useLayoutEffect 在浏览器绘制前同步执行,用来「读了真实布局、又要赶在用户看到之前改掉」(测量气泡高度再定位是唯一常见场景);它同步阻塞绘制,滥用直接拖慢帧率;而服务端渲染时它不执行、也不会有任何告警renderToString 产物正常、console.errorconsole.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>;
}
依赖写对象或数组字面量,effect 就永远在重跑。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 值说明
不开 StrictMode3看起来一切正常
开 StrictMode6翻倍了——当场证明这个组件不纯

换句话说,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=0effect n=1 就是这条;
  • 清理函数拿到的是它自己那一帧的闭包,所以它关掉的一定是它自己开出去的那个连接、那个 timer id,绝不会串台。这也是为什么必须在 effect 内部创建资源,而不是放模块顶层。

三个必须清理的典型

开出去的东西清理动作不清理的后果
setInterval / setTimeoutclearInterval / clearTimeout组件没了定时器还在跑,回调里 setState 触发对已卸载组件的更新
addEventListenerremoveEventListener必须传同一个函数引用监听器越积越多,一次事件触发 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 变量再两处引用。
写 effect 时把自己问成两句话:「我开出去了什么?」「怎么把它收回来?」凡是回答得出第一句的,第二句就必须落成 return。反过来,如果一个 effect 根本没开出去任何东西(只是算了个值、setState 一下),那它多半根本就不该是 effect——见本章最后一张卡。

陈旧闭包(stale closure)不是 React 的怪癖,而是 JavaScript 闭包的必然结果:一个函数捕获的是它被创建那一刻的变量,之后外面再怎么变都与它无关。React 每帧重造函数,于是「哪一帧造的」就决定了「读到哪一帧的值」。

屏幕上是 3,定时器读到的永远是 0

右侧那段代码在实验台跑出来的结果是——连点三次按钮后 DOM 上显示 3,而每秒跑一次的定时器回调打印的始终定时器读到 count=0,一次都没变过。

原因:[] 让 effect 只在挂载后执行一次,那次执行时 count0setInterval 的回调捕获了那一帧的 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 挡住会得到一个连着旧房间却显示新房间名的界面。先想清楚这个值到底该不该触发重跑,再选工具。
诊断口诀:凡是「界面明明变了,可回调里读到的还是老值」,一律先去看依赖数组。特别检查 setIntervalsetTimeout、事件监听、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 这类陪跑数据;典型的「希望」是 roomIduserId 这类决定同步对象是谁的标识。

在 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
  • 代价是多一个依赖、多一套缓存心智(staleTimegcTime、查询键设计)。但只要你的应用超过三五个数据源,这个代价一定比手搓便宜;
  • 还有一条更彻底的路:如果用 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" 再当错误处理。
分清两种状态,是选型的分水岭:服务器状态(远端数据的本地副本,会过期、要重新验证、可能被别人改)交给 TanStack Query 或 SWR;客户端状态(弹窗开关、当前标签页、表单草稿)才用 useState 或状态库(见 07 章)。把服务器状态硬塞进全局 store 手动维护,是中大型项目最常见的自找麻烦。

新手写出的 effect,大概有一半是多余的。根源是把 effect 当成了「生命周期钩子」——「数据变了我要做点什么」。effect 的正确定位只有一个:把组件和一个 React 管不着的外部系统同步起来。不涉及外部系统的,都不该是 effect。

三分口诀

这段逻辑是……放哪例子
渲染时就能算出来的直接在函数体里算,昂贵时套 useMemo全名、筛选后的列表、总价、是否可提交
用户做了某事才触发的事件处理函数提交表单、埋点上报、弹 toast、跳转
和外部系统保持同步的才轮到 useEffectWebSocket、订阅、手动操作 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.titlelocalStorage、媒体播放器)、网络连接、第三方图表/地图实例、全局事件总线——这些都是。而「另一个组件的 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]);
「effect 里 setState」是死循环的标准配方。忘写依赖数组、或者依赖里放了每帧新建的对象,effect 跑完 setState 触发渲染,渲染又触发 effect……React 会抛出 Error: Maximum update depth exceeded.。但更糟的情况是它死循环,只是每次交互多跑一两趟渲染,你根本不会发现——直到列表变长后界面开始发卡。
删 effect 的实操顺序:先看它有没有 return 清理。没有清理的 effect,八成没在跟任何外部系统打交道,值得高度怀疑。再问一句「它的第一行是不是 setXxx」——如果是,几乎可以断定这个 state 该被删掉,换成渲染时直接算。

核心 Hooks

useRef、useMemo、useContext、useTransition 到 React 19 的 use()——逐个讲清机制、适用场景与各自的代价。

useRef 返回一个跨渲染始终是同一个{ current } 对象。它和 useState 的唯一区别只有一条,但这条决定了一切:.current 不会通知 React。所以它装的是「组件要记住、但屏幕不关心」的东西。

屏幕会撒谎

实验台里一个组件同时持有 stateref,连点三次「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.currentnull,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 完全合法,内层天然覆盖外层。一个只有 useContextHeading 组件,套在三层逐级加一的 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>
  );
}
渲染 Provider 的那个组件,自己读不到自己提供的值。Section 里先 useContext<LevelContext value={level + 1}>,三层嵌套打印出的是 012——每层读到的都是上一层的值。因为 useContext 向上查找的起点是当前组件的父级,自己刚创建的那个 Provider 在树上位于自己下方。想在同一层用到新值,得自己算,别指望读回来。
判断一个组件能不能取到某个 context,只看一件事:它在渲染树上是不是那个 Provider 的后代。和文件放在哪、从哪 import、元素在哪一行写出来通通无关——把元素写在外层、当 children 传进内层 Provider,它读到的仍是内层的值,因为它最终是在内层被渲染的。

当一次交互要同时改好几个 state、而且它们之间有约束时,useState 会让「更新逻辑」散落在十几个事件处理函数里。useReducer 把这些逻辑全部收进一个纯函数——组件只负责说「发生了什么」(dispatch 一个 action),怎么变由 reducer 独家决定。

比 useState 强在哪

  • 逻辑集中:想知道 items 有几种变化方式,看 reducer 的 switch 就够了,不用翻遍组件;
  • 可测试:reducer 是不依赖 React 的纯函数,expect(reducer(旧态, action)).toEqual(新态) 直接跑,不用渲染任何东西(见 12 章);
  • 非法状态更难表达:把「加载中 / 数据 / 错误」三态塞进一个 reducer,就能保证不会同时出现 isLoading: trueerror
  • dispatch 的引用跨渲染恒定(为 true)。这意味着它可以放心地传给 memo 子组件、放进依赖数组,完全不需要 useCallback——这是它一个被低估的实际好处。

三个进阶用法

用法写法解决什么
惰性初始化useReducer(reducer, 种子, init)初始状态需要计算时,init 只在挂载时调一次;直接传第二参数则每次渲染都会求值
复用 init 做重置case "reset": return init(action.payload)初始化和重置共用一份逻辑,不会两处走样
配合 Context 下发statedispatch 分成两个 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} />;
}
reducer 里改原对象再返回它,屏幕纹丝不动。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 新增了第二个参数 initialValue19 真正新增的是 useuseOptimisticuseActionState 三个(外加 19.2 的 useEffectEvent)。把 useTransition 归到「React 19 新特性」是网上大量文章的通病。

两者的分工:你握着谁

useTransitionuseDeferredValue
你包裹的是你自己发起的那次更新(有 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 存在的理由。
选哪个只看一件事:你够不够得着那个 setter。够得着(是你自己 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()生成服务端和客户端一致的唯一 id19.2.8 生成的形如 _r_0__r_1_——不是 React 18 的 :r0: 格式,老文章里的冒号写法已过时。专为 htmlForaria-describedby 这类关联而生,不要用它当列表的 key
useImperativeHandle(ref, fn, deps)规定父组件通过 ref 拿到什么把整个 DOM 节点换成一组你挑好的方法(focusscrollToclear),封住其余能力
use(资源)在渲染中读 Promise 或 ContextReact 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>;
}
忘记包 Provider 不会报错useContext 会安静地返回 createContext 的默认值。无 Provider 时拿到 null,报错要等到下一行解构才炸,且错误信息里根本不提 Context。所以默认值给 null 并在自定义 Hook 里主动抛错。
判断该不该上 Context 的口诀:只透传一两层就别用。多传一个 prop 的成本远低于多一个 Context 的心智负担,Context 真正的收益出现在三层以上、或消费点分散在很多分支的时候。

很多人以为在中间套一层 memo 就能把 Context 的重渲染挡在外面——挡不住。memo 比较的是 props,而 Context 的值是从 fiber 树上另开一条线直接送到消费者手里的,根本不经过 props,自然也就不受 memo 管辖。

memo 组件不重渲染,它内部的消费者照样重渲染

结构是 Provider > memo(Middle) > Consumer1 + Consumer2Middle 不接收任何 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,而只负责触发修改的组件(按钮、表单)根本不关心当前值是多少,它只要那个 dispatchsetter。把两者塞进同一个对象,等于逼着按钮跟着值一起重渲染。

拆开后,派发方法的那个 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 都很难稳住(依赖数组里挂了七八项)。
拆分口诀:值一个 Context,派发一个 Context。改用 useReducerdispatch 引用天然稳定,派发侧的 Provider 连 useMemo 都不用写,纯赚。这一刀通常就解决了 Context 八成的性能问题。

选型的第一刀不是切库,而是切状态的种类。远端数据在本地的副本,和纯前端的界面状态,根本不是一类东西——把它们塞进同一个 store 是绝大多数状态管理灾难的起点。

先划这条线:服务器状态 vs 客户端状态

服务器状态(server state)是别人家的数据在你这儿的缓存副本:用户资料、商品列表、订单详情。它的真身在数据库里,你手上这份天生就是过期的。它带来的问题全是缓存问题——何时失效、并发请求怎么去重、切回标签页要不要重拉、失败怎么重试。这些不是 useState 能解决的,也不该由你手写,交给 TanStack Query 或 SWR(见 05 章讲的「别用 useEffect 取数据」)。

客户端状态(client state)是没有远端真身的东西:弹窗开不开、侧边栏折不折叠、表单草稿、当前选中的标签页。它就住在浏览器里,不存在过期一说,这类才轮到 useState 和状态库出场。

划完这条线你会发现,大部分应用里客户端状态少得可怜——多到需要上 Redux 的,往往是把服务器状态错当客户端状态在管。

客户端状态的横向对比

方案订阅粒度样板量要 Provider什么时候选它
useState + 提升——状态只被一两个相邻组件用。默认选它
useStateuseReducer + Context整个 Context很低低频、跨层级。主题、当前用户、i18n
Zustand选择器级很低中高频、消费点多。需要外部库时的默认答案
Jotai/Valtio原子/属性级Jotai 建议要状态天然碎片化、组合关系复杂
Redux Toolkit选择器级大团队要强规范、复杂中间件、时间旅行调试

Zustand 靠选择器做到按字段订阅——这正是 Context 做不到的那件事。一个含 itemscoupon 的 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 里出现 loadingerror 字段,就是走错路的信号。
开工前先问一句:这个值刷新页面后能从服务器再拿回来吗?能,就是服务器状态,归 TanStack Query/SWR;不能(弹窗开关、表单草稿、当前标签页),才归 useState/Zustand。这一问能挡掉大半的选型纠结。

状态管理库不是「更好的 useState」。它替你做成的核心只有一件事,而这件事恰恰是 Context 结构上给不了的:让每个组件只订阅自己真正用到的那一小片状态。其余的中间件、持久化、DevTools,都是围着这一件事长出来的配套。

先看手搓版会卡在哪

不用库也能做全局 store:useReducer 攒一份状态,用 Context 广播下去,几十行搞定。问题在下一步——消费端只能整份拿走

一个装着 userunread 的 Context store,两个消费者各自只读一个字段,还都用 memo 包好了。只改 user,日志是:

  • [ctx] User 渲染
  • [ctx] Badge 渲染

Badge 一个字节的相关数据都没变,照样跑了一遍。想修好它,你得给自己的 store 加一张监听表、在每次变更时对每个订阅者跑一遍选择器、比较新旧结果、决定要不要触发更新,还得让这套东西在并发渲染下不出错。写到这一步,你已经在重造一个状态管理库了——而且大概率造得更差。

选择器就是订阅声明

库的做法是把「读哪一片」从消费动作里显式提出来。useApp((s) => s.unread) 这一行既是取值,也是在说「只有这一片变了才叫醒我」。同一组结构换成 zustand 的对照:

操作Context 手搓版zustand 选择器版
只改 userUser、Badge 都重渲染只有 User 重渲染
只改 unreadUser、Badge 都重渲染只有 Badge 重渲染
组件外读写状态做不到,值锁在树里useApp.getState() 直接读到 1
要不要包 Provider必须包,且必须在消费者上方不需要,store 就是个模块

「不需要 Provider」这条比看起来重要:状态不再挂在树上,请求拦截器、路由守卫、埋点回调这些不是组件的地方也能读写它。Context 做不到这件事,因为它的值本来就是靠树的位置传递的。

选择器之外的配套

  • 持久化:一行中间件接管本地存储。给 store 包上 persist(..., { name: "app" }),改完状态后 localStorage 里躺着 {"state":{"user":"张三","unread":5},"version":0},刷新自动回填,不用自己写读写与序列化。
  • DevToolsdevtools 中间件把每一次变更连同 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 订阅下来再解构的。
什么时候从 Context 换成库,有一条很干脆的分界线:当你开始想给自己的 Context store 加「选择器」时,停下来装个库。到这一步之前,Context 加拆分完全够用;到了这一步,你要补的是订阅表、比较逻辑和并发安全,那是别人已经写好并被几十万个项目验过的东西,没有理由自己再写一遍。

自定义 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——它能持有状态、能注册副作用、能订阅外部数据,因而与调用它的组件的生命周期绑定

判据很简单:函数体内出现了 useStateuseEffectuseContextuseRef 中任何一个,它就是 Hook,就必须遵守 Hooks 规则、就必须以 use 开头。反之,一个纯粹的 formatPrice(n) 永远不该叫 useFormatPrice

为什么名字必须以 use 开头

这不是「大家约定俗成图个好看」,而是工具链唯一的识别依据。JavaScript 里没法在运行时判断一个函数是不是 Hook,所以 React 生态一致选择了命名约定作为契约:

  • eslint-plugin-react-hooksuse 前缀决定「要不要检查这个函数里的 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 或外部 store
别指望自定义 Hook 能让两个组件共享同一份状态。两个组件调用同一个 useCounter,点其中一个,另一个纹丝不动。发现「怎么改了这边那边没跟着变」时,说明你要的是 Context 或全局 store,抽 Hook 解决不了。
起名前先自问:这个函数体里调用 Hook 了吗?调了就必须 use 开头,没调就绝对不要用 use 开头。前缀是给 ESLint 和 React Compiler 看的契约,不是修辞——名字取错,静态检查会整段失效或整段误报。

抽取的信号只有一个:组件里有一段代码和这个组件长什么样毫无关系。它只是在管理某种状态、订阅某个数据源、或者协调某段异步流程——把这段搬走,组件就只剩下描述 UI 的部分了。

三个信号

  • 成团出现的 state + effect。几个 useState 和一个 useEffect 总是绑在一起改,它们其实是一个概念,只是没有名字。
  • 同一段逻辑在第二个组件里出现了。第一次写别急着抽,第二次出现时抽——此时你才真正看清哪些是共性、哪些是差异。
  • 组件读起来像流水账。函数体前四十行全是订阅、清理、防抖、解析,最后三行才是 JSX。抽走后组件恢复成「一眼看懂」的状态。

几个真实例子

Hook它封装了什么为什么值得抽
useLocalStorage读初值、写回、JSON 序列化初始化要惰性读、每次变更要同步写,两段逻辑必须成对出现
useDebounce定时器 + 清理清理函数一忘就漏定时器,封装一次全站受益
useMediaQuery订阅 matchMedia 变化外部数据源,订阅/退订样板固定
useOnlineStatus订阅 online/offline 事件同上,且需要服务端快照兜底(见 07 章)

useLocalStorage 可用:初始渲染读到 初始 且写入了存储,点击后界面与 localStorage 同步变成 已改useDebounceqa 改成 ab 的瞬间显示 ab|a(原值已变、防抖值还没跟上),等过延迟后变成 ab|ab

不该抽的情况

  • 只有一处使用,且逻辑就三五行。抽出去只是把代码搬到另一个文件,读者从此要跳两个文件才能看懂一件事。
  • 抽出来的 Hook 需要一堆参数和开关。签名里出现 modeenabledvariant 这类分支参数,说明两个调用方的需求其实不一样,硬合并只会让两边都难改。宁可有两个各自清晰的 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 十有八九签名是错的,而改一个已被两处依赖的 Hook 比当初多写一遍还贵。

自定义 Hook 的返回值就是它的公开 API。两个决定最要紧:用数组还是对象,以及返回的函数引用稳不稳定——后者会直接影响调用方能不能正确写依赖数组。

数组还是对象

数组 [a, b]对象 { a, b }
调用方改名解构时自由命名要写 { a: myA },啰嗦
只取其中几个要靠位置占位直接按名字取
加新返回值只能往后追加随便加,不影响老调用方
适合恰好两项、地位对称三项及以上,或有可选项

判断很干脆:返回两项、且调用方大概率要改名(因为会在同一组件里用两次)→ 数组,这是 useState 立下的规矩,useToggleuseCounter 都该照做。同一组件里两次调用 useToggle 得到 open/toggleOpendark/toggleDark,互不干扰。其余一律返回对象——尤其是带 loadingerrorrefetch 这类可选项的,用数组会逼出 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,等于白做——调用方拿到的对象每次渲染都是新引用,解构出来的函数虽稳,但把整个对象写进依赖数组照样每次都变。返回对象时,对象和它里面的函数要一起稳。
自定义 Hook 返回的每一个函数都该是稳定引用,这是 Hook 作者对调用方的基本承诺。检验办法:假想调用方把你返回的东西全塞进 useEffect 的依赖数组,如果 effect 会每次渲染都重跑,说明你的 API 有问题。

Hooks 的两条规则——只在顶层调用只在组件或自定义 Hook 里调用——不是风格偏好,而是实现机制的直接推论:React 靠调用顺序把每次渲染的 Hook 和它存的状态对上号,顺序一变就全线错位。

为什么是顺序

useState("A") 这行代码里,React 拿不到任何标识——没有名字、没有 key,只有一个初值。它怎么知道这次渲染的 useState 对应上次那个?答案是:按顺序数下标

每个组件实例(fiber)上挂着一条 hook 链表。渲染开始时指针归零,之后每调用一个 Hook 就往后挪一格,从当前格子里取出上次存的状态。这个设计换来了极简的 API(不用给每个 state 起名字、不用注册),代价就是顺序必须每次渲染完全一致。写在 if 里,条件为假时那一格被跳过,后面所有 Hook 集体前移一格,从此张冠李戴。

条件调用 Hook 的报错原文

组件里第二个 useStateif (show) 包着,先用 show=true 渲染再改成 false,报错:

  • Rendered fewer hooks than expected. This may be caused by an accidental early return statement.
  • 反过来先 falsetrue,报的是 Rendered more hooks than during the previous render.

同时 React 还会打出 React has detected a change in the order of Hooks called by Bad2.,后面跟着 Previous renderNext render 两列对照表,第 2 行是 undefineduseState,并用 ^^^^^ 标出错位点。注意「提前 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 changedContext changedThe 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 去刷,是新手最常见的时间黑洞。
  • 每一个 memouseMemo 都是永久的维护成本:多一个依赖数组要跟着代码演进、多一份内存常驻、每次渲染多一轮比较。更严重的是依赖写漏了会变成极难查的陈旧值 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 真正省下的那部分
十秒钟的分诊法:打开 DevTools 的「Highlight updates」,在输入框里敲一个字。只有输入框和字数统计闪,就没有性能问题,收工;整页都在闪,才轮得到你去 Profiler 里查是谁。这一步能砍掉九成的无效优化。

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 + Consumer2Middle 不接收任何 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 里翻半天快得多。

useMemouseCallback 不是免费的:每次渲染都要存一份值、逐项比一遍依赖数组,还要你终身维护那个数组。它们只在下游能省下更大一笔开销时才划算,否则纯属噪音。

先认清它们的成本

  • 不省渲染次数。组件该重跑还是重跑,函数体每一行照样执行到 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 的子组件。子组件反正每次都重渲染,引用稳不稳定它根本不看,你白花了一次依赖比较和一份内存。先给子组件包 memouseCallback 才有意义,顺序反了等于零。
拿不准就照这句话判:「下游有没有人在乎这个引用变没变。」传给原生 DOM 属性的、传给没包 memo 的子组件的、只在本组件内部用的,一律不包。只有 memo 子组件的 prop、别人的依赖数组、以及真的以毫秒计的计算,才值得掏这笔钱。

React Compiler 是一个独立的构建期工具,1.0 已经稳定发布。它读懂你的组件在算什么,自动把结果缓存起来——目标是让手写的 useMemouseCallbackmemo 变得不必要。

它做的事,用编译产物说话

把一个连 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 自带);为 1718 时导入 react-compiler-runtime这个包要自己额外装
  • Next.js 16reactCompiler 已从 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;
}
最大的坑是「以为开了就全覆盖」。给 props 属性赋值(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 用下标是双重灾难:不但增删时错位,滚动本身就在不断改变每一行的下标,等于每滚一格全表重建。务必用 key={it.id}。另外记得虚拟化后浏览器的 Ctrl+F 搜不到没渲染的行,这条要提前跟产品说清楚。
上虚拟化之前先做一道算术题:一屏能看几行,数据有几行。比值小于三十倍就先别动,改成分页或搜索通常更划算。真要上就直接用 react-window 或 TanStack Virtual,自己手写的版本活不过第一个不定高需求。

前面五卡治的都是「跑起来之后卡」。这一卡治的是「打开就慢」——用户下载并解析的每一个字节都要花时间,而首屏真正用得上的往往只是其中一小部分。

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 每次归零,而控制台一个报错都没有
分割的收益排序是路由 > 重依赖组件 > 模态框 > 其它,照这个顺序切,切到收益变平就停。用 Next.js 的话路由级已经自动切好了,你要补的只是那几个几百 KB 的重依赖。

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: ReactNodeReactNode 是最宽的那个——元素、字符串、数字、数组、nullundefined、布尔全都算。想要「必须传且不能为空」就用 children: ReactElement,但这会拒掉纯文本,多数时候管太宽了。
  • interfacetype 在这个场景下几乎等价,选哪个都不影响正确性。一条能用的规矩:要和原生元素的 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>;
}
props 里忘了声明 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) => ...,然后就再也不用想 FCVFCFunctionComponent 这些名字了。它们能做的这个写法全能做,反过来不成立。

Hooks 的类型几乎全靠初始值推断,所以绝大多数时候你一个类型都不用写。要显式插手的只有三处:初始值撑不起未来的值、ref 的两种用途、以及 reducer 的 action。

useState:只在初始值不够代表全集时写泛型

  • useState(0) 推断出 numberuseState("") 推断出 string,这些都别画蛇添足。
  • 要写泛型的高频场景是初始值为 nulluseState(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.textaction.id 都能安全访问,而在别的分支里访问它们会直接报错。
  • default 分支里写一句 const never: never = action。所有分支都处理完时 action 的类型是 never,赋值合法;哪天有人加了新的 action 却忘了写 case,这一行就会在编译期红给你看。这是全书性价比最高的一个类型技巧。
  • reducer 的两个参数和返回值都标上类型后,useReducer(reducer, 初值)statedispatch 全部自动推断,调用处不需要任何标注。
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,补上泛型即可。
一条能覆盖九成情况的规矩:初始值能代表这个 state 未来所有可能的值,就别写泛型;不能,就必须写useState(0) 不用写,useState(null)useState([])useState("idle") 这三种一律要写。

React 的事件对象是合成事件,类型全部带一个泛型参数指明「事件发生在哪种元素上」。真正的分界线是:写在 JSX 里的内联箭头函数能自动推断,抽成独立函数就必须自己标

能不标就不标

  • onChange={e => setName(e.target.value)} 写在 input 上时,e 的类型由 inputonChange 属性签名反向推断出来,不用写一个字。这是最常见也最推荐的写法。
  • 一旦把处理函数抽成 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

targetcurrentTarget 的差别不是 TS 的怪癖,是 DOM 本来的语义:target 是事件最初发生的那个节点(可能是按钮里的图标),currentTarget你挂监听的那个节点。ChangeEvent 之所以能直接用 e.target.value,是因为 React 在它的类型里特意把 target 也收窄了。

不靠记忆:从 JSX 属性反查

  • 编辑器里查:在 JSX 的属性名上按住 Ctrl 点进去,或者直接把鼠标悬停在内联箭头函数的 e 上,浮层里写的就是你要的类型全名,抄下来即可。这比背表可靠得多,尤其是 onKeyDownonDroponPointerMove 这种不常写的。
  • 代码里抠:用 Parameters<NonNullable<ComponentProps<"input">["onChange"]>>[0] 把参数类型直接算出来。这个式子能通过类型检查,并且拿到的 e.target.valuestring抽公共处理函数时这招最省事——元素换了,类型自动跟着换。
  • 元素类型名本身也不用背:JSX 标签名首字母大写加上 HTML 前缀、加 Element 后缀,input 就是 HTMLInputElementaHTMLAnchorElement(这个不规则的自己查一下)。
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——它才是你挂监听的那个元素,类型也是准的。
优先写内联箭头函数,让 TS 自己推断,省掉一整类烦恼。真要抽成独立函数时不要凭记忆敲类型名——把鼠标悬停在内联版本的 e 上,浮层里显示的全名直接抄走。这条对 onKeyDownonDrop 这些冷门事件尤其管用。

自己封装的 Button 应该能像原生 button 一样用——typedisabledaria-*、各种事件都照收。手写这几百个属性不现实,ComponentProps 一行就把它们全借过来

三个工具类型

写法含义什么时候用
ComponentProps<"button">原生 button 的全部 props,refReact 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 里拿到了真实节点,tagNameBUTTONdisabledtrue,渲染出的 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>;
}
别再为 ref 去套 forwardRef 了。React 19 里 ComponentProps<"button"> 本身就含 ref解构出来铺上去即可,跑通、类型检查也过。反过来,如果你解构了自定义属性却忘了 ...rest,使用者传的 onClickdisabled 会被静默吞掉——类型全绿,行为全丢。
封装任何一个「看起来像原生元素」的组件时,第一行就写 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——它逼你在使用前做一次收窄(typeofin、或者一个类型守卫函数),安全边界仍然完整,只是把检查从编译期挪到了你显式写的那一行。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>
  );
}
别在 Suspense 子树里做不可重复的副作用。挂起会让组件函数被完整重跑,一个挂起的组件函数被调用了 2 次——如果你在函数体里直接发埋点、写全局计数器或触发第二个请求,它们都会执行两遍(开发环境叠加 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 松在哪、紧在哪

规则普通 Hookuse()
必须在组件顶层调用必须不必,可以写在 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>
「页面一直转圈但没有任何报错」几乎总是 Promise 没缓存。每次渲染新建 Promise 时,组件函数被反复调用 9 次而 HTML 始终停在 <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 Promisefetch(...)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):父级只要有 transformfilteropacity < 1position: fixed,就会创建新的层叠上下文,子元素的 z-index 再大也翻不出去。这就是「我 z-index 写了 9999 弹窗还是被盖住」的真正原因——不是数字不够大,是根本不在同一个上下文里比较。
  • 所以 Portal 的适用面很窄也很明确:Modal、Drawer、Tooltip、Toast、下拉菜单。除此之外基本用不上。

反直觉的关键点:事件沿 React 树冒泡

配置:一个 <Panel> 组件带 onClick,它内部用 Portal 把一个按钮渲染到 body 下的 #portal-host。点击那个按钮,输出:

  • 按钮的 DOM 父节点是 portal-hostcontainer.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>
  );
}
「点击外部关闭」最容易在这里出错。因为合成事件按 React 树冒泡,点击弹窗内部的按钮时,事件会一路冒到逻辑父组件——如果父组件挂了「点我就关闭」的处理,弹窗会自己关掉自己。解法是在内容区加 onClick={e => e.stopPropagation()},或改用挂在 document 上的原生监听(原生事件走 DOM 树,不会经过逻辑父组件)。另一处是在 Next.js 里直接写 createPortal(..., document.body):服务端没有 document,必须放进客户端组件并等 useEffect 里置位 mounted 后再渲染。
记住一句话:Portal 换的是 CSS 的爹,不是 React 的爹。凡是按 React 树算的东西——合成事件冒泡、Context、错误边界、状态提升——Portal 一概不影响;凡是按 DOM 树算的东西——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: "提交" }) 能通过,说明这个元素在无障碍树里确实是个按钮、确实有可访问名称。用 divonClick 冒充按钮的写法会直接查不到——测试帮你把 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*(见本章后面讲异步的那一卡)。
写断言前先问一句:「用户能看到这个东西吗」。看不到的(state 变量、class 名、组件内部方法调用次数)就别断言。一个好用的自检是把组件内部实现整个重写一遍,测试应该一行都不用改——做不到,说明测试测的是实现而不是行为。查询优先用 getByRole,逼到没办法才用 getByTestId

2026 年的 React 测试栈答案很收敛:单元与组件测试用 Vitest + React Testing Library + user-event,端到端用 Playwright。下面每一条都给出为什么,以及什么情况下才该选别的。

Vitest 还是 Jest

维度VitestJest
配置成本几乎为零:直接复用 vite.config 的 alias、插件、环境变量要单独配 babel-jestts-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 事件,仅此而已。真实用户点击按钮会先 pointerdownmousedownfocuspointerupmouseupclickfireEvent 一个都不管。
  • 差别在输入框上最致命: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 都要跑真实浏览器和真实后端,几十条之后就慢到没人愿意等,然后整套测试被跳过。
新项目直接抄这套:Vitest + jsdom + React Testing Library + user-event + jest-dom + MSW,E2E 用 Playwright。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 已经更新——注意它是告警不是错误,测试可能照样通过,然后在别的机器上随机失败。
  • 最常见的成因不是你忘了包,而是异步回调迟到了。一个组件在 useEffectsetTimeout(() => 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 告警,也没有手写一行 actfindBy* 就是 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 renderNext 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 Orderusing <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]   ← 每次请求现场渲染
别把 Next.js 当成「带路由的 React」。它多出来的不是几个 API,而是一整个服务端运行时——从此你写的每一行代码都要先回答「在哪一侧执行」。很多人把 Vite 项目原样搬进 app/,然后在构建期被一堆 window is not defined 和 一句「这个 API 只在客户端组件里可用」的构建错误拦住(完整原文见 14 章),根因就是没意识到默认那一侧变了。
判断要不要上 Next 只问一句话:这个页面需要被没登录的陌生人(或搜索引擎、微信分享卡片)打开吗?需要,就上;不需要(后台、内嵌挂件、桌面应用内页),纯 Vite 加 React Router 反而更省心,你会少掉一整层「这段代码在哪跑」的心智负担。

「文件夹即路由段」不是为了少写一份配置,而是让框架在构建期就能静态地推导出整棵路由树——正因为不用运行任何代码就知道有哪些路由、每条路由套了哪几层布局,Next 才能做预渲染、按路由分包和预取。

app 目录下的角色分工

App Router 只认几个保留文件名,其余文件放在 app/ 里不会产生任何路由:

文件作用关键点
page.tsx这一段的页面 UI只有它能让一个文件夹变成可访问的 URL
layout.tsx包住本段及其所有子段的共享外壳导航切换时不重新挂载,状态保留
route.ts接口处理函数page.tsx 不能同时存在于同一段
loading.tsx
error.tsx
not-found.tsx
加载态、错误边界、404见本章后面的「特殊文件」卡

根布局(app/layout.tsx)是唯一必需的文件,而且它必须亲手渲染 <html><body>——Next 不会替你生成外层文档结构。

布局嵌套的真实渲染结构

布局是累加的,不是覆盖的。路径上每一段的 layout.tsx 会由外向内层层包住页面。在 app/t13/layout.tsx 里放一个 <section><nav>,访问 /t13/inside 时返回的 HTML 结构是:

  • htmlbody(来自根布局)▸ sectionnav(来自 t13 段布局)▸ 页面内容

这个「由外向内」的顺序有一个很实用的推论:布局在客户端导航时不会重新挂载。侧边栏里展开的折叠项、滚动位置、播放中的音频,在同一布局下的页面之间跳转时都会原样保留——这是 App Router 相比传统多页应用最直观的体验优势。

怎么放不产生路由的文件

组件、工具函数、样式当然可以直接放在 app/ 里,只要那个文件夹没有 page.tsx 就不会变成 URL。但更明确的做法是用私有文件夹:以下划线开头的目录名会被整个排除在路由之外。

这一条我踩过一次的坑:一开始把测试路由建在 app/_t13/ 下,next build 一切正常、零报错,但路由表里一条 t13 的路由都没有。改名成 app/t13/ 后立刻全部出现。下划线前缀是静默生效的,不会有任何提示。

默认那一侧变了

app/ 下的组件默认是服务端组件(Server Component)。这意味着 useStateonClickwindowlocalStorage 默认都用不了,要用得先在文件顶部写 '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.tsxapp/about/About.tsx 然后访问 /about 得到 404,是最常见的第一个坑——App Router 不认 index 这个名字。另外以下划线开头的目录(如 app/_admin/)会被静默排除出路由,构建零报错但路由表里什么都没有,命名时留意。
把「会变的」放进 page.tsx,把「跨页面不该重来的」放进 layout.tsx。判据不是「它长得像不像框架」,而是用户在这个布局覆盖的几个页面之间跳转时,这块 UI 的状态该不该保留。侧边栏展开状态、播放器、筛选面板放布局里能白拿状态保持;反之放页面里。

动态段把 URL 的一部分变成参数交给页面。而 Next 15 起 paramssearchParams 都变成了 Promise——这个改动真正棘手的地方不在于要多写一个 await,而在于忘了写完全不会报错

四种目录命名

下面每一行的结果都是跑出来的:

目录名匹配await params 的结果
[id]/p/abc{ id: "abc" },只吃一段
[...slug]/docs/a/b/c{ slug: ["a","b","c"] },但不匹配 /docs 本身
[[...slug]]/docs/docs/a/b访问 /docsslugundefined,不是空数组
(group)——圆括号目录名不进 URLapp/(grp)/inside/page.tsx 的路由是 /inside

路由组的用处是在不改 URL 的前提下多套一层布局:把营销页放进 (marketing)/、把后台放进 (app)/,两组各有自己的 layout.tsx,而用户看到的 URL 干干净净。

异步参数:一个完全静默的坑

Next 15 起,paramssearchParams 以及 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,圆括号那层不进 URL
忘记 await params 是完全静默的。把类型断言成 any 后直接读 params.id:TypeScript 编译通过、next build 通过、生产模式返回 200,页面上渲染出 undefined服务端一条告警都没有。所以「动态路由拿到的值是空的」时,先别怀疑数据源,回去数一遍 await
await 写进肌肉记忆:在 App Router 里,凡是「和这次请求有关」的东西都是异步的——paramssearchParamscookies()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 buildError: 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(),比在 onSubmitrouter.push 更省包体积(见 15 章)。

这些保留文件名不是命名规范,而是一种声明方式:你把组件放进去,框架就自动把它塞进预留好的 <Suspense> 或错误边界插槽里。你不写 <Suspense>,但确实用上了它。

五个文件各管什么

文件框架替你做的事要点
loading.tsx自动用 <Suspense fallback> 包住同级 page.tsx页面里 await 慢数据时先显示它
error.tsx给本段套一个 React 错误边界必须 'use client',收到 errorreset
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.tsxtemplate.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:titleog: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/ogImageResponse,让你用 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 }];
}
metadatagenerateMetadata 只在服务端组件里有效。在标了 '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:imagecanonical 都能写相对路径,框架自动补成绝对 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 处
// 它从头到尾没去过浏览器。
「服务端组件」不等于「服务端渲染」,两者解决的是不同问题。常见误解是以为用了 RSC 就不需要 SSR 了,于是奇怪首屏 HTML 里为什么还有客户端组件的内容。实际上 Next 是两件事一起做的。另一个方向的误解更危险:以为服务端组件里的东西「反正不进浏览器所以随便写」——但只要你把某个值作为 props 传给客户端组件,它就会被序列化进 Payload 明文发出去。API 密钥这样泄漏过不止一次。
想直观确认某个模块有没有进浏览器,别猜——跑一次 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' 只是说「这个组件也要送到浏览器」,不是「这个组件只在浏览器跑」。所有访问 windowdocumentlocalStorage 的代码都必须放进 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 记住用户操作的中间状态。
  • 需要绑事件:onClickonChangeonScroll
  • 需要浏览器专属 API:localStorageIntersectionObservermatchMedia
  • 需要用一个本身就是客户端组件的第三方库(大部分动画库、图表库、富文本编辑器)。

并且切的时候只切最小的那一块。一个「带筛选框的列表页」的正确拆法是:页面留在服务端负责查库,把筛选框单独做成客户端组件,列表数据以 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)} />;
}
服务端组件不能用 Context,但错误往往在很远的地方冒出来。典型场景是主题、国际化这类库要求在根布局包一层 Provider,于是有人给根布局加了 'use client'——整棵树瞬间全变客户端组件,包体积翻几倍,而且构建不会有任何提示。正确做法是把 Provider 单独抽成一个客户端组件文件,在根布局里用它包住 {children}:Provider 是客户端的,children 仍然是服务端渲染的(原理见下一张卡)。
拆分时先问「这块 UI 需要记住什么吗」。需要记住东西(输入内容、展开状态、选中项)的就是客户端组件,只是把数据摆出来的就留在服务端。按这条线拆出来的边界通常正好落在对的位置,比按「这个组件复不复杂」直觉判断准得多。

客户端组件不能 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、数组、普通对象、DateMapSet,以及 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。
写客户端组件时给自己定一条规矩:它的 props 里只准出现数据和 children,不准出现「要去取什么」的逻辑。需要展示服务端数据的地方,一律做成 children 或者具名的 JSX prop(如 <Panel header={<ServerHeader />} />)——具名 prop 和 children 一样能跨边界传 JSX,很多人不知道这一点。

服务端组件里取数就是一句 await,没有 useEffect、没有加载态、没有竞态。但缓存这一层有个几乎人人误解的地方:「Next 15 起 fetch 默认不缓存」是对的,「所以每次请求都拿新数据」是错的。

先把误解拆开

这里有两层缓存,常被混为一谈:

  • 数据缓存fetch 的结果要不要存起来跨请求复用。Next 15 起这一层默认关闭
  • 整页的渲染时机:这条路由是构建时渲染一次(○ Static),还是每次请求现渲染(ƒ Dynamic)。默认是静态

「默认不缓存」说的是第一层,可决定你看到什么的是第二层。路由是静态的,整页在构建时就定格了,那次构建时取到的数据被写死进 HTML,之后每个请求都发同一份——数据缓存关不关根本没机会起作用。

数据

我用一个「每被调用一次就自增」的接口验证了这一点。生产构建后各连发三次请求:

写法构建产物标记三次请求返回
fetch(url)○ Static3、3、3(构建时取的那一次)
fetch(url, { cache: "force-cache" })○ Static2、2、2
fetch(url, { cache: "no-store" })ƒ Dynamic12、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 buildnext start 复现。
构建完一定要看那张路由表:想每次都拿新数据的路由,前面必须是 ƒ;标着 就说明它已经在构建时定格了,之后再怎么改数据库页面都不会变。这一眼比任何调试都快,是判断「缓存有没有按预期生效」的第一手段。

服务端组件是 await 到底的,一个慢查询就能拖住整页。<Suspense> 的作用是把「慢」隔离在一个盒子里:盒子外的内容立刻发出去,盒子里的渲染好之后再追加进同一个响应流。

流式渲染是怎么发生的

一个 <Suspense> 包住 sleep 1.2 秒组件的页面:服务器立刻发出包含页面外壳和 fallback 的 HTML,1.2 秒后把真实内容追加到同一个响应里。用 curl 抓完整响应,能同时看到 FAST-SHELLSLOW-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、9
开了 cacheComponents: true 之后,await searchParams 也算「未缓存的数据访问」。一个只是读了查询串的普通页面,构建就直接失败:Uncached data was accessed outside of <Suspense>。同时 export const dynamic = "force-dynamic" 被整个禁用,报 not compatible with `nextConfig.cacheComponents`。所以别在老项目里随手打开这个开关,它是一次需要逐路由改造的迁移,不是性能优化按钮。
Suspense 边界要围着「慢的东西」画,而不是围着「组件」画。正确姿势是把 await 慢数据的那部分单独抽成一个小组件再用 <Suspense> 包住它——如果 await 写在页面组件顶层,包在外面的 Suspense 一点用都没有,因为整个页面都在那个 await 后面。fallback 请画成和真实内容同尺寸的骨架屏,否则内容到位时会跳版。

数据变更、Server Actions 与缓存

从表单提交到乐观更新,再到四层缓存与重新验证——把「写数据」这条完整链路走通。

Server Action 不是「把函数发到客户端执行」。它在编译期被分配一个稳定 id,服务端按这个 id 注册了一个 POST 端点;客户端拿到的只是一个同名的空壳函数,调用它等于发一次网络请求。想清楚这一点,安全和限制就都顺理成章了。

它被编译成了什么

  • 'use server' 是给打包器的指令:写在文件顶部标记整个模块,写在函数体第一行标记单个函数。
  • 编译后,服务端保留真实函数体并按 id 登记;客户端得到的是一层 fetch 包装,把 Action id 和序列化后的参数 POST 到当前路由。
  • 所以数据库连接、密钥、node:fs 永远进不了浏览器包——它们从头到尾没被打进客户端图。这也是为什么服务端组件里可以直连数据库(见 14 章)。
  • 参数与返回值必须能被 React 的序列化协议处理:普通对象、数组、字符串、DateFormDataPromise 都行;类实例、函数、DOM 节点不行。

两种调用方式

  • 表单:<form action={createPost}>,React 自动把表单字段打包成 FormData 作为唯一参数传进去。这条路自带渐进增强。
  • 客户端组件里当普通异步函数调:await createPost(data)。参数就不再限于 FormData 了,但也就没有渐进增强。
  • 需要额外参数时用 fn.bind(null, id) 预绑定——注意绑定值会被序列化后发给客户端再发回来,同样不可信。

它是公开端点,不是私有函数

  • Action id 稳定且可被重放。攻击者根本不需要打开你的页面,直接构造 POST 就行。
  • 因此鉴权必须写在 Action 体内,不能依赖「这个按钮只对管理员显示」——按钮是客户端的,端点是公开的。
  • 入参一律当作敌意输入校验。尤其是隐藏字段里的 userIdrole:正确做法是无视它,从服务端 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
}
把鉴权写在调用点而不是 Action 里。<button> 只对管理员显示挡不住任何人,Action id 是稳定的,直接 POST 就能触发。同理,别信 <input type="hidden" name="userId">——那是用户可以随手改的字段,身份只能从服务端 session 取。
判断一个函数该不该做成 Server Action,就问一句:它需要访问服务端独占资源吗(数据库、密钥、内网服务)。需要就做成 Action,不需要就留在客户端——把纯计算搬到服务端只会白白多一次往返。

useActionState 把「提交表单 → 等服务端 → 拿回结果」压成一个 Hook:它替你保管上一次的返回值、给你一个能直接塞进 action 的函数、外加一个 pending 布尔。你不用再手写 loading 和 error 两个 state。

签名与三元组

  • const [state, formAction, isPending] = useActionState(fn, initialState)
  • fn 的签名是 (prevState, formData) => newState——第一个参数是上一次的返回值,不是 formData。这是最容易搞反的地方。
  • React 19.2.8 的时序:提交期间 state 仍是旧值、isPendingtrue;Action resolve 之后 state 换成返回值、isPendingfalse。也就是说 pending 期间你渲染的是「上一次的结果」,不是空。
  • initialState 决定首屏那一次渲染的 state,给个有结构的对象(如 { error: null }),别给 undefined,否则下游到处要判空。

渐进增强是怎么来的

  • formAction 交给 <form action={...}>,Next 会给这个表单渲染出真实的 action 属性和隐藏字段。JS 还没加载完时,浏览器原生提交也能把数据送到服务端。
  • 代价:只有 <form action> 这一条路径有渐进增强。你若改成 onSubmit 里手动 await createPost(),就退回成必须有 JS 才能用的纯客户端逻辑了。
  • 另一个代价:原生提交没有 preventDefault 的机会,所以「提交后清空输入框」要靠非受控 defaultValuekey 重置,而不是清 state。

useFormStatus 补在哪一环

  • useFormStatus()react-dom(不是 react)引入,返回的 key 是 actiondatamethodpending 四个。
  • 它读的是最近的祖先 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 里。
想要「服务端校验失败后把用户填过的内容留在框里」,就让 Action 把 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 payload
以为 revalidateTag 之后所有人立刻看到新数据。它只清服务端的 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"); ... }
在 Route Handler 或 webhook 里调 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        动态,每次请求现渲染
以为「没写 cache 选项就是实时数据」。不带选项的 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(...)),它后面的代码一行都不会执行,缓存永远没被失效。顺序必须是先失效、后跳转。
列表数据由服务端组件通过 props 传给客户端表单组件,表单只负责「乐观地展示」。这样缓存失效后服务端重新渲染,新 props 一到,乐观态和真值自然对齐——比在客户端维护一份列表副本省心得多。

部署与工程化

Route Handlers、取代了 middleware 的 proxy.ts、环境变量的泄密风险,以及 Vercel、自托管与静态导出怎么选。

Route Handler 是 App Router 里唯一一种不返回 UI 的路由文件。你导出的不是组件而是 HTTP 方法函数,收的是浏览器原生的 Request,还的是原生 Response——它就是一个普通的 HTTP 端点,只是恰好住在 Next 的路由树里。

写法与约束

  • 文件叫 route.ts,放在 app/api/posts/route.ts 这样的目录下,导出 GETPOSTPUTPATCHDELETEHEADOPTIONS 中的任意几个。
  • 第一个参数是原生 Requestnew URL(request.url).searchParams 读查询串,await request.json() 读请求体。第二个参数是 { params },Next 15 起 params 是 Promise,要 await(见 13 章)。
  • 返回原生 ResponseResponse.json(data, { status })NextResponse 只是它的子类,多了 cookies、redirect、rewrite 这些便利方法,不是必需品。
  • 同一个目录里不能同时有 page.tsxroute.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 });
}
Route Handler 不是 Server Action:这里调 updateTagrefresh 会在运行时抛错,webhook 里刷新缓存只能用 revalidateTag(tag, "max")。两者的分工、以及反向那个「服务端组件渲染期调 revalidatePath 会让构建失败」的坑,都在 15 章。
判断口诀:这个入口需要一个稳定的 URL 吗。第三方 webhook、移动端、别的服务要调——需要,写 Route Handler;只有你自己页面上的表单在用——不需要,写 Server Action,省掉一整层手写胶水。

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.tsimport { 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):outputimages.remotePatternsredirects() 等。它跑在 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_livepostgres://)。命中就说明它进了浏览器包。这个检查十秒钟,比任何 code review 都可靠,值得写进 CI。

next build 最后打印的那张路由表,就是你的性能体检报告:每行前面那个符号回答了「这个路由的每一次请求要不要跑服务器」。看懂它,比装任何监控都先一步发现问题。

怎么读这张表

符号/列含义什么时候出现
Static,prerendered as static content路由没碰过任何请求时才知道的东西
ƒDynamic,server-rendered on demand用了 cookies()headers()searchParamsno-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 buildnext dev 都默认走 Turbopack。
  • 要退回 webpack 得显式写 next build --webpack。遇到某个只支持 webpack 的老插件时才需要。
  • next build -d 打开更详细的构建输出,排查「为什么这个路由变成 ƒ 了」时有用。

next/image 与 next/font 各自解决什么

  • next/image 解决的是布局抖动和流量:强制你提供 widthheight(或 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.tsxglobal-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.——你的登录拦截就这么静默消失了,全站变成公开访问。改这个配置后一定要手测一次未登录访问。
选型口诀:要不要 Server Actions 或按需重新验证——要,就必须有 Node 进程,只能 Vercel 或 standalone 自托管;不要且内容基本不变(文档站、落地页),才考虑 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 的坑几乎都是体感型的:stale closure、key 导致的状态错位、多余重渲染、服务端组件边界——你不亲手踩一次,读多少遍都记不住。正确顺序是:读一章 → 立刻写一个小到能在十分钟内跑起来的例子 → 故意把它写错一次看看报什么 → 再回头读那一章。本页每一章的结论都是这么来的。
一条自测标准:给你一个「点击后列表闪烁、输入框内容串位」的页面,你能否讲清一次点击后从事件、setState 批处理、重渲染到提交 DOM 的完整流程,并用 key/memo/状态下放定位并修掉那个多余重渲染——能做到,这一页就毕业了。

如果只能带走一样东西,带走这条链路——本页十八章讲的每件事,都是这条链路上的某一环。

从点击到屏幕更新的完整流程

  • ① 事件触发: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 还是 0」——这一行是理解 React 的分水岭。n这一次渲染捕获的常量快照,不是一个会变的变量;新值要到下一次渲染才存在。想通了这句,依赖数组、stale closure、为什么要用函数式更新,全都会一起想通。