说说 Hooks 的闭包陷阱?如何解决?

一、是什么

闭包陷阱(Stale Closure)是 React Hooks 中最常见的 bug 之一。它指的是在 useEffect、useCallback、事件处理器等闭包中,捕获了某次渲染时的 state/props 旧值,而非最新值,导致行为不符合预期。

要理解闭包陷阱,首先要理解 React 函数组件的运行模型:每次渲染都是一次函数调用,每次调用都有自己的 props、state 和闭包环境

function Counter() {
  const [count, setCount] = useState(0);

  // 这个函数"捕获"了当前渲染的 count 值
  const handleClick = () => {
    setTimeout(() => {
      alert(`你点击时 count 是 ${count}`);
    }, 3000);
  };

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
      <button onClick={handleClick}>显示 count</button>
    </div>
  );
}

如果你快速点击 +1 三次,然后点"显示 count",3 秒后弹窗显示的是点击时的值(比如 3),而不是弹窗出现时的最新值。这是闭包的正常行为,但在某些场景下会成为陷阱。

二、常见的闭包陷阱场景

场景一:useEffect 中的定时器

function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      // 闭包捕获了 count = 0,永远打印 0
      console.log('当前 count:', count);
      setCount(count + 1); // 永远是 0 + 1 = 1
    }, 1000);

    return () => clearInterval(timer);
  }, []); // 空依赖 → effect 只在挂载时执行一次

  return <div>{count}</div>;
}

这里 count 永远停留在初始值 0。因为 useEffect 的回调函数在挂载时创建,闭包捕获了当时的 count = 0,之后的 setInterval 回调一直引用这个旧值。

场景二:useCallback 中的过时状态

function MessageSender() {
  const [message, setMessage] = useState('');
  const [messages, setMessages] = useState([]);

  const sendMessage = useCallback(() => {
    // messages 被闭包捕获,始终是空数组
    setMessages([...messages, message]);
    setMessage('');
  }, []); // 缺少依赖

  return (
    <div>
      <input value={message} onChange={e => setMessage(e.target.value)} />
      <MemoizedButton onClick={sendMessage} />
      <ul>{messages.map((m, i) => <li key={i}>{m}</li>)}</ul>
    </div>
  );
}

因为 sendMessage 的依赖数组为空,它始终引用挂载时的 messages(空数组),每次发送都会覆盖之前的消息。

场景三:事件处理器中的异步操作

function ProfileEditor() {
  const [name, setName] = useState('Alice');

  const handleSave = async () => {
    await saveToServer(name);
    // 如果在 await 期间用户修改了 name
    // 这里的 name 是点击保存时的值,不是最新值
    alert(`已保存:${name}`);
  };

  return (
    <div>
      <input value={name} onChange={e => setName(e.target.value)} />
      <button onClick={handleSave}>保存</button>
    </div>
  );
}

三、解决方案

方案一:正确填写依赖数组

最直接的方案,让 effect 在依赖变化时重新创建闭包:

function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      console.log('当前 count:', count);
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(timer);
  }, [count]); // 加上 count 依赖

  return <div>{count}</div>;
}

但这带来新问题:每次 count 变化都要销毁旧定时器、创建新定时器,不够高效。

方案二:函数式更新(推荐)

当新 state 只依赖旧 state 时,使用 setter 的函数形式:

function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      setCount(prev => prev + 1); // 不依赖外部的 count 变量
    }, 1000);
    return () => clearInterval(timer);
  }, []); // 空依赖没问题了

  return <div>{count}</div>;
}

函数式更新的回调接收的 prev 参数始终是最新的 state 值,因此不受闭包影响。但如果需要读取最新 state(而非更新),这个方案就不够了。

方案三:useRef 保存最新值

useRef 创建的对象在组件的整个生命周期内保持同一个引用,.current 可以随时被更新和读取:

function Timer() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);

  // 每次渲染时同步最新值到 ref
  useEffect(() => {
    countRef.current = count;
  });

  useEffect(() => {
    const timer = setInterval(() => {
      // 始终读取最新值
      console.log('当前 count:', countRef.current);
      setCount(prev => prev + 1);
    }, 1000);
    return () => clearInterval(timer);
  }, []);

  return <div>{count}</div>;
}

