React useEffect 依赖数组踩坑:闭包陷阱与清理函数

React useEffect 依赖数组踩坑:闭包陷阱与清理函数

写 React 的时候,useEffect 大概是最容易「看起来对、跑起来错」的 Hook。定时器读到的永远是旧数据、请求回来把已经卸载的组件更新报警告、依赖数组填了个对象结果每次渲染都重新执行......这些坑几乎每个 React 开发者都踩过。这篇把最常见的两类问题------闭包陷阱和清理函数------讲透。

先看一个必翻车的例子

需求很简单:一个计数器,点按钮后每秒自动加一。很多人第一版会这么写:

jsx 复制代码
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      // 这里的 count 是哪一次的?
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []); // 依赖数组空的

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

跑起来你会发现:数字从 0 变成 1,然后就卡住了,再也不动。

问题出在闭包

useEffect 的回调只在挂载时执行了一次(依赖数组是 [])。执行那一刻,count 的值是 0,这个 0setInterval 里的闭包永久捕获 了。之后每一秒执行的都是 setCount(0 + 1),也就是永远把 count 设成 1。

这就是「闭包陷阱」:effect 里引用的 state/props,锁定的是那次渲染时的快照值,不会自动跟着后续渲染更新。

修法一:函数式更新,绕开对旧值的依赖

如果你只是想基于上一个值递增,根本不需要读 count,用 setState 的函数式写法即可:

jsx 复制代码
useEffect(() => {
  const id = setInterval(() => {
    // 不读外部的 count,拿到的永远是最新值
    setCount(c => c + 1);
  }, 1000);
  return () => clearInterval(id);
}, []); // 依赖数组可以安心留空

setCount(c => c + 1) 里的 c 是 React 传进来的最新 state,和闭包捕获的那个 count 无关,所以不会卡住。

修法二:老老实实把依赖写进去

如果 effect 内部确实要读某个 state 做别的事,那就把它放进依赖数组,让 effect 在值变化时重新绑定:

jsx 复制代码
useEffect(() => {
  const id = setInterval(() => {
    console.log('当前 count:', count); // 这里能读到最新值
    setCount(count + 1);
  }, 1000);
  return () => clearInterval(id); // 每次重建前先清掉旧定时器
}, [count]); // count 变了就重新执行

注意:这样每次 count 变化都会「清掉旧 interval + 建新 interval」。逻辑正确,但对高频场景开销更大。能用函数式更新就优先用修法一。

记住一条规则:effect 里用到的所有响应式值(state、props、以及基于它们算出来的函数),都应该出现在依赖数组里。不想它进依赖,就想办法在 effect 内部不引用它(比如函数式更新、useRef)。

清理函数:被忽略的一半

useEffect 的返回值是清理函数,它在两个时机执行:组件卸载时 ,以及下一次 effect 执行前。忘了写清理,轻则内存泄漏,重则报错。

典型场景:请求回来时组件已经卸载

jsx 复制代码
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    let cancelled = false; // 标志位,标记这次 effect 是否已失效

    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => {
        // 组件卸载 / userId 变了,就别再 setState
        if (!cancelled) setUser(data);
      });

    return () => {
      cancelled = true; // 清理:让这次未完成的请求结果作废
    };
  }, [userId]);

  return <div>{user?.name ?? '加载中...'}</div>;
}

如果不加 cancelled 标志,用户快速切换 userId 时会有两个隐患:一是慢请求后回来覆盖快请求的结果(竞态);二是组件已卸载还调 setState,控制台报 "Can't perform a React state update on an unmounted component"。

更彻底的做法是用 AbortController 直接取消请求:

jsx 复制代码
useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/users/${userId}`, { signal: controller.signal })
    .then(res => res.json())
    .then(setUser)
    .catch(err => {
      // 主动取消会抛 AbortError,忽略它
      if (err.name !== 'AbortError') throw err;
    });

  return () => controller.abort(); // 清理:直接中断请求
}, [userId]);

事件监听、订阅、WebSocket 同理,建了就要在清理函数里对应地拆掉:

jsx 复制代码
useEffect(() => {
  const onResize = () => setWidth(window.innerWidth);
  window.addEventListener('resize', onResize);
  return () => window.removeEventListener('resize', onResize);
}, []);

依赖数组里放对象/函数:每次都触发

还有一个高频坑:把对象或函数直接塞进依赖数组。

jsx 复制代码
function List({ filters }) {
  useEffect(() => {
    fetchData(filters);
  }, [filters]); // 如果父组件每次渲染都传新对象,这里就每次都执行
}

React 用 Object.is 比较依赖。对象、数组、函数比的是引用,父组件每次渲染 { status: 'active' } 都是新引用,于是 effect 每次都触发。解决办法:

  • 依赖具体的原始值,而不是整个对象:}, [filters.status, filters.page]);
  • 父组件用 useMemo / useCallback 稳定住引用后再传下来。
jsx 复制代码
// 父组件里
const filters = useMemo(() => ({ status, page }), [status, page]);

小结

  • 闭包陷阱 :effect 引用的 state 是渲染快照。要么用 setState(prev => ...) 函数式更新绕开,要么把依赖如实写进数组。
  • 依赖数组:effect 里用到的响应式值必须全部列出;不想让它进依赖,就在 effect 内部别引用它。
  • 清理函数 :定时器、监听、订阅、请求,建了必须在返回的清理函数里拆掉;异步请求用 cancelled 标志或 AbortController 防竞态和卸载后 setState。
  • 对象/函数依赖:比的是引用,拆成原始值或用 useMemo/useCallback 稳定引用。

一句话记忆点:effect 里读到的是快照,清理函数拆的是上一次的自己。 想清楚「这个值是哪一次渲染的」和「这次 effect 留了什么需要收拾」,大部分 useEffect 的坑就都绕开了。

相关推荐
_瑞8 小时前
深入理解 iOS 渲染原理
前端·ios
IT_陈寒8 小时前
SpringBoot自动配置失灵?你可能忘了这个关键注解
前端·人工智能·后端
iCOD3R8 小时前
Skill - kill-ai-slop 解决“AI 味”样式
前端·css·ai编程
shuaijie05188 小时前
强制修改调用接口的api地址。
javascript·vue.js·ecmascript
JerrySir8 小时前
恶意 npm 包只活了 17 分钟:技术面试里,为什么“升级依赖”还不算止血?
javascript·安全
皮音8 小时前
Skill 在项目中的实践 —— 从 Agent 困境到 Skill 工程化
前端
白帽小阳9 小时前
2026前端面试题!(附答案及解析)
javascript·网络·python·安全·web安全·网络安全·护网行动
大龄秃头程序员9 小时前
RN 0.86 新架构实战:TurboModule + Fabric 组件 + iOS 原生容器,完整代码开源
前端·react native
这是个栗子9 小时前
前端开发中的常用工具函数(八)
开发语言·前端·javascript
Hilaku9 小时前
Vue 和 React 真正的差距,不在语法,而在团队犯错成本
前端·javascript·程序员