Chapter 04
副作用与 Hook
第 3 章把 state 钉成了“快照”。这一章处理两件事:Hook 凭什么能在“每次重跑的函数”里记住东西(答案藏在调用顺序里),以及 useEffect 到底是什么——它不是生命周期钩子,而是“与外部世界同步”的声明。
本章你将建立的 schema
- Hook 规则的底层原因:React 靠“第几次调用”认领记忆槽,所以顺序不能变
useRef:跨渲染保留、但改了不触发渲染的逃生舱useEffect是同步机制:依赖数组声明“读了哪些值”,cleanup 在重新同步前先跑- 多数“看似需要 effect”的逻辑其实不需要——能算就算,事件归事件
4.1Hook 的规则与它的底层原因
Hook 必须在组件顶层、按固定顺序、无条件调用——因为 React 靠“这是第几次调用”来认领每个 Hook 的记忆槽。
第 3 章留了个问题:组件函数每次渲染都整个重跑,里面的局部变量每次重建,那 useState 凭什么能记住上一次的值?答案解释了一条看似武断的规则——为什么不能把 Hook 放进 if、循环或嵌套函数里。
React 不靠变量名记住 Hook,靠调用顺序。每个组件的 fiber 上挂着一个 Hook 列表。首次渲染时,第 1 次 useState 认领槽 0、第 2 次认领槽 1、useEffect 认领槽 2……之后每次渲染都必须以相同顺序、相同数量调用,React 才能把这次的第 N 个 Hook 对到上次的第 N 个槽。把 useState 放进 if,某次渲染少调一个,后面所有 Hook 的槽集体错位——这才是 Rules of Hooks 的真正原因,不是代码风格。
// ❌ Hook 放进条件:某次渲染少调一个,后面的槽全部错位
if (isLoggedIn) {
const [name, setName] = useState("");
}
// ✅ Hook 永远在组件顶层、无条件调用;把条件放进 Hook 之后
const [name, setName] = useState("");
if (isLoggedIn) {
// 用 name
}
4.2useRef:不触发渲染的记忆
useRef 给你一个跨渲染保留、但修改时不触发渲染的盒子(ref.current)。
有些值需要在渲染之间记住——定时器 id、某个 DOM 节点、上一次的值——但它们不该驱动 UI。放进 state 会引发多余渲染甚至死循环;放进普通局部变量又会每次渲染被重置(第 3 章)。useRef 正好填这个缝。
ref 也是 fiber 上的一个槽,和 state 一样跨渲染保留——唯一的区别是:改 ref.current 不通知 React 重渲染。它是第 3 章“快照”规则的逃生舱:当你想读“此刻最新的值”而不是“这一帧的快照”时,把它存进 ref。代价:正因为它不触发渲染,把“该显示在界面上的值”放进 ref,界面不会更新——这是新手第二常见的错误(第一是滥用 effect)。
| 跨渲染保留 | 修改触发渲染 | 该装什么 | |
|---|---|---|---|
| state | 是 | 是 | 影响 UI 的值(计数、输入、开关) |
| ref | 是 | 否 | 不影响 UI 的值(定时器 id、DOM 节点、上次的值) |
const timerRef = useRef(null); // 跨渲染保留;改它不触发渲染
function start() {
timerRef.current = setInterval(tick, 1000); // 记住 id,但不该让界面重渲染
}
function stop() {
clearInterval(timerRef.current);
}
4.3useEffect 是“同步”,不是生命周期
effect 描述“如何让某个外部系统与当前 props/state 保持一致”。React 在每次相关渲染提交后运行它来完成同步。
把 effect 当成 “组件挂载时/更新时触发的回调”(即旧的 componentDidMount/componentDidUpdate 框架)的人,会写出一连串 bug:忘了 cleanup、依赖配错、effect 之间互相级联。换一个心智模型,这些 bug 从源头消失。
effect 不是“在某个时刻触发的回调”。正确心智:每次渲染都有它自己的一份 effect,闭包捕获那一帧的 props/state(第 3 章的快照)。提交并绘制后,React 比较这次和上次的依赖数组——若依赖变了,先运行上一份 effect 的 cleanup,再运行这一份 effect。组件卸载时运行最后一次 cleanup。所以一个 effect 表达的是:“对于当前这一帧的值,外部系统应当处于什么状态。”
依赖数组不是“何时重跑的开关”,而是“这个 effect 读了哪些响应式值”的声明——React 据此判断值变没变、需不需要重新同步。漏写依赖 = 撒谎,effect 会读到过期的快照。
useEffect(() => {
const conn = createConnection(roomId);
conn.connect();
return () => conn.disconnect(); // cleanup:重新同步前 / 卸载时,先断开旧连接
}, [roomId]); // 依赖:声明这个 effect 读了 roomId
roomId,连接应处于什么状态”。注意:从 A 切到 B 时,React 先跑旧 effect 的 cleanup(断开 A),再跑新 effect(连接 B)。cleanup 不是“卸载时才跑”——每次重新同步前都先跑,这正是订阅类逻辑不泄漏的关键。4.4你可能不需要 Effect
能在渲染中算出来的值,不要塞进 state+effect;由用户操作触发的逻辑,放事件处理函数,不放 effect。
effect 最大的滥用是拿它做“数据派生”和“级联状态”——用一个 effect 监听 A 去 set B,再用另一个 effect 监听 B 去 set C。这制造多余渲染、画面闪烁和难查的 bug。多数时候,effect 根本不该出现。
// ❌ 用 state + effect 同步一个派生值:多一次渲染,还可能闪烁
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(first + " " + last);
}, [first, last]);
// ✅ 能从现有 state 算出来的,渲染时直接算
const fullName = first + " " + last;
问一个问题:这段逻辑是因为某个值变了、需要与外部系统同步才跑,还是因为用户做了某个具体操作才跑?
— 能从现有 props/state 算出的值(全名、过滤后的列表):渲染时直接算,别进 state。
— 用户点击/提交引发的事(发请求、弹提示):放进那个事件处理函数。
— 只有“因为组件渲染出来了、要把外部系统(订阅、网络连接、非 React 的 DOM)拉到与当前状态一致”时,才用 effect。
“用户提交搜索表单后,发一个网络请求”——这该写进 useEffect 吗?
展开答案(先停 10 秒)
不该。它是“用户提交”这个具体操作触发的,属于决策树的第二个分叉——放进表单的 onSubmit 事件处理函数。只有当“请求该发”是由某个响应式值变化(如 URL 里的查询参数变了,要把结果与之同步)驱动时,才考虑 effect。把事件逻辑塞进 effect,会让“到底什么触发了请求”变得不可追踪。
4.5把四章串起来
一个聊天室组件的完整数据循环,调用了前四章的全部概念:组件是 props 的纯函数(§1.2),roomId 作为 prop 单向流入(§1.4);它在渲染中直接算出标题文本而不用 effect(§4.4);useState 与 useEffect 各自按调用顺序认领 fiber 槽(§4.1);连接逻辑写成 effect,依赖 [roomId],闭包捕获当前帧的 roomId 快照(§3.1),roomId 变时先 cleanup 旧连接再建新连接(§4.3);切换房间触发重渲染时,React 按 key 复用 DOM、协调出最小改动(§2.3)。没有一个概念是孤立的——它们是同一个循环在不同位置的名字。
§本章 self-check
先合上教程,把答案写下来,再展开对照。
- React 凭什么在“每次重跑的函数”里把这次的
useState对到上次的同一个值?这条机制如何推出“不能把 Hook 放进 if”? - 一个值要跨渲染保留,但改它时不该触发重渲染——用 state 还是 ref?反过来,一个要显示在界面上的值放进了 ref,会出什么问题?
useEffect(fn, [roomId])里的 cleanup 函数在哪两个时刻运行?- (设计题)为什么把“依赖数组”理解成“何时重跑的开关”是错的?正确的理解是什么,漏写一个依赖会导致什么具体后果?
答案(先做完再展开)
- 靠调用顺序:fiber 上的 Hook 列表按“第几次调用”索引,第 N 个 Hook 永远对第 N 个槽。把 Hook 放进 if,某次渲染少调一个,之后所有 Hook 的序号前移一位、对错槽,状态全乱——所以必须每次按相同顺序、相同数量调用。
- 用 ref:跨渲染保留且改它不触发渲染。反过来,把该显示的值放进 ref,改了它界面不会更新(ref 不触发渲染),用户看到的是旧画面——这类“数据变了 UI 不动”的 bug 就是误用 ref 装了 UI 状态。
- 两个时刻:① 下一次该 effect 因依赖变化重新运行之前(先清理旧的再建新的);② 组件卸载时(最后一次清理)。
- 因为 effect 不是“在某时刻被触发的回调”,而是“对当前这一帧的值,外部系统该是什么状态”的声明;依赖数组是“这个 effect 读了哪些响应式值”的清单,React 用它判断要不要重新同步。漏写依赖等于谎报“没读这个值”,于是值变了 effect 不重跑,effect 内部继续用过期的快照——典型表现是连接连到旧房间、回调读到旧 state。
计数器为什么停在 1?
下面这个 effect 想每秒把 count 加 1,但 count 永远停在 1:
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // 永远是 0 + 1
}, 1000);
return () => clearInterval(id);
}, []); // 空依赖
用第 3 章的“快照”和本章的“依赖数组”解释为什么停在 1,并给出两种修法。
提示(卡住再展开)
空依赖 [] 意味着 effect 只在挂载时运行一次,那次运行的闭包永远捕获首帧的 count = 0(快照)。于是 interval 里每秒都执行 setCount(0 + 1),永远把 state 设成 1。
修法一:函数式更新——setCount(c => c + 1),不读快照里的 count,改读队列里的上一个结果(§3.2),空依赖也正确。
修法二:把 count 写进依赖数组 [count],让每次 count 变化都重建 interval、捕获新快照——但这会频繁重建定时器,通常不如修法一。这正是“依赖数组是‘读了哪些值’的声明”的实战意义。