深入理解 React useEffect:从入门到精通的 8 个关键技巧


🎯 核心摘要

useEffect 是 React Hooks 中最强大也最容易误用的 Hook 之一。本文深入解析 useEffect 的执行机制、依赖数组的正确使用方式、清理函数的编写技巧,以及 8 个实际开发中经常遇到的场景和解决方案。无论你是刚接触 React 的新手,还是有一定经验的开发者,都能从中获得启发,避免常见的陷阱。


📚 原文要点

1. useEffect 的本质

useEffect 用于处理组件中的"副作用",即那些不需要在渲染期间执行的操作,比如数据获取、订阅、手动 DOM 操作等。

2. 依赖数组的作用

依赖数组决定了 useEffect 何时重新执行。空数组表示只在挂载时执行一次,包含变量则表示当这些变量变化时重新执行。

3. 清理函数的重要性

返回的清理函数在组件卸载或下次 effect 执行前调用,用于取消订阅、清除定时器等,避免内存泄漏。


🔍 技术点解析

技术点 说明 应用场景
useEffect 基础 处理组件副作用 数据获取、订阅、DOM 操作
依赖数组 控制 effect 执行时机 避免不必要的重新执行
清理函数 清理副作用 取消订阅、清除定时器
执行时机 渲染完成后执行 保证 DOM 已更新
严格模式 开发环境双重执行 帮助发现清理问题

💡 补充信息

背景知识

useEffect 是 React 16.8 引入的 Hooks 之一,它的出现让函数组件能够处理副作用,不再局限于纯 UI 渲染。在 class 组件时代,我们需要在 componentDidMountcomponentDidUpdatecomponentWillUnmount 等多个生命周期中分散处理逻辑,而 useEffect 将这些统一到一个 API 中。

最佳实践

  1. 每个 effect 关注单一职责 - 不要把所有逻辑塞进一个 useEffect
  2. 正确设置依赖数组 - 使用 ESLint 插件检查依赖项
  3. 及时清理副作用 - 尤其是订阅和定时器
  4. 避免在 effect 中更新状态导致循环 - 仔细检查依赖项

常见问题

  • 无限循环 - 依赖数组包含状态,effect 中又更新该状态
  • 过时闭包 - 依赖数组不完整,effect 中使用旧值
  • 清理函数遗漏 - 导致内存泄漏或意外行为

📝 完整文章

深入理解 React useEffect:从入门到精通的 8 个关键技巧

一、为什么 useEffect 让人又爱又恨?

在我接触 React Hooks 的初期,useEffect 是让我最头疼的 API。明明按照文档写了代码,却总是遇到各种奇怪的问题:

  • 为什么我的 effect 执行了两次?
  • 为什么数据请求一直在循环?
  • 为什么我拿到的状态值总是旧的?

相信很多开发者都有过类似的困惑。今天,我们就来彻底搞懂 useEffect,让你在实际开发中游刃有余。

二、useEffect 到底是什么?

简单来说,useEffect 用于处理组件中的副作用。那么什么是副作用?

副作用指的是那些不需要在渲染期间执行的操作,它们"影响"了组件之外的事情。常见的副作用包括:

  • 向服务器发送数据请求
  • 订阅或取消订阅事件
  • 手动操作 DOM
  • 设置或清除定时器
  • 日志记录

在 class 组件时代,这些逻辑分散在不同的生命周期方法中:

javascript 复制代码
class MyComponent extends React.Component {
  componentDidMount() {
    // 组件挂载后执行
    this.subscribeToChat();
  }
  
  componentDidUpdate(prevProps) {
    // 组件更新后执行
    if (prevProps.roomId !== this.props.roomId) {
      this.subscribeToChat();
    }
  }
  
  componentWillUnmount() {
    // 组件卸载前执行
    this.unsubscribeFromChat();
  }
  
  render() {
    return <div>{this.props.content}</div>;
  }
}

而使用 useEffect,我们可以将这些逻辑统一到一个地方:

javascript 复制代码
function MyComponent({ roomId }) {
  useEffect(() => {
    // 这个函数在每次渲染后执行
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    
    // 返回的清理函数在下次 effect 执行前或组件卸载时调用
    return () => {
      connection.disconnect();
    };
  }, [roomId]); // 依赖数组:当 roomId 变化时重新执行
  
  return <div>Connected to {roomId}</div>;
}

三、useEffect 的执行时机

理解 useEffect 的执行时机至关重要。记住这个核心原则:

useEffect 在渲染完成后执行

这意味着:

  1. React 先执行你的组件函数,生成虚拟 DOM
  2. React 将虚拟 DOM 同步到真实 DOM
  3. 浏览器完成屏幕绘制
  4. useEffect 开始执行

