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 的坑就都绕开了。

相关推荐
宸翰2 小时前
uni-app 设置 Android 底部虚拟导航栏背景色
android·前端·uni-app
柒和远方2 小时前
LeetCode 4. 寻找两个正序数组的中位数 —— 二分划分的艺术
javascript·python·算法
金色的暴发户2 小时前
一劳永逸解决Codex手机验证
前端·人工智能·开源
默_笙2 小时前
😢 我被 JS 的 this 整不会了:同一个函数,换个“地区”调用,结果就不一样
javascript
DanCheOo2 小时前
AI 应用的可观测性:你的 AI 系统在生产上到底表现怎么样
前端·openai
Hyyy3 小时前
流式渲染用react-markdown还是Vercel/Streamdown
前端
Tom@敲代码3 小时前
js学习笔记-01
javascript·笔记·学习
风中的小甜瓜3 小时前
ASP.NET Page 那点事
java·前端·asp.net
木西3 小时前
告别机械重复:使用 Node.js + Playwright 构建 24 小时全自动测试网领水脚本
前端·javascript·node.js