CHAPTER 06 · 前端

React 心智模型

React 的 API 不多,难的是换一种思考方式:你不再“修改界面”,而是“描述在某个状态下界面应该长什么样”。掌握这个模型和几条重渲染规则,你就能看出 Agent 写的组件哪里多余、哪里会出 bug。

⏱ 约 70 分钟原理(渲染模型、State、Effect)会用(Hooks、表单、TanStack Query)
读完本章,你应该能够:
  • 解释一次点击之后 React 内部的完整流程:事件 → 更新状态 → 重新执行组件函数 → 比对 → 更新 DOM。
  • 预测某个状态变化会让哪些组件重新渲染。
  • 判断一个 useEffect 是否必要,并说出三种常见的替代写法。
  • 为任意一份数据选择合适的“住处”:局部状态、提升的状态、URL、服务器缓存、全局 store。

开场:两个“看不懂”的现象

开场问题

现象一:你在组件里写 let count = 0,按钮点击时 count++。日志显示 count 在增加,界面上的数字却一直是 0。

现象二:Agent 写了一个组件,用 useEffect 请求收藏列表。上线后发现浏览器在疯狂地重复请求同一个接口。

这两个现象背后是同一个机制。是什么?

答案方向:React 组件是一个会被反复调用的函数。每次“渲染”就是重新调用一次这个函数。普通局部变量在每次调用时都重新初始化,而且修改它也不会触发新的调用;只有交给 React 保管的“状态(state)”才能跨调用保留、并在改变时触发重新调用。而 Effect 在每次渲染后按条件执行——如果它自己又改变了状态,并且依赖条件每次都“看起来变了”,就形成了“渲染 → Effect → 改状态 → 渲染”的死循环。

核心模型:UI = f(state)

State React 保管的数据 组件函数 重新执行(渲染) 必须是纯计算 新的 JSX 树 对界面的“描述” 与上次比对(diff) DOM 只改有差异的部分 用户点击 → 事件处理函数调用 setState(新值) → 触发下一轮 你只负责两件事:①写出“状态 → 界面”的纯函数 ②在事件里更新状态 “比对并修改 DOM”全部由 React 完成
React 的更新循环。中间两步(渲染与比对)在 React 术语里叫 render 和 reconciliation,最后一步叫 commit。
类比:模板引擎,但会自动重跑

如果你写过服务器端模板(JSP、Jinja、Go template),组件函数很像一个模板:输入数据,输出 HTML。区别在于:服务器模板只渲染一次;React 的“模板”在数据变化时会被自动重新执行,并且只把差异应用到页面上,不整页刷新。

失配点:模板里通常可以随意执行查询等副作用;React 的组件函数要求是纯的(同样的输入 → 同样的输出,不修改外部世界),因为 React 可能在任何时候、任意次数地调用它。

组件、JSX 与 props

State:为什么普通变量不行 原理

点击后:plain++ 改了当前这次调用里的局部变量,但它不会触发重新渲染;setCount 告诉 React“状态变了”,React 安排一次新的渲染,重新调用 Counter()。新的调用中,plain 又从 0 开始,而 useState 返回了 React 保存的新值 1。

每次渲染都是一张“快照”

这是最容易踩坑的一点,它是第 03 章“闭包”在 React 里的直接体现:

setCount 不会立刻修改 count 这个变量——它是一个 const,属于这次渲染的快照。新值只会出现在下一次渲染的快照里。

对象和数组:必须创建新的

回忆第 03 章:React 用引用比较判断状态是否改变。

谁会重新渲染? 原理

下面是 LinkNote 页面的组件树。点击按钮触发不同位置的状态变化,观察哪些组件函数被重新执行(右上角计数):

从演示中可以总结出三条规则:

  1. 状态在哪个组件,重渲染就从哪里开始。它的父组件和兄弟组件不受影响。
  2. 默认情况下,一个组件重渲染时,它渲染出的所有子组件都会重渲染,不论 props 是否改变。这通常没问题——重新执行函数很便宜,React 只会把真正的差异写入 DOM。
  3. 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 执行 → ……。依赖数组里放了每次渲染都会新建的对象、数组或函数,是最常见的原因。

开发模式下 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 状态(筛选、分页、选中的标签页)。把这两类分出去后,剩下的客户端状态通常很少。

LinkNote 中的落点

列表页:标签筛选放 URL(?tag=react);收藏列表用服务器缓存;搜索框输入用局部状态 + 防抖;“添加链接”用表单 Action + 乐观更新;实时显示“摘要生成中”的进度用一个订阅 SSE 的 Effect(这是真正需要 Effect 的场景)。

对 Agent 怎么说
模糊
精确
“列表页加个标签筛选。”
“标签筛选状态放在 URL 查询参数 tag 里,刷新和分享链接后保留。过滤结果在渲染时计算,不要存成单独的 state,也不要用 useEffect 同步。收藏列表继续用现有的 useQuery,queryKey 加上 tag。”
“页面一直重复请求,修一下。”
“LinkList 的 useEffect 依赖了每次渲染都新建的 options 对象,导致请求死循环。把数据获取改为 TanStack Query,删除这个 Effect,并说明修改后的请求在什么条件下触发。”

本章词汇表:提升状态(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 的结果)并加防抖。