React useEffect 完全指南:我在 3 个项目中踩坑后总结的血泪经验

1 阅读10分钟

React useEffect 完全指南:我在 3 个项目中踩坑后总结的血泪经验

依赖数组写错、清理函数忘记、无限重渲染——这些坑我都帮你踩过了


前言

我第一次被 useEffect 坑,是在一个电商项目的购物车页面。

场景很简单:用户添加商品后,需要实时更新购物车角标数字。我这样写的:

function CartIcon({ userId }) {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    fetchCartCount(userId).then(setCount);
  }, []); // 当时我觉得,userId 不会变,所以依赖数组写空
}

结果呢?用户切换账号后,角标显示的还是上一个用户的购物车数量。

我花了 3 个小时才找到问题:依赖数组写错了

但这还不是最惨的。

在另一个项目中,我忘记清理定时器,导致组件卸载后还在执行代码,内存泄漏让页面越来越卡。测试报告里全是"页面卡顿"、"浏览器崩溃"的 bug。

还有一次,我在 useEffect 里用了状态值,结果拿到了旧值,排查了一整天才发现是闭包陷阱。

这篇文章,就是我用无数个加班夜晚换来的 useEffect 血泪总结。我会告诉你:

  • 依赖数组的 5 个常见错误(我都踩过)
  • 清理函数什么时候执行(90% 的人理解错了)
  • 如何避免无限重渲染(这个坑太深了)
  • 2026 年的最佳实践(别再用 2019 年的写法了)

如果你也被"为什么 effect 总是执行?"、"为什么拿到的是旧值?"这些问题困扰过,继续往下看。


一、useEffect 的核心作用

什么是副作用?

简单说,副作用就是会让组件和外部世界产生交互的操作

比如:

  • 调用 API 获取数据
  • 手动操作 DOM
  • 设置定时器
  • 添加事件监听
  • 写入 localStorage

这些都是"副作用",因为它们超出了 React 的渲染范围。

useEffect 的基本用法

useEffect(() => {
  // 这里写副作用代码
  fetch('/api/data').then(...);
  
  // 可选:返回清理函数
  return () => {
    // 组件卸载或依赖变化时执行
    cleanup();
  };
}, [dependencies]); // 依赖数组

核心思想:useEffect 让 React 知道你的组件需要在渲染后"做些额外的事"。

依赖数组的三种写法

依赖数组执行时机使用场景踩坑指数
[]仅挂载时执行一次初始化、订阅⭐⭐⭐
[dep]dep 变化时执行响应状态变化⭐⭐
不传每次渲染后执行极少使用⭐⭐⭐⭐⭐

我的建议:90% 的场景用 [dep],10% 用 [],几乎不用"不传"。


二、依赖数组的正确用法(重点)

坑 1:遗漏依赖项(我踩过最多次)

// ❌ 我当年的写法,bug 找了我一下午
function Counter() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const timer = setInterval(() => {
      console.log(count); // 始终是 0!
    }, 1000);
    return () => clearInterval(timer);
  }, []); // 依赖数组是空的
  
  return <div>{count}</div>;
}

// ✅ 正确写法
function Counter() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const timer = setInterval(() => {
      console.log(count); // 现在能拿到最新的 count 了
    }, 1000);
    return () => clearInterval(timer);
  }, [count]); // 添加 count 到依赖数组
  
  return <div>{count}</div>;
}

问题表现:定时器里打印的 count 永远是初始值 0,不会更新。

原因:依赖数组是空的,effect 只在组件挂载时执行一次。那时候 count 是 0,所以定时器里的闭包捕获的也是 0。

我的血泪教训:ESLint 的 react-hooks/exhaustive-deps 规则会警告你遗漏的依赖。别忽略它,它大概率是对的。

坑 2:依赖项写太多导致无限循环

// ❌ 这样写会无限重渲染
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const config = { api: '/api/user' }; // 每次渲染都创建新对象
  
  useEffect(() => {
    fetchUser(userId, config).then(setUser);
  }, [userId, config]); // config 每次都变,导致无限循环
  
  return <div>{user?.name}</div>;
}