这个执行顺序保证了你在 effect 中可以访问到最新的 DOM,但也意味着 effect 中的操作不会阻塞渲染。

严格模式下的双重执行

在开发环境中,如果你使用了 React.StrictMode,useEffect 会执行两次。这是故意的!目的是帮助你发现清理函数编写是否正确。

javascript 复制代码
useEffect(() => {
  console.log('Effect 执行');
  
  return () => {
    console.log('清理函数执行');
  };
}, []);

// 开发环境输出:
// Effect 执行
// 清理函数执行
// Effect 执行

不要担心,生产环境中不会这样。但请确保你的清理函数能够正确处理这种情况。

四、依赖数组:useEffect 的灵魂

依赖数组是 useEffect 最核心也最容易出错的部分。让我们通过几个场景来理解它。

场景 1:只在挂载时执行一次

javascript 复制代码
useEffect(() => {
  // 只在组件首次挂载时执行
  fetchInitialData();
}, []); // 空依赖数组

适用于:初始化数据、设置全局订阅等只需要执行一次的操作。

场景 2:当特定变量变化时执行

javascript 复制代码
useEffect(() => {
  // 当 userId 变化时重新执行
  fetchUserData(userId);
}, [userId]); // 依赖数组包含 userId

适用于:需要根据 props 或 state 变化重新执行的操作。

场景 3:每次渲染后都执行

javascript 复制代码
useEffect(() => {
  // 每次渲染后都执行(谨慎使用!)
  console.log('渲染完成');
}); // 没有依赖数组

警告:这种写法很少使用,因为可能导致性能问题或无限循环。

场景 4:多个依赖项

javascript 复制代码
useEffect(() => {
  // 当 userId 或 roomId 任意一个变化时执行
  connectToChat(userId, roomId);
}, [userId, roomId]);

五、8 个关键技巧与实战场景

技巧 1:正确清理副作用

忘记清理是常见的错误来源。尤其是定时器和订阅:

javascript 复制代码
// ❌ 错误示例:没有清理定时器
useEffect(() => {
  const timer = setInterval(() => {
    setCount(c => c + 1);
  }, 1000);
}, []);

// ✅ 正确示例:清理定时器
useEffect(() => {
  const timer = setInterval(() => {
    setCount(c => c + 1);
  }, 1000);
  
  return () => {
    clearInterval(timer);
  };
}, []);

技巧 2:避免无限循环

这是新手最容易踩的坑:

javascript 复制代码
// ❌ 错误示例:无限循环
const [count, setCount] = useState(0);

useEffect(() => {
  setCount(count + 1); // effect 中更新依赖的状态
}, [count]); // count 变化触发 effect,effect 又更新 count...

// ✅ 正确示例:只在需要时更新
useEffect(() => {
  if (count < 10) {
    setCount(count + 1);
  }
}, [count]);

技巧 3:使用函数式更新

当新状态依赖于旧状态时,使用函数式更新:

javascript 复制代码
// ✅ 推荐写法
useEffect(() => {
  const timer = setInterval(() => {
    setCount(c => c + 1); // 使用函数式更新,避免依赖 count
  }, 1000);
  
  return () => clearInterval(timer);
}, []); // 不需要依赖 count

技巧 4:异步操作的正确处理

useEffect 本身不能是 async 函数,但可以在内部使用 async:

javascript 复制代码
// ❌ 错误:useEffect 不能是 async
useEffect(async () => {
  const data = await fetchData();
  setData(data);
}, []);

// ✅ 正确:在内部使用 async
useEffect(() => {
  const fetchData = async () => {
    const data = await api.getData();
    setData(data);
  };
  
  fetchData();
}, []);

// ✅ 更优雅:使用 IIFE
useEffect(() => {
  (async () => {
    const data = await api.getData();
    setData(data);
  })();
}, []);

技巧 5:处理竞态条件

当快速切换依赖项时,可能出现竞态条件:

javascript 复制代码
// ❌ 可能的问题:旧请求的结果覆盖新请求
useEffect(() => {
  fetchComments(postId).then(setComments);
}, [postId]);

// ✅ 正确:使用清理函数取消过时的请求
useEffect(() => {
  let cancelled = false;
  
  fetchComments(postId).then(data => {
    if (!cancelled) {
      setComments(data);
    }
  });
  
  return () => {
    cancelled = true;
  };
}, [postId]);

技巧 6:自定义 Hook 封装

将可复用的 effect 逻辑提取为自定义 Hook:

