文章

React Hooks闭包陷阱深度解析

React Hooks闭包陷阱深度解析

一句话概括

React Hooks 每次渲染都是一次全新的函数调用,闭包捕获的是当前渲染时的状态快照而非最新值。空依赖让闭包”冻结”在初始值上,定时器/事件监听/异步回调读到旧值的根源全在这。解法三条:函数式更新正确声明依赖useRef 穿透

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ❌ 闭包陷阱:count 永远是 0
function Bug() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    setInterval(() => {
      console.log(count);     // forever 0
      setCount(count + 1);    // forever 1
    }, 1000);
  }, []);
}

// ✅ 函数式更新:React 把最新值传给你
function Fix() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    setInterval(() => setCount(c => c + 1), 1000);
  }, []);
}

核心知识一:为什么会有闭包陷阱

函数组件每次渲染都是一次全新的函数执行,每次产生一套全新的闭包,各捕获各的值。问题出在你让老闭包活到了未来

1
2
3
Mount:         count=0   effect 执行 → 定时器回调捕获 count=0
用户点击:      count=1   新渲染,新闭包有 count=1,但定时器还在跑老回调
1000ms 后:     setInterval 触发 → 调用老闭包 → count 还是 0 ← 陷阱

核心原因就一句:useEffect(fn, []) → effect 只执行一次 → 回调永远是 mount 那次渲染的闭包。

核心知识二:函数式更新 — 绕开快照

1
2
3
4
5
// ❌ 点击快,count 可能不准(读的是闭包里的旧值)
<button onClick={() => setCount(count + 1)}>+1</button>

// ✅ React 保证 c 是最新状态
<button onClick={() => setCount(c => c + 1)}>+1</button>

setState(fn) 不是从闭包里读,而是 React 把当前最新状态传给你。新状态依赖旧状态时必须用它,上面 Fix 组件就是标准写法。

核心知识三:依赖数组 — 让闭包重新”照相”

读到最新值做判断时,函数式更新不够用——你需要依赖数组让 effect 重来:

1
2
3
4
5
6
7
8
9
10
11
12
13
function Search({ query }) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    let cancelled = false;
    fetch(`/api/search?q=${query}`)
      .then(r => r.json())
      .then(data => { if (!cancelled) setResults(data); });
    return () => { cancelled = true; };
  }, [query]); // query 变了 → 旧 effect 清理 → 新闭包捕获新 query

  return <ul>{results.map(r => <li key={r.id}>{r.name}</li>)}</ul>;
}

诀窍:effect 里用了什么外部变量,就放进依赖。react-hooks/exhaustive-deps 规则帮你做这个检查。

核心知识四:useRef — 穿透闭包的”任意门”

想读到最新值却不想频繁重建 effect?useRef

1
2
3
4
5
6
7
8
9
10
11
function ScrollTracker() {
  const [scrollY, setScrollY] = useState(0);
  const scrollYRef = useRef(scrollY);
  scrollYRef.current = scrollY; // 每次渲染同步

  useEffect(() => {
    const onScroll = () => console.log('最新位置:', scrollYRef.current);
    window.addEventListener('scroll', onScroll);
    return () => window.removeEventListener('scroll', onScroll);
  }, []); // 只绑定一次,但永远拿到最新 scrollY
}

精髓:闭包捕获的是 ref 对象(引用不变),通过 .current 读值绕过了快照限制。代价是改 .current 不触发渲染

核心知识五:useCallback + useRef 黄金组合

useCallback 的经典矛盾:传给 React.memo 子组件想要引用稳定,又想读最新 state:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function Parent() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  countRef.current = count;

  // 引用永不变化,通过 ref 读最新值
  const handleClick = useCallback(() => {
    console.log('最新值:', countRef.current);
    setCount(c => c + 1);
  }, []);

  return <Child onClick={handleClick} />;
}

const Child = React.memo(({ onClick }) => {
  console.log('Child 只在必要时渲染');
  return <button onClick={onClick}>+1</button>;
});

三解法速查:

场景解法代价
只更新状态setState(c => c + 1)
读最新值做计算正确依赖 [dep]effect 频繁重建
读最新值但不想重建useRef改动不触发渲染

其实你每天都在用

场景一:搜索防抖

1
2
3
4
5
6
7
8
function SearchBox() {
  const timerRef = useRef(null);
  const handleChange = (e) => {
    clearTimeout(timerRef.current);
    timerRef.current = setTimeout(() => fetch(`/api?q=${e.target.value}`), 300);
  };
  return <input onChange={handleChange} />;
}

timer ID 不参与 UI,useRef 天生合适。

场景二:WebSocket 消息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);
  const messagesRef = useRef(messages);
  messagesRef.current = messages;

  useEffect(() => {
    const ws = new WebSocket(`wss://chat/${roomId}`);
    ws.onmessage = (e) => {
      const msg = JSON.parse(e.data);
      setMessages([...messagesRef.current, msg]); // 永远基于最新列表
    };
    return () => ws.close();
  }, [roomId]);

  return <ul>{messages.map(m => <li key={m.id}>{m.text}</li>)}</ul>;
}

onmessage 绑定一次,但 messagesRef 永远拿到最新列表。

场景三:第三方库事件绑定

1
2
3
4
5
6
7
8
9
10
11
12
function MapWidget({ onMarkerClick }) {
  const cbRef = useRef(onMarkerClick);
  cbRef.current = onMarkerClick;

  useEffect(() => {
    const map = new ThirdPartyMap('#container');
    map.on('click', marker => cbRef.current(marker)); // 永远调最新回调
    return () => map.destroy();
  }, []);

  return <div id="container" />;
}

地图库初始化一次,但回调每次渲染都是新的——useRef 让老监听器能调到最新回调。

场景四:effect 清理的闭包隔离

1
2
3
4
5
6
7
function DataFetcher({ userId }) {
  useEffect(() => {
    let cancelled = false;
    fetchUser(userId).then(u => { if (!cancelled) setUser(u); });
    return () => { cancelled = true; };
  }, [userId]);
}

userId 变化 → 执行上次清理(那次 cancelled 变 true)→ 再执行新 effect。闭包隔离的正确用法。

常见误解 FAQ

❌ 误区一:”useEffect 加空依赖就一定有问题”

空依赖本身不是错,错的是里面读了需要最新值的外部变量。如果只用函数式更新和 ref,空依赖完全正确:

1
2
3
4
5
// ✅ 没问题:只更新不读取
useEffect(() => {
  const id = setInterval(() => setCount(c => c + 1), 1000);
  return () => clearInterval(id);
}, []);

❌ 误区二:”改了 ref.current 就会触发渲染”

ref.current = xxx 永远不会触发渲染——这是它和 setState 的根本区别。UI 要响应变化 → state;只是记个东西不重画 → ref。

❌ 误区三:”用 useCallback 包住所有函数就没闭包问题了”

useCallback 本身就是闭包——依赖数组里的值会被”冻结”。空依赖内部读 state,读到的就是旧值:

1
const handle = useCallback(() => console.log(count), []); // count 永远是初始值

❌ 误区四:”把依赖全加上就万事大吉”

加依赖 = effect 每次变化都重建。定时器场景下 count 一变就销毁重开,低效且可能闪烁。选方案看场景,不能无脑加。

一句话总结

React Hooks 闭包陷阱的本质是每次渲染都是独立快照,老闭包活到未来就看不到新值。解法按需选:只改状态用函数式更新,要读值用正确依赖,要读值但不想重建用 useRef。面试记住三个词:快照、闭包、ref 穿透。

本文由作者按照 CC BY 4.0 进行授权