封装成通用 Hook:

function useLatest(value) {
  const ref = useRef(value);
  ref.current = value;
  return ref;
}

function Timer() {
  const [count, setCount] = useState(0);
  const latestCount = useLatest(count);

  useEffect(() => {
    const timer = setInterval(() => {
      console.log('当前 count:', latestCount.current);
      setCount(prev => prev + 1);
    }, 1000);
    return () => clearInterval(timer);
  }, []);

  return <div>{count}</div>;
}

方案四:useReducer 替代 useState

当状态更新逻辑复杂时,dispatch 是稳定引用,不存在闭包陷阱:

function reducer(state, action) {
  switch (action.type) {
    case 'tick':
      return { ...state, count: state.count + 1 };
    case 'setStep':
      return { ...state, step: action.step };
    default:
      return state;
  }
}

function Timer() {
  const [state, dispatch] = useReducer(reducer, { count: 0, step: 1 });

  useEffect(() => {
    const timer = setInterval(() => {
      dispatch({ type: 'tick' }); // dispatch 引用稳定
    }, 1000);
    return () => clearInterval(timer);
  }, []);

  return <div>{state.count}</div>;
}

方案五:useEffectEvent(实验性 API)

React 团队提出的 useEffectEvent(目前在实验阶段)专门解决"effect 中需要读取最新值但不想把它加入依赖"的场景:

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  // onConnected 总是读取最新的 message,但不作为 effect 的依赖
  const onConnected = useEffectEvent(() => {
    showNotification(`已连接!当前消息:${message}`);
  });

  useEffect(() => {
    const conn = createConnection(roomId);
    conn.on('connected', onConnected);
    conn.connect();
    return () => conn.disconnect();
  }, [roomId]); // 不需要把 message 加到依赖中
}

useEffectEvent 创建的函数始终能读取最新的 props/state,但 React 将其视为"effect 的一部分"而非响应式依赖,从而解决了"需要最新值"和"不想触发重新执行"之间的矛盾。

四、心智模型

理解闭包陷阱的关键是建立正确的心智模型:

渲染 #1: count=0 → 创建闭包 A → 闭包 A 中 count 永远是 0
渲染 #2: count=1 → 创建闭包 B → 闭包 B 中 count 永远是 1
渲染 #3: count=2 → 创建闭包 C → 闭包 C 中 count 永远是 2

每次渲染就像拍了一张快照。闭包捕获的是快照中的值,而不是一个"实时更新"的变量。这和 class 组件的 this.state 行为相反——this.state 始终指向最新值,因为 this 是可变的。

对比 class 组件

class Timer extends React.Component {
  state = { count: 0 };

  componentDidMount() {
    this.timer = setInterval(() => {
      // this.state.count 始终是最新值
      this.setState({ count: this.state.count + 1 });
    }, 1000);
  }

  componentWillUnmount() {
    clearInterval(this.timer);
  }

  render() {
    return <div>{this.state.count}</div>;
  }
}

class 组件不会有闭包陷阱,因为 this.state 是可变引用。而函数组件的每次 state 值都是不可变的快照。

五、实践建议

  1. 开启 eslint-plugin-react-hooksexhaustive-deps 规则能自动检测遗漏的依赖
  2. 优先使用函数式更新setState(prev => prev + 1)setState(count + 1) 更安全
  3. 需要读取最新值时用 useRef:配合 useLatest 自定义 Hook 简化使用
  4. 复杂状态逻辑用 useReducer:dispatch 天然不受闭包影响
  5. 不要为了避免闭包而关闭 ESLint 规则// eslint-disable-next-line 往往会埋下更深的 bug

六、总结

闭包陷阱的本质是 JavaScript 闭包机制和 React 函数式渲染模型的交汇产物。每次渲染都会创建新的闭包环境,如果一个长期存在的回调(定时器、事件监听、异步操作)引用了某次渲染的变量,就会"定格"在那个时刻。

理解了"每次渲染都是一张快照"这个心智模型,闭包陷阱就不再神秘。选择正确的解决方案取决于场景:能用函数式更新就用函数式更新,需要读取最新值就用 useRef,复杂逻辑就用 useReducer。