// ✅ 正确写法
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  
  useEffect(() => {
    fetchUser(userId, { api: '/api/user' }).then(setUser);
  }, [userId]); // 对象直接写在 effect 里,不作为依赖
  
  return <div>{user?.name}</div>;
}

问题表现:页面卡死,控制台全是"Maximum update depth exceeded"。

原因config 对象在每次渲染时都会创建新的引用,导致 effect 不停执行。

我的解决方法

  1. 把对象/数组直接写在 effect 里
  2. useMemo 保持引用稳定
  3. 只依赖原始值(string、number)

坑 3:函数作为依赖项

// ❌ 函数每次渲染都变
function SearchInput() {
  const [query, setQuery] = useState('');
  
  const handleSearch = (q) => { // 每次渲染都创建新函数
    console.log('search:', q);
  };
  
  useEffect(() => {
    const timer = setTimeout(() => {
      handleSearch(query); // handleSearch 在依赖数组里
    }, 300);
    return () => clearTimeout(timer);
  }, [query, handleSearch]); // handleSearch 每次都变!
  
  return <input onChange={e => setQuery(e.target.value)} />;
}

// ✅ 正确写法
function SearchInput() {
  const [query, setQuery] = useState('');
  
  useEffect(() => {
    const handleSearch = (q) => { // 函数写在 effect 里
      console.log('search:', q);
    };
    
    const timer = setTimeout(() => {
      handleSearch(query);
    }, 300);
    return () => clearTimeout(timer);
  }, [query]);
  
  return <input onChange={e => setQuery(e.target.value)} />;
}

我的建议:如果函数只在 effect 里用,就写在 effect 里面。如果需要传递给子组件,用 useCallback 包裹。

坑 4:state 更新依赖旧值

// ❌ 拿到的是旧值
function Counter() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const timer = setInterval(() => {
      setCount(count + 1); // count 是闭包里的旧值
    }, 1000);
    return () => clearInterval(timer);
  }, []);
  
  return <div>{count}</div>;
}

// ✅ 正确写法
function Counter() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const timer = setInterval(() => {
      setCount(prev => prev + 1); // 用函数式更新
    }, 1000);
    return () => clearInterval(timer);
  }, []);
  
  return <div>{count}</div>;
}

核心原则:如果 state 更新依赖于之前的值,用函数式更新 setCount(prev => prev + 1)

坑 5:异步函数直接作为 effect 回调

// ❌ 这是禁止的
useEffect(async () => {
  const data = await fetchData();
  setData(data);
}, []);

// ✅ 正确写法
useEffect(() => {
  async function fetchDataInEffect() {
    const data = await fetchData();
    setData(data);
  }
  fetchDataInEffect();
}, []);

原因:effect 回调不能是 async 函数,因为它需要返回清理函数(或 undefined)。


三、清理函数的正确使用

什么时候需要清理?

需要清理的场景

  • 定时器(setIntervalsetTimeout
  • 事件监听(addEventListener
  • 网络请求(AbortController)
  • 订阅(WebSocket、Observable)
  • 手动 DOM 操作

我踩过的坑

// ❌ 忘记清理定时器
useEffect(() => {
  setInterval(() => {
    console.log('tick');
  }, 1000);
  // 没有返回清理函数!
}, []);

// 结果:组件卸载后定时器还在跑,内存泄漏

// ✅ 正确写法
useEffect(() => {
  const timer = setInterval(() => {
    console.log('tick');
  }, 1000);
  
  return () => {
    clearInterval(timer); // 清理定时器
  };
}, []);

清理函数的执行时机

清理函数会在以下时机执行:

  1. 组件卸载时
  2. 依赖项变化,effect 重新执行前
useEffect(() => {
  console.log('effect 执行');
  
  return () => {
    console.log('清理函数执行');
  };
}, [dep]);

// 执行顺序:
// 1. 组件挂载:effect 执行
// 2. dep 变化:清理函数执行 → effect 执行
// 3. 组件卸载:清理函数执行

我的经验:把清理函数想象成"上一次的收尾工作",在下一次 effect 执行前,先把上一次的尾巴收干净。


四、实战场景

场景 1:数据获取(处理竞态条件)

背景:在一个文章详情页,用户可能快速切换不同文章。如果网络请求返回顺序和发起顺序不一致,会显示错误的数据。

function Article({ id }) {
  const [article, setArticle] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    let ignore = false; // 关键:忽略标志
    
    fetchArticle(id).then(data => {
      if (!ignore) {
        setArticle(data);
        setLoading(false);
      }
    });
    
    return () => {
      ignore = true; // 组件卸载或 id 变化时,忽略之前的请求
    };
  }, [id]);
  
  if (loading) return <div>加载中...</div>;
  return <div>{article.title}</div>;
}

