跳至正文
来两杯美式
返回

React 核心(9):Hooks 规则与闭包陷阱

By 来两杯美式
发布于

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()
自定义 Hookfunction 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 报错
提前 returnreturn 之后的 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——因为 handleAlertcount=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 里的 roomIdmessages 都是初始渲染的快照。

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


分享这篇文章:
通过邮件分享这篇文章✓ 链接已复制
所属专题
React
第 9 / 17 篇
查看系列全部文章
  1. 01.React 核心(1):React 组件与函数的本质区别
  2. 02.React 核心(2):声明式与 React 的实现机制
  3. 03.React 核心(3):Props 完全指南
  4. 04.React 核心(4):useState 完全指南
  5. 05.React 核心(5):useEffect 完全指南
  6. 06.React 核心(6):useRef 完全指南
  7. 07.React 核心(7):Context API 与状态共享
  8. 08.React 核心(8):useCallback 与 useMemo
  9. 09.React 核心(9):Hooks 规则与闭包陷阱
  10. 10.React 核心(10):自定义 Hook 设计
  11. 11.React 核心(11):Key 与列表渲染
  12. 12.React 核心(12):表单与受控/非受控组件
  13. 13.React 核心(13):React 渲染机制深入——Fiber 与调度
  14. 14.React 核心(14):React 18 并发特性
  15. 15.React 核心(15):Suspense 机制详解
  16. 16.React 核心(16):TanStack Query 入门指南
  17. 17.React 核心(17):TanStack Query 缓存机制详解

上一篇
React 核心(10):自定义 Hook 设计
下一篇
React 核心(8):useCallback 与 useMemo