CHAPTER 06 · 前端
React 心智模型
React 的 API 不多,难的是换一种思考方式:你不再“修改界面”,而是“描述在某个状态下界面应该长什么样”。掌握这个模型和几条重渲染规则,你就能看出 Agent 写的组件哪里多余、哪里会出 bug。
- 解释一次点击之后 React 内部的完整流程:事件 → 更新状态 → 重新执行组件函数 → 比对 → 更新 DOM。
- 预测某个状态变化会让哪些组件重新渲染。
- 判断一个
useEffect是否必要,并说出三种常见的替代写法。 - 为任意一份数据选择合适的“住处”:局部状态、提升的状态、URL、服务器缓存、全局 store。
开场:两个“看不懂”的现象
现象一:你在组件里写 let count = 0,按钮点击时 count++。日志显示 count 在增加,界面上的数字却一直是 0。
现象二:Agent 写了一个组件,用 useEffect 请求收藏列表。上线后发现浏览器在疯狂地重复请求同一个接口。
这两个现象背后是同一个机制。是什么?
答案方向:React 组件是一个会被反复调用的函数。每次“渲染”就是重新调用一次这个函数。普通局部变量在每次调用时都重新初始化,而且修改它也不会触发新的调用;只有交给 React 保管的“状态(state)”才能跨调用保留、并在改变时触发重新调用。而 Effect 在每次渲染后按条件执行——如果它自己又改变了状态,并且依赖条件每次都“看起来变了”,就形成了“渲染 → Effect → 改状态 → 渲染”的死循环。
核心模型:UI = f(state)
如果你写过服务器端模板(JSP、Jinja、Go template),组件函数很像一个模板:输入数据,输出 HTML。区别在于:服务器模板只渲染一次;React 的“模板”在数据变化时会被自动重新执行,并且只把差异应用到页面上,不整页刷新。
失配点:模板里通常可以随意执行查询等副作用;React 的组件函数要求是纯的(同样的输入 → 同样的输出,不修改外部世界),因为 React 可能在任何时候、任意次数地调用它。
组件、JSX 与 props
- JSX 看起来像 HTML,其实会被编译成普通函数调用(大致是
jsx("li", { className: ..., children: ... })),产出的是一个描述界面的普通 JS 对象。所以你可以在{}里写任意表达式,用if、map实现条件和循环,不需要模板语法。 - props 是父组件传给子组件的参数,对子组件来说是只读的。数据永远从上往下流。
key告诉 React 列表中“哪一项是哪一项”。必须稳定且唯一(用数据库 ID)。用数组下标作 key,在插入、删除、排序时会导致状态错位(例如输入框的内容跑到了另一行)。
State:为什么普通变量不行 原理
点击后:plain++ 改了当前这次调用里的局部变量,但它不会触发重新渲染;setCount 告诉 React“状态变了”,React 安排一次新的渲染,重新调用 Counter()。新的调用中,plain 又从 0 开始,而 useState 返回了 React 保存的新值 1。
每次渲染都是一张“快照”
这是最容易踩坑的一点,它是第 03 章“闭包”在 React 里的直接体现:
setCount 不会立刻修改 count 这个变量——它是一个 const,属于这次渲染的快照。新值只会出现在下一次渲染的快照里。
对象和数组:必须创建新的
回忆第 03 章:React 用引用比较判断状态是否改变。
谁会重新渲染? 原理
下面是 LinkNote 页面的组件树。点击按钮触发不同位置的状态变化,观察哪些组件函数被重新执行(右上角计数):
从演示中可以总结出三条规则:
- 状态在哪个组件,重渲染就从哪里开始。它的父组件和兄弟组件不受影响。
- 默认情况下,一个组件重渲染时,它渲染出的所有子组件都会重渲染,不论 props 是否改变。这通常没问题——重新执行函数很便宜,React 只会把真正的差异写入 DOM。
memo可以让子组件在 props 未变时跳过渲染。React Compiler(2025 年 10 月发布 1.0)会在构建时自动做类似的记忆化,所以新项目里手写useMemo、useCallback、memo的需求已经大幅减少。
两个关键设计原则
提升状态(lifting state up):当两个组件需要共享同一份状态(比如 SearchBox 的输入要用来过滤 List),把状态移到它们最近的共同父组件,通过 props 分别传下去——一个传值,一个传修改函数。
能算出来的,就不要存(derived state):这是 Agent 生成代码里最常见的冗余。
Effect:与外部世界同步 原理
组件函数必须是纯的,那需要订阅 WebSocket、操作浏览器 API、设置定时器时怎么办?这就是 useEffect 的用途:在渲染提交到屏幕之后,让组件与某个外部系统保持同步。
执行规则:首次渲染后执行;之后每次渲染后,比较依赖数组中的每一项(用 Object.is,即引用比较),有变化才先执行上一次的清理函数,再执行新的 Effect。
死循环是怎么形成的
模型:渲染 → Effect 执行 → setState → 渲染 → 依赖“变了” → Effect 执行 → ……。依赖数组里放了每次渲染都会新建的对象、数组或函数,是最常见的原因。
在开发环境的 StrictMode 下,React 会故意“挂载 → 卸载 → 再挂载”一次组件,以暴露缺少清理函数的 Effect。看到请求发了两次,先别急着“修复”,这是有意为之,生产环境不会发生。
你可能不需要 Effect
React 官方文档专门有一篇《You Might Not Need an Effect》。按场景对照:
| 你想做的 | 不要 | 应该 |
|---|---|---|
| 根据 props/state 计算新值 | Effect + setState | 渲染时直接计算 |
| 响应用户操作(提交、点击) | 设一个标志位,让 Effect 监听它 | 在事件处理函数里直接做 |
| 从服务器获取数据 | 手写 Effect + fetch | 框架的 loader / Server Component / TanStack Query |
| props 变化时重置状态 | Effect 里 setState | 给组件换一个 key |
| 订阅外部系统(WebSocket、浏览器事件) | — | 这才是 Effect 的正确用途 |
数据获取:不要自己写 会用
手写 useEffect + fetch 看似简单,但你还需要处理:加载状态、错误状态、竞态(快速切换筛选条件时,旧请求比新请求晚返回,覆盖了新数据)、缓存、去重、重试、窗口重新聚焦时刷新……这些正是 TanStack Query 解决的问题:
它背后的思想值得记住:服务器数据不是你的“状态”,而是远端数据的一份“缓存”。你关心的是“它什么时候过期、什么时候重新获取”,而不是“怎么把它存进 useState”。在使用全栈框架时,很多数据获取会改由服务器组件或路由 loader 完成(第 07 章),思想是一致的。
表单与 React 19 Actions 会用
React 19 让表单处理回归到 HTML 的原生方式:把一个函数传给 <form action>,React 负责处理提交、等待状态和错误。
相关的还有 useOptimistic(在服务器确认前先把结果显示出来,失败再回滚,即“乐观更新”)和 useFormStatus。在 Next.js 等框架中,这个 action 可以直接是一个运行在服务器上的函数(Server Action / Server Function,第 07、08 章)。
两个术语:受控输入(controlled input)——输入框的值由 state 驱动,每次按键都 setState,适合需要实时校验或联动的场景;非受控输入(uncontrolled)——由浏览器保存值,提交时从 FormData 读取,更简单,适合大多数表单。
Hooks 规则与自定义 Hook
所有以 use 开头的函数都是 Hook。两条硬性规则:只在组件或自定义 Hook 的顶层调用(不能放在 if、循环、嵌套函数里);只在 React 函数中调用。原因是 React 靠“调用顺序”来识别每个 Hook 对应哪份状态。Lint 规则 react-hooks 会自动检查。
自定义 Hook 就是把一组 Hook 调用封装成函数,用来复用逻辑(不是复用状态本身——每个使用者都有自己的一份):
数据应该住在哪里?
“状态管理”是 React 生态里被讨论最多的话题。实际上,按下面的顺序问自己,大多数情况下根本不需要状态管理库:
| 数据性质 | 住在哪里 | LinkNote 例子 |
|---|---|---|
| 只有一个组件用 | 组件内 useState | 下拉菜单是否展开 |
| 几个相邻组件共享 | 提升到共同父组件 | 搜索词与结果列表 |
| 应该能分享链接、刷新后保留 | URL 查询参数 | 当前筛选的标签、页码 |
| 来自服务器 | 服务器缓存(TanStack Query / 框架 loader) | 收藏列表、用户信息 |
| 深层组件都要读,且很少变 | Context | 主题、当前登录用户 |
| 复杂的、跨页面的纯客户端状态 | Zustand 等 store | 离线草稿、复杂编辑器状态 |
很多人以为需要“全局状态”,其实他们需要的是服务器缓存(数据来自后端)或 URL 状态(筛选、分页、选中的标签页)。把这两类分出去后,剩下的客户端状态通常很少。
列表页:标签筛选放 URL(?tag=react);收藏列表用服务器缓存;搜索框输入用局部状态 + 防抖;“添加链接”用表单 Action + 乐观更新;实时显示“摘要生成中”的进度用一个订阅 SSE 的 Effect(这是真正需要 Effect 的场景)。
tag 里,刷新和分享链接后保留。过滤结果在渲染时计算,不要存成单独的 state,也不要用 useEffect 同步。收藏列表继续用现有的 useQuery,queryKey 加上 tag。”本章词汇表:提升状态(lift state up)、派生状态(derived state)、受控 / 非受控输入、key、Effect 清理函数(cleanup)、依赖数组、服务器状态 vs 客户端状态、乐观更新、记忆化(memoization)。
- 什么会触发组件重新渲染?自身 state 变化、父组件重渲染、所消费的 Context 变化。props 变化本身不是独立原因——props 变化一定来自父组件的重渲染。
- 为什么列表需要 key?为什么不能用 index?key 让 React 在比对时匹配新旧元素;用 index 时插入或排序会导致元素错配,局部状态和 DOM 被复用到错误的项上。
- useEffect 的依赖数组如何工作?每次渲染后用 Object.is 逐项比较;变化则先清理再执行。空数组 = 仅在挂载时执行一次。
- 什么是虚拟 DOM?组件返回的 JSX 描述对象树;React 比对新旧两棵树,计算出最小的 DOM 操作。重点是“声明式 + 批量高效更新”,而不是“虚拟 DOM 一定比直接操作 DOM 快”。
自检
1. 父组件 Page 每秒更新一次时钟 state,它的子组件 ExpensiveChart 的 props 完全不变。没有 React Compiler、没有 memo 时,Chart 会每秒渲染吗?有什么更好的结构调整?
会,父组件重渲染会带着所有子组件重渲染。除了 memo,更好的做法是把时钟状态下移到一个独立的 Clock 组件里,让状态只影响真正需要它的部分——Chart 就不再是时钟状态所在组件的子组件了。
2. 下面代码想在用户切换 linkId 时清空评论输入框:useEffect(() => setDraft(""), [linkId])。有什么问题?更好的写法?
它会先用旧的 draft 渲染一次,Effect 执行后再渲染一次,出现闪烁,也多一次渲染。更好的写法是在父组件里给评论组件加 key={linkId}:key 变了,React 会把它当作一个全新的组件,状态自然重置。
3. 用户在搜索框快速输入 "r"、"re"、"rea",每次都用 Effect 发请求。"re" 的响应最后才返回。界面会显示什么?怎么避免?
会显示 "re" 的结果,与输入框里的 "rea" 不一致——这是竞态。可以在 Effect 清理函数里标记旧请求已过期、或用 AbortController 取消旧请求;更好的做法是用 TanStack Query(按 queryKey 管理,只采用当前 key 的结果)并加防抖。