javascript 复制代码
// 自定义 Hook:处理窗口大小
function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  });
  
  useEffect(() => {
    const handleResize = () => {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight
      });
    };
    
    window.addEventListener('resize', handleResize);
    
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []);
  
  return size;
}

// 使用
function MyComponent() {
  const { width, height } = useWindowSize();
  return <div>Window size: {width} x {height}</div>;
}

技巧 7:多个 effect 分离关注点

不要把所有逻辑塞进一个 effect:

javascript 复制代码
// ❌ 不推荐:一个 effect 做太多事
useEffect(() => {
  fetchUserData(userId);
  document.title = `User: ${userName}`;
  const timer = setInterval(logActivity, 1000);
  
  return () => clearInterval(timer);
}, [userId, userName]);

// ✅ 推荐:分离为多个 effect
useEffect(() => {
  fetchUserData(userId);
}, [userId]);

useEffect(() => {
  document.title = `User: ${userName}`;
}, [userName]);

useEffect(() => {
  const timer = setInterval(logActivity, 1000);
  return () => clearInterval(timer);
}, []);

技巧 8:使用 ESLint 插件

安装并使用 eslint-plugin-react-hooks,它会自动检查依赖数组:

bash 复制代码
npm install eslint-plugin-react-hooks
javascript 复制代码
// ESLint 会警告你遗漏的依赖
useEffect(() => {
  console.log(userId, userName);
}, []); // ⚠️ React Hook useEffect has missing dependencies: 'userId', 'userName'

六、常见陷阱与解决方案

陷阱 1:过时的闭包

javascript 复制代码
// ❌ 问题:count 总是 0
const [count, setCount] = useState(0);

useEffect(() => {
  const timer = setInterval(() => {
    console.log(count); // 永远是 0
    setCount(count + 1); // 永远是 1
  }, 1000);
}, []);

// ✅ 解决 1:使用函数式更新
useEffect(() => {
  const timer = setInterval(() => {
    setCount(c => {
      console.log(c);
      return c + 1;
    });
  }, 1000);
}, []);

// ✅ 解决 2:添加依赖(但会频繁重置定时器)
useEffect(() => {
  const timer = setInterval(() => {
    console.log(count);
    setCount(count + 1);
  }, 1000);
  return () => clearInterval(timer);
}, [count]);

陷阱 2:对象/数组作为依赖

javascript 复制代码
// ❌ 问题:config 每次都是新引用,effect 不停执行
const [config, setConfig] = useState({ theme: 'dark', lang: 'zh' });

useEffect(() => {
  applyConfig(config);
}, [config]); // config 引用每次都变

// ✅ 解决 1:使用具体属性
useEffect(() => {
  applyConfig(config);
}, [config.theme, config.lang]);

// ✅ 解决 2:使用 useMemo 稳定引用
const config = useMemo(() => ({ theme: 'dark', lang: 'zh' }), []);

七、总结

useEffect 是 React Hooks 中最强大的工具之一,但也需要谨慎使用。记住以下要点:

  1. 明确 effect 的目的 - 每个 effect 只做一件事
  2. 正确设置依赖数组 - 使用 ESLint 检查
  3. 不要忘记清理 - 尤其是定时器和订阅
  4. 理解执行时机 - effect 在渲染后执行
  5. 善用自定义 Hook - 封装可复用逻辑

掌握这些技巧后,你会发现 useEffect 不再神秘,而是你开发中的得力助手。


🔗 参考资料

  1. React 官方文档 - useEffect
  2. React 官方教程 - 同步与 Effects
  3. React Hooks 完全指南
  4. Overreacted - 深入理解 useEffect

相关推荐
三年三月2 小时前
Redux 技术栈使用总结
前端·react.js
三掌柜6664 小时前
TypeScript+React 全栈生态实战:从架构选型到工程落地,告别开发踩坑
react.js·架构·typescript
Easonmax4 小时前
ReactNative for OpenHarmony项目鸿蒙化三方库:react-native-linear-gradient — 绚丽的渐变效果实现
react native·react.js·harmonyos
光影少年4 小时前
React和Vue的区别?
前端·vue.js·react.js
英俊潇洒美少年4 小时前
react useDeferredValue和useTransition有啥区别
javascript·react.js·ecmascript
zadyd15 小时前
Workflow or ReAct ?
前端·react.js·前端框架
从文处安18 小时前
「前端何去何从」(React教程)React 状态管理:从局部 State 到可扩展架构
前端·react.js
Wect18 小时前
React Scheduler & Lane 详解
前端·react.js·面试
玉米Yvmi21 小时前
React自定义Hook实战指南:从入门到精通,让你的代码像乐高一样灵活
前端·react.js·面试