快手前端面试题:手写 `useInterval`,彻底搞懂 React Hooks 中的闭包陷阱

475 阅读4分钟

大家好,我是 小公主 👋。
最近在复盘面试题的时候,遇到了一道很有代表性的问题:手写一个防闭包陷阱的 useInterval

别看只是个定时器问题,背后其实考察了 React Hooks 的核心逻辑:闭包陷阱、副作用管理、内存泄漏防范和可复用性。
在快手、字节、美团等大厂的前端面试里,这是一道高频题,能很好地区分“会用 Hooks”与“理解 Hooks”的差别。

今天这篇文章,我就带大家:

  1. 看看为什么直接写会踩闭包坑
  2. 手写一个健壮的 useInterval
  3. 深入分析设计思想
  4. 给出完整的实战案例

读完这篇,你不仅能写,还能说清楚 为什么这么写


🧩 一、为什么直接用 setInterval 会出问题?

初学者可能会写出这样的 Hook:

function useInterval(callback, delay) {
  useEffect(() => {
    const id = setInterval(callback, delay);
    return () => clearInterval(id);
  }, [delay]);
}

看似合理,但实际使用时会遇到 闭包陷阱(stale closure)

❌ 问题演示

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

  useInterval(() => {
    console.log(count); // 期望打印最新值,但永远是 0!
  }, 1000);

  return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
}

原因在于:

  • 第一次渲染时,callback 捕获了 count = 0
  • 组件更新后,callback 虽然变了,但 useEffect 只依赖了 delay,不会重建定时器
  • 结果 setInterval 永远执行的是旧的回调

这就是典型的闭包问题。


✅ 二、正确解法:useRef + useEffect

解决思路很简单:

  • useRef 存储最新的回调,突破闭包限制
  • useEffect 管理定时器,在 delay 改变时重建

最终写法如下:

// hooks/useInterval.js
import { useEffect, useRef } from 'react';

function useInterval(callback, delay) {
  const savedCallback = useRef();

  // 更新最新的回调
  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  // 管理定时器
  useEffect(() => {
    if (delay === null) return; // 传 null 表示暂停

    const tick = () => savedCallback.current();
    const id = setInterval(tick, delay);

    return () => clearInterval(id); // 清理,防止内存泄漏
  }, [delay]);
}

export default useInterval;

🔍 三、设计思想拆解

1. useRef 避免闭包陷阱

  • 普通函数会捕获“值”,而 ref.current 保存的是“引用”
  • 定时器每次执行的 savedCallback.current(),永远是最新的函数

2. 依赖分离,逻辑更清晰

  • callback 更新时,只更新 ref,不会重启定时器
  • delay 更新时,才重建定时器

这样避免了因为回调变化导致定时器被频繁清除重建。

3. 支持 null 来暂停

  • delay === null 时,不创建定时器
  • 可以灵活实现“开始 / 暂停”的功能

4. 自动清理,防止内存泄漏

  • 组件卸载时自动清除定时器
  • 避免了后台还在执行的风险

🧪 四、实战:实现一个可暂停的计数器

import React, { useState } from 'react';
import useInterval from './hooks/useInterval';

function App() {
  const [count, setCount] = useState(0);
  const [running, setRunning] = useState(true);

  useInterval(
    () => setCount(prev => prev + 1),
    running ? 1000 : null
  );

  return (
    <div>
      <h1>Count: {count}</h1>
      <button onClick={() => setRunning(!running)}>
        {running ? 'Pause' : 'Start'}
      </button>
      <button onClick={() => setCount(0)}>Reset</button>
    </div>
  );
}

export default App;

👉 运行效果如下图

20250918-1101-14.1650354.gif

📚 五、为什么这是优秀实践?

特性说明
✅ 解决闭包陷阱保证回调永远拿到最新值
✅ 依赖分离避免定时器频繁重建
✅ 支持暂停delay = null 灵活控制
✅ 自动清理避免内存泄漏
✅ 可复用组件化封装,逻辑清晰

这也是 React 官方推荐的实现方式,既优雅又健壮。


💡 面试加分点

如果在面试里遇到这道题,除了写出代码,还可以顺便补充:

  • 为什么会产生闭包陷阱
  • 为什么选择 useRef 而不是 useCallback
  • 如何防止内存泄漏
  • 实际开发中的应用场景(轮询请求、动画、计时器等)

这样面试官会觉得你不仅能写,还能讲清楚原理。


🎯 总结

手写 useInterval 看似小题,但背后包含了 React Hooks 的核心理念

  • 闭包处理
  • 副作用管理
  • 可复用的自定义 Hook

掌握这道题,不仅能应对面试官的追问,更能在日常开发中写出健壮、可维护的业务逻辑。

下次面试官问:“你会手写 useInterval 吗?”
你就可以自信地说: “我不仅会写,还能讲明白为什么这么写。”


📌 如果你觉得文章对你有帮助,记得 点赞 + 收藏,也欢迎在评论区留言交流。
💬 你在面试中遇到过哪些有意思的 Hooks 手写题?一起来分享下吧 🚀