效果:即使用户快速切换文章,也不会出现"闪回"问题(旧数据覆盖新数据)。

我踩过的坑:之前我不知道这个技巧,测试报告里经常有"显示的文章内容和标题不符"的 bug。后来加了 ignore 标志,问题解决了。

场景 2:事件监听(别忘了清理)

背景:需要监听窗口大小变化,实时更新布局。

function ResponsiveComponent() {
  const [width, setWidth] = useState(window.innerWidth);
  
  useEffect(() => {
    function handleResize() {
      setWidth(window.innerWidth);
    }
    
    window.addEventListener('resize', handleResize);
    
    return () => {
      window.removeEventListener('resize', handleResize); // 清理
    };
  }, []);
  
  return <div>窗口宽度:{width}</div>;
}

不清理的后果:组件多次挂载卸载后,handleResize 会被调用多次,性能越来越差。

场景 3:定时器(最常见的内存泄漏)

背景:倒计时组件,每秒更新剩余时间。

function Countdown({ targetTime }) {
  const [remaining, setRemaining] = useState(0);
  
  useEffect(() => {
    const timer = setInterval(() => {
      const now = Date.now();
      const diff = targetTime - now;
      setRemaining(Math.max(0, diff));
    }, 1000);
    
    return () => {
      clearInterval(timer); // 必须清理!
    };
  }, [targetTime]);
  
  return <div>剩余:{remaining}ms</div>;
}

我踩过的坑:有一次我忘记清理定时器,用户在一个页面来回切换了几十次,浏览器直接卡死。控制台全是"Can't perform a React state update on an unmounted component"的警告。

场景 4:WebSocket 连接(订阅与取消订阅)

背景:实时聊天室,需要保持 WebSocket 连接。

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);
  
  useEffect(() => {
    const ws = new WebSocket(`ws://example.com/${roomId}`);
    
    ws.onmessage = (event) => {
      setMessages(prev => [...prev, JSON.parse(event.data)]);
    };
    
    ws.onopen = () => {
      console.log('连接已建立');
    };
    
    return () => {
      ws.close(); // 清理连接
    };
  }, [roomId]);
  
  return <div>{messages.map(m => <div key={m.id}>{m.text}</div>)}</div>;
}

不清理的后果:切换聊天室后,旧房间的 WebSocket 还在接收消息,新房间会收到两个房间的消息。


五、性能优化技巧

1. 避免不必要的 effect 执行

// ❌ 对象作为依赖,每次都变
function Component({ config }) {
  useEffect(() => {
    // ...
  }, [config]); // config 每次都是新引用
}

// ✅ 用 useMemo 保持引用稳定
function Parent() {
  const config = useMemo(() => ({ api: '/api' }), []);
  return <Child config={config} />;
}

2. 使用自定义 Hook 封装逻辑

// 这是我用得最多的自定义 Hook 之一
function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    let ignore = false;
    
    fetch(url).then(res => res.json()).then(data => {
      if (!ignore) {
        setData(data);
        setLoading(false);
      }
    });
    
    return () => { ignore = true; };
  }, [url]);
  
  return { data, loading };
}

// 使用
function MyComponent() {
  const { data, loading } = useFetch('/api/data');
  // ...
}

好处

  • 逻辑复用
  • 代码整洁
  • 易于测试

3. 使用 useEffectEvent 处理非响应式依赖(React 19 新特性)

// React 19 新 API,解决闭包陷阱
import { useEffect, useRef, useEffectEvent } from 'react';

