Hooks 的规则不是”最佳实践建议”,而是 React 内部机制的硬约束;闭包陷阱不是 React 的 Bug,而是 JavaScript 闭包与 React 渲染模型碰撞后的必然结果。
Hooks 规则与闭包陷阱
一、Hooks 的两条铁律
1.1 铁律一:只在最顶层调用 Hook
❌ 错误——在条件、循环、嵌套函数中调用 Hook:
function BadComponent({ isAdmin }) {
// ❌ 条件里调用 Hook
if (isAdmin) {
const [adminData, setAdminData] = useState(null);
}
// ❌ 循环里调用 Hook
for (let i = 0; i < 3; i++) {
const [val, setVal] = useState(i); // 每次渲染循环次数可能不同
}
// ❌ 嵌套函数里调用 Hook
const handleClick = () => {
const [count, setCount] = useState(0); // 这不是 Hook 调用,是普通函数里的 state
};
}
✅ 正确——始终在组件顶层、无条件地调用:
function GoodComponent({ isAdmin }) {
const [adminData, setAdminData] = useState(null); // ✅ 顶层调用
const [val0, setVal0] = useState(0); // ✅ 顶层调用
const [val1, setVal1] = useState(0); // ✅ 顶层调用
const [val2, setVal2] = useState(0); // ✅ 顶层调用
const [count, setCount] = useState(0); // ✅ 顶层调用
// 条件逻辑放在 Hook 调用之后
if (isAdmin) {
// 用 adminData 做事...
}
}
1.2 铁律二:只在 React 函数中调用 Hook
允许调用 Hook 的地方只有两种:
| 场景 | 能否调用 Hook | 说明 |
|---|---|---|
| 函数组件 | ✅ | function MyComponent() |
| 自定义 Hook | ✅ | function useSomething() |
| 普通JS函数 | ❌ | function helper() |
| Class 组件 | ❌ | class MyComponent |
| 事件处理函数 | ❌ | onClick={() => {}} 内部 |
// ❌ 在普通函数中调用
function formatDate(date) {
const [locale] = useState("zh-CN"); // 错误!
return date.toLocaleDateString(locale);
}
// ✅ 改为自定义 Hook
function useLocale() {
const [locale, setLocale] = useState("zh-CN");
return { locale, setLocale };
}
💡 自定义 Hook 以
use开头不是命名惯例,而是 React 识别 Hook 的依据。不以use开头,linter 插件不会检查它。
二、为什么有这些规则——React 的内部机制
2.1 调用顺序 = 身份标识
React 内部用链表管理一个组件的所有 Hook。它没有给每个 Hook 取名字,而是靠调用顺序来匹配:
🗄️ 存物柜类比:想象一排存物柜。第 1 次调用
useState→ 存入第 1 柜,第 2 次调用 → 存入第 2 柜……下次渲染时,React 按同样的顺序开柜取物。如果你跳过了第 1 柜直接开第 2 柜,取出来的就是别人的东西。
渲染 1(isAdmin = true):
Hook#0 → useState('name') → 'Alice'
Hook#1 → useState('role') → 'admin' ← 条件内
Hook#2 → useEffect(...) → effect
渲染 2(isAdmin = false):
Hook#0 → useState('name') → 'Alice'
❌ 跳过了 Hook#1(条件不满足)
Hook#1 → useEffect(...) → 拿到的是 'admin' 的 state!
❌ Hook#2 不存在 → 崩溃或数据错位
2.2 调用顺序错位的具体后果
| 情况 | 后果 |
|---|---|
| 条件内调用 Hook | 条件翻转时,后续所有 Hook 偏移一位,state 全部错位 |
| 循环内调用 Hook | 循环次数变化时,Hook 数量不匹配,React 报错 |
| 提前 return | return 之后的 Hook 不会执行,数量减少,同样错位 |
| 嵌套函数中调用 | 根本不会被 React 的链表追踪,行为不可预测 |
2.3 正确 vs 错误的调用顺序对比
✅ 正确:每次渲染调用顺序固定
渲染 1: useState → useState → useEffect → useRef
渲染 2: useState → useState → useEffect → useRef
渲染 3: useState → useState → useEffect → useRef
─────────────────────────────────────────
顺序永远一致,React 能正确匹配每个 Hook
❌ 错误:条件导致顺序变化
渲染 1 (show=true): useState → useState → useEffect → useRef
渲染 2 (show=false): useState → useEffect → useRef
─────────────────────────────────
useState#2 消失,后面的 Hook 全部错位
⚠️ React 在开发模式下会检测 Hook 数量变化并报错,但生产模式下可能静默地产生错误数据,更难排查。
三、闭包陷阱(Stale Closure)
3.1 什么是闭包
JavaScript 闭包:函数”记住”自己创建时能访问的变量,即使那些变量所在的执行上下文已经结束。
function createCounter() {
let count = 0;
return () => {
console.log(count); // 这个函数"记住"了 count
};
}
📸 拍照类比:闭包像拍照片——按下快门那一刻,场景被定格。之后场景变了(变量被重新赋值),照片里的画面不变。你拿着旧照片,看到的永远是拍摄那一刻的景象。
3.2 React 组件每次渲染都是新的闭包
这是理解闭包陷阱的核心:
function Counter() {
const [count, setCount] = useState(0);
function handleAlert() {
// 这个函数在渲染时创建,"拍下了" count = 0 的照片
setTimeout(() => {
alert("当前 count: " + count); // 拿到的是创建时的快照,不是最新值
}, 3000);
}
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
<button onClick={handleAlert}>3秒后弹窗</button>
</div>
);
}
操作:点击 +1 三次 → 点击弹窗 → 再点击 +1 两次
你期望弹窗显示 5,实际显示 1——因为 handleAlert 在 count=1 那次渲染中被创建,它”拍下”的是 count=1 的快照。
关键认知:组件函数每次渲染都是一次新的调用,函数体内的变量(props、state)是那一次渲染的值。事件处理函数、effect 回调拿到的永远是创建时那次渲染的快照,不是”最新值”。
3.3 经典场景
场景 A:setTimeout/setInterval 里读到旧 state
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const id = setInterval(() => {
// ❌ 每次都读到初始渲染的 seconds = 0
// 所以永远 setSeconds(0 + 1) = 1
setSeconds(seconds + 1);
}, 1000);
return () => clearInterval(id);
}, []); // 空依赖 → effect 只在 mount 时执行一次 → 闭包永远锁在 seconds=0
return <p>{seconds}</p>;
}
场景 B:useEffect 依赖遗漏导致读到旧值
function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
fetchData(query).then(data => {
// ❌ 如果 query 变了但 effect 没重新执行
// 这里拿到的还是旧 query
setResults(data);
});
}, []); // ❌ 遗漏了 query 依赖
return <ResultList results={results} />;
}
场景 C:事件处理器里的异步回调读到旧值
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
const handleJoin = async () => {
await joinRoom(roomId); // 异步操作
// ❌ 如果 roomId 在 await 期间变了
// 这里拿到的还是旧的 roomId
console.log("已加入房间:", roomId);
subscribe(roomId, msg => {
setMessages([...messages, msg]); // ❌ messages 也是旧值
});
};
return <button onClick={handleJoin}>加入</button>;
}
四、闭包陷阱的解决方案
4.1 函数式更新——适用于 setter
当新值依赖旧值时,用 prev => newValue 代替直接引用 state:
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const id = setInterval(() => {
// ✅ 函数式更新:React 保证 prev 是最新值
setSeconds(prev => prev + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <p>{seconds}</p>;
}
// 数组/对象的函数式更新
setMessages(prev => [...prev, newMsg]); // ✅ 追加元素
setUser(prev => ({ ...prev, name: "Bob" })); // ✅ 更新字段
💡 函数式更新的本质:你不再”读”旧值,而是告诉 React “基于最新值做变换”,React 帮你拿到最新值。
4.2 useRef 存最新值——适用于非 setter 场景
当你需要在回调中读取最新值,但不是调用 setter 时,用 ref:
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
const roomIdRef = useRef(roomId); // 用 ref 存最新值
const messagesRef = useRef(messages);
// 每次 roomId 变化时同步到 ref
useEffect(() => {
roomIdRef.current = roomId;
}, [roomId]);
// 每次 messages 变化时同步到 ref
useEffect(() => {
messagesRef.current = messages;
}, [messages]);
const handleJoin = async () => {
await joinRoom(roomId);
// ✅ 通过 ref 读取最新值
console.log("已加入房间:", roomIdRef.current);
subscribe(roomIdRef.current, msg => {
setMessages(prev => [...prev, msg]); // setter 用函数式更新
});
};
return <button onClick={handleJoin}>加入</button>;
}
4.3 正确填写依赖数组——让 effect 总是用最新值
最根本的解法:让 effect 在依赖变化时重新执行,这样闭包自然拿到新值:
function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
let cancelled = false;
fetchData(query).then(data => {
if (!cancelled) {
setResults(data); // ✅ effect 重新执行,query 是最新值
}
});
return () => {
cancelled = true;
}; // 清理上一次请求
}, [query]); // ✅ query 变化时重新执行 effect
return <ResultList results={results} />;
}
⚠️ 三种方案的适用场景:
- 函数式更新:只需要基于旧 state 计算新 state
- useRef:需要在非 effect 回调中读取最新值(事件处理器、定时器等)
- 补全依赖:effect 中使用了外部变量,最根本的解法
五、依赖数组的正确姿势
5.1 为什么要写全依赖
exhaustive-deps 规则的原理:effect 内部用到的每个外部变量,都应该出现在依赖数组中。否则 effect 闭包中的值就是过时的。
function FriendStatus({ friendId }) {
const [isOnline, setIsOnline] = useState(null);
useEffect(() => {
// 用到了 friendId → 必须在依赖中
subscribe(friendId, status => setIsOnline(status));
return () => unsubscribe(friendId);
}, []); // ❌ 缺少 friendId → 订阅的永远是初始 friendId
// ✅ 正确写法
// }, [friendId]) // friendId 变化时重新订阅
}
5.2 什么时候可以”合法”省略
极少数场景下,省略依赖是合理的:
场景一:cleanup only effect
useEffect(() => {
// 只做清理,不读取任何外部变量
const handler = () => {
/* 不依赖任何 state/props */
};
window.addEventListener("resize", handler);
return () => window.removeEventListener("resize", handler);
}, []); // ✅ 确实不依赖任何会变化的值
场景二:只跑一次的初始化
useEffect(() => {
// 初始化逻辑,确实只需要执行一次
// 且不依赖任何 props/state
initAnalytics();
}, []); // ✅ 初始化代码,无外部依赖
⚠️ 如果你不确定能不能省略,不要省略。90% 的省略都是 Bug 的温床。
5.3 对象/函数依赖导致无限循环的解法
常见陷阱:每次渲染都创建新的对象/函数 → 依赖数组检测到”变化” → effect 重新执行 → 又创建新对象/函数 → 无限循环:
function Profile({ userId }) {
const [user, setUser] = useState(null);
// ❌ 每次渲染 fetchOptions 是新对象 → 依赖"变化" → effect 重执行 → 无限循环
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId, { method: "GET" }]); // 字面量对象每次都是新引用
// ❌ 同理,函数也是
const handleError = err => console.error(err);
useEffect(() => {
fetchUser(userId).then(setUser).catch(handleError);
}, [userId, handleError]); // handleError 每次渲染都是新引用
}
解法一:useMemo / useCallback 稳定引用
function Profile({ userId }) {
const [user, setUser] = useState(null);
// ✅ useMemo 稳定对象引用
const fetchOptions = useMemo(() => ({ method: "GET" }), []);
// ✅ useCallback 稳定函数引用
const handleError = useCallback(err => console.error(err), []);
useEffect(() => {
fetchUser(userId, fetchOptions).then(setUser).catch(handleError);
}, [userId, fetchOptions, handleError]);
}
解法二:把声明移到 effect 内部
function Profile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
// ✅ 不需要作为依赖,因为只在 effect 内部使用
const options = { method: "GET" };
const handleError = err => console.error(err);
fetchUser(userId, options).then(setUser).catch(handleError);
}, [userId]); // 只依赖真正来自外部的值
}
💡 优先用解法二(移入 effect 内部),只有在外部确实需要复用时才用 useMemo/useCallback。详见 useCallback与useMemo。
六、实战模式:如何安全地”只在 mount 时执行”
6.1 为什么 [] 不总是安全的
很多开发者习惯用 useEffect(() => { ... }, []) 表示”只在 mount 时执行”。但这个 effect 内部的闭包锁定了初始渲染的值:
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const connection = createConnection(roomId); // ❌ roomId 永远是初始值
connection.connect();
connection.onMessage(msg => {
setMessages([...messages, msg]); // ❌ messages 永远是 []
});
return () => connection.disconnect();
}, []); // "只在 mount 执行" → 闭包锁死
return <MessageList messages={messages} />;
}
当 roomId 变化时,连接不会更新,消息列表也不会正确追加——因为 effect 里的 roomId 和 messages 都是初始渲染的快照。
6.2 最新回调 ref 模式
当你确实需要”只注册一次,但总是调用最新版本”的逻辑时,用 ref 存最新回调:
function useLatestCallback(callback) {
const callbackRef = useRef(callback);
// 每次渲染都更新 ref → ref.current 始终是最新回调
useEffect(() => {
callbackRef.current = callback;
});
// 返回一个稳定引用的函数,内部通过 ref 调用最新回调
const stableCallback = useCallback((...args) => {
return callbackRef.current(...args);
}, []); // 空依赖 → 引用永远不变
return stableCallback;
}
使用示例:
function ChatRoom({ roomId, onMessage }) {
// ✅ onMessage 可能每次渲染都变,但我们只订阅一次
const stableOnMessage = useLatestCallback(onMessage);
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
connection.onMessage(msg => {
stableOnMessage(msg); // ✅ 总是调用最新的 onMessage
});
return () => connection.disconnect();
}, [roomId, stableOnMessage]); // stableOnMessage 引用稳定,不会导致重执行
// ...
}
6.3 模式对比总结
| 模式 | 适用场景 | 依赖数组 | 闭包风险 |
|---|---|---|---|
| 补全依赖 | 通用 | [dep1, dep2] | ✅ 无风险 |
| 函数式更新 | 只需基于旧 state 计算 | 不影响 | ✅ 无风险 |
| useRef 存值 | 需要在回调中读取最新值 | 不影响 | ✅ 无风险 |
| 最新回调 ref | 只注册一次但调用最新回调 | [] | ✅ 无风险 |
空依赖 [] | 真正无外部依赖的初始化 | [] | ⚠️ 有风险 |
💡 选择原则:能用补全依赖就用补全依赖,只有当补全依赖导致性能问题(如频繁重订阅)时,才考虑 ref 模式。
一句话总结:Hooks 的规则源于 React 用调用顺序匹配 state 的链表机制,闭包陷阱源于组件每次渲染创建新闭包——理解了”顺序即身份”和”渲染即快照”这两件事,所有规则和陷阱都有了解释。
相关:React组件与函数本质 · useState完全指南 · useEffect完全指南 · useRef完全指南 · useCallback与useMemo