function ChatRoom({ roomId }) {
  const onMessage = useEffectEvent((message) => {
    // 这个函数不会触发 effect 重新执行
    console.log('收到消息:', message);
  });
  
  useEffect(() => {
    const ws = new WebSocket(`ws://example.com/${roomId}`);
    ws.onmessage = (event) => {
      onMessage(event.data); // 调用不会触发重新执行
    };
    return () => ws.close();
  }, [roomId]); // 只需要依赖 roomId
}

我的建议:如果你在用 React 19,可以用这个新 API 解决闭包陷阱问题。


六、最佳实践总结

场景推荐方案注意事项
数据获取useEffect + ignore 标志处理竞态条件
事件监听useEffect + 清理函数别忘了 removeEventListener
定时器useEffect + clearInterval必须清理
WebSocketuseEffect + close切换房间时清理
依赖数组完整依赖用 ESLint 检查
状态更新函数式更新setCount(prev => prev + 1)

核心原则(我每条都是用教训换来的)

  1. 依赖数组必须完整 - 用 ESLint 插件自动检查
  2. 及时清理副作用 - 尤其是定时器和订阅
  3. 避免在 effect 中创建对象/数组作为依赖 - 用 useMemo 保持引用稳定
  4. 异步函数不要直接作为 effect 回调 - 在 effect 内部定义 async 函数
  5. 状态更新依赖旧值时用函数式更新 - setCount(prev => prev + 1)

我的个人建议

经过三年的 useEffect 使用经验,我有以下几点建议:

  1. 装上 ESLint 插件 - 它能帮你避免 90% 的依赖项错误
  2. 每个 effect 只做一件事 - 不要把多个不相关的逻辑塞进一个 effect
  3. 清理函数一定要写 - 尤其是定时器、事件监听、网络请求
  4. 遇到闭包陷阱用函数式更新 - 或者用 React 19 的 useEffectEvent
  5. 复杂逻辑封装成自定义 Hook - 代码会更清晰

七、工具推荐

1. ESLint 插件(必装)

npm install -D eslint-plugin-react-hooks

配置

{
  "plugins": ["react-hooks"],
  "rules": {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn"
  }
}

我的体验:这个插件帮我避免了至少 30+ 个潜在 bug,强烈建议每个 React 项目都装上。

2. React DevTools

主要功能

  • 查看组件渲染次数
  • 分析 effect 执行时机
  • 调试 Hook 依赖

使用技巧:打开"Highlight Updates"功能,可以看到哪些组件在重渲染。


总结

写了两年的 useEffect,我有三点最深的体会:

  1. 依赖数组别偷懒 - 少写一个依赖项,bug 找你好几天
  2. 清理函数一定要写 - 尤其是定时器、事件监听、网络请求
  3. 复杂逻辑封装成 Hook - 代码会清晰很多

如果你刚开始学 useEffect,我的建议是:

  • 先掌握基本用法,理解依赖数组和清理函数
  • 装上 ESLint 插件,让它帮你检查错误
  • 多写多练,踩几个坑就学会了
  • 遇到问题先看官方文档

最后,别被各种"最佳实践"吓到。先写出能跑的代码,再优化——这是我从无数个项目中学到的真理。


参考资料

  1. React 官方文档 - useEffect: react.dev/reference/r…
  2. React 官方文档 - 同步 effect: react.dev/learn/synch…
  3. React 官方博客 - 深入 useEffect: overreacted.io/a-complete-…
  4. patterns.dev - React Hooks 模式:www.patterns.dev/react/hooks

觉得文章对你有帮助?

  • 👍 点赞支持一下,让我更有动力创作
  • 收藏备用,下次遇到类似问题快速找到
  • 📢 分享给团队伙伴,一起提升代码质量
  • 💬 评论区聊聊:你在使用 useEffect 时遇到过哪些坑?

你的每一次互动,都是我继续创作的动力!


关于作者

前端开发 8 年,踩过无数坑,写过不少烂代码。现在在一家创业公司负责前端架构,日常和 React/Vue 打交道。

我的目标:分享最实用的前端技巧,帮助大家少踩坑,早点下班摸鱼🐟。

关注我,获取更多前端实战内容!