useEffect 的核心使用技巧与避坑指南

背景

在 React 中,useStateuseEffect 是两个最关键的 Hooks。

据不完全统计,约 90% 的组件使用 useState,而 70% 的组件会用到 useEffect,这充分说明了它们的重要性。

useEffect 是处理副作用(数据请求、DOM 操作、订阅等),是替代生命周期方法(componentDidMountcomponentDidUpdatecomponentWillUnmount),是连接 React 与外部系统的桥梁。

最近我在使用 Trae 进行代码检查时,发现它总是提醒我注意 useEffect 的依赖项问题。因此,如何正确使用 useEffect 以确保依赖项设置正确,成为了我必须重点关注的问题。

避免无限循环

依赖项与状态更新

useEffect 中直接修改依赖项的状态(如 setState),导致依赖项变化 → 重新执行副作用 → 再次修改状态,形成循环。

ts 复制代码
// ❌ 错误示例:每次更新 count 都会触发 effect
useEffect(() => {
  setCount(count + 1);
}, [count]);

// ✅ 正确:函数式更新 + 空依赖
useEffect(() => {
  setCount(prev => prev + 1);
}, []);

// ✅ 正确:条件阻断循环
useEffect(() => {
  if (count < 10) setCount(count + 1);
}, [count]);

// ✅ 正确:根据 count 变化执行副作用,但不会修改 count
useEffect(() => {
  document.title = `Count: ${count}`;
}, [count]); // 依赖 count,但副作用不修改 count

依赖项为引用类型

对象或数组依赖项仅进行浅比较,内容变化但引用未变时,effect 不会触发

ts 复制代码
const [user, setUser] = useState({ id: 1 });
// ❌ 错误:直接修改对象属性
user.name = 'Alice';
setUser(user); // 引用未变,effect 不执行

// ✅ 创建新引用
setUser({ ...user, name: 'Alice' }); 

正确处理异步操作

避免直接使用 async 函数

useEffect 的回调函数不能是 async,因其返回 Promise 而非清理函数

ts 复制代码
// ❌ 错误:直接使用 async 函数
useEffect(async () => {
  const data = await fetchData();
}, []);

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

处理加载与错误状态

结合 useState 管理加载状态与错误信息,提升用户体验

ts 复制代码
// 定义状态变量,用于跟踪加载状态
const [loading, setLoading] = useState(true);
// 定义状态变量,用于存储错误信息
const [error, setError] = useState('');
// 定义状态变量,用于存储获取到的数据
const [data, setData] = useState(null);

useEffect(() => {
  // 创建 AbortController 实例,用于在组件卸载时取消请求
  const abortController = new AbortController();

  const fetchData = async () => {
    try {
      // 开始加载数据,设置加载状态为 true
      setLoading(true);
      // 重置错误状态,确保每次新请求开始时清除之前的错误
      setError(''); 
      // 发送 GET 请求获取数据,并传入 signal 用于可能的请求取消
      const result = await axios.get('/api/data', {
        signal: abortController.signal
      });
      // 请求成功,更新数据状态
      setData(result.data);
    } catch (err) {
      // 检查错误是否由于请求被取消导致,如果不是则设置错误状态
      if (!abortController.signal.aborted) {
        setError(err.message || '请求失败,请重试');
      }
    } finally {
      // 如果请求没有被取消,则更新加载状态为 false
      if (!abortController.signal.aborted) {
        setLoading(false);
      }
    }
  };

  // 调用数据获取函数
  fetchData();

  // 清理函数:组件卸载时取消正在进行的请求
  return () => abortController.abort();
}, []); // 依赖数组为空,表示仅在组件挂载时执行一次

这里顺带提一下另外一篇文章:# async/await 必须使用 try/catch 吗?

副作用清理与性能优化

清理资源

定时器、事件监听等需在组件卸载或依赖项变化时清理,防止内存泄漏或重复注册事件

ts 复制代码
useEffect(() => {
  const timer = setInterval(() => {
    setCount(c => c + 1);
  }, 1000);
  return () => clearInterval(timer); // ✅ 清理定时器
}, []);

减少不必要执行

空依赖数组 :当 useEffect 的依赖项数组为空([])时,它会在组件挂载后的首次渲染时执行一次 ,模拟类组件中 componentDidMount 的生命周期行为。

ts 复制代码
useEffect(() => {
  console.log('组件挂载');
}, []); // ✅ 空数组

依赖项精确控制:仅当特定变量变化时触发 effect

ts 复制代码
useEffect(() => {
  fetchUserData(userId);
}, [userId]); // ✅ 仅 userId 变化时执行

使用 useReducer 解耦复杂逻辑

当状态更新依赖前值或涉及多步骤时,useReduceruseState 更合适

ts 复制代码
const [state, dispatch] = useReducer(reducer, initialState);

useEffect(() => {
  const timer = setInterval(() => {
    dispatch({ type: 'increment' }); // ✅ 稳定的 dispatch
  }, 1000);
  return () => clearInterval(timer);
}, []);

依赖项传递函数

使用 useCallbackuseMemo 缓存函数和数据,减少不必要的更新.

组件内定义的函数每次渲染引用不同,需用 useCallback 缓存

ts 复制代码
const fetchData = useCallback(async () => {
  const res = await axios.get(`/api/data?id=${id}`);
  setData(res.data);
}, [id]); // ✅ 依赖 id

useEffect(() => {
  fetchData();
}, [fetchData]);

缓存计算结果,仅在依赖项变化时重新计算,需用 useMemo 缓存。

ts 复制代码
// 使用 useMemo 优化:仅在 list 变化时重新计算过滤结果
const filteredList = useMemo(() => {
    return list.filter(item => item.price > 100);
}, [list]); // 当 list 发生变化时,filteredList 才会重新计算

// 使用 useEffect,当 filteredList 更新时执行副作用操作(例如记录日志或更新状态)
useEffect(() => {
    console.log('Filtered list updated:', filteredList);
// 此处可添加其他需要在 filteredList 更新时执行的逻辑
}, [filteredList]); // 依赖 filteredList

依赖项过多

有时可能会遇到依赖项列表过长的情况,这时需要仔细思考哪些变量真正需要作为依赖。对那些频繁变动但实际上不影响 effect 内逻辑的值,可以考虑通过 useRef 存储。

ts 复制代码
const [count, setCount] = useState(0);
// 使用 useRef 存储最新的 count 值
const countRef = useRef(count);

// 每次 count 变化时更新 ref 的值
useEffect(() => {
    countRef.current = count;
}, [count]);

// 设置一个 effect,用于定时输出最新的 count 值
// 注意:这里我们不直接依赖 count,而是通过 countRef.current 获取最新值,
// 这样依赖项列表就不会因为 count 频繁变化而过长或导致 effect 重新执行
useEffect(() => {
    const timer = setInterval(() => {
      console.log('当前 count 值:', countRef.current);
    }, 1000);
    return () => clearInterval(timer);
}, []); // 依赖项为空,effect 只在组件挂载时执行一次

分离副作用

如果一个 effect 内部处理的逻辑过于复杂,可以考虑将逻辑拆分成多个 effect,每个 effect 只关注一个责任,便于维护和调试。

ts 复制代码
const [userData, setUserData] = useState(null);
const [windowWidth, setWindowWidth] = useState(window.innerWidth);

// Effect 1:当 userId 变化时,从 API 获取用户数据
useEffect(() => {
    async function fetchUserData() {
      try {
        const response = await fetch(`https://api.example.com/users/${userId}`);
        const data = await response.json();
        setUserData(data);
      } catch (error) {
        console.error('获取用户数据失败:', error);
      }
    }

    if (userId) {
      fetchUserData();
    }
}, [userId]);

// Effect 2:监听窗口尺寸变化,更新窗口宽度
useEffect(() => {
    function handleResize() {
      setWindowWidth(window.innerWidth);
    }

    window.addEventListener('resize', handleResize);
    // 清理函数:组件卸载时移除事件监听
    return () => {
      window.removeEventListener('resize', handleResize);
    };
}, []); // 空依赖数组确保只在挂载和卸载时执行

使用自定义 Hook

将复杂的 effect 逻辑封装成自定义 Hook,可以使组件代码更加清晰,同时也方便复用和测试。

这部分就不提供示例代码了哈。主要思路和目的:

  • 封装复杂逻辑:将部分集中功能封装在自定义 Hook 中,使得主组件更加专注于 UI 展示。
  • 复用性:同样的 Hook 可在其他需要这部分功能逻辑的组件中复用,避免代码重复。
  • 测试方便:自定义 Hook 独立封装后,可以单独编写测试用例,对其异步逻辑和状态管理进行验证。

执行顺序

父子组件 effect 顺序

子组件的 useEffect 先于父组件执行。

jsx 复制代码
// 父组件
useEffect(() => console.log('父 effect'));
// 子组件
useEffect(() => console.log('子 effect'));
// 输出顺序:子 effect → 父 effect

调试

react-devtools

react.dev/learn/react...

sh 复制代码
npm install -g react-devtools

react-devtools

然后通过将以下 <script> 标签添加到您网站 <head> 的开头来连接您的网站:

html 复制代码
<html>  
    <head>  
        <script src="http://localhost:8097"></script>

这会启动一个独立窗口,你可以在浏览器中打开你的 React 应用,然后在 DevTools 中查看组件树、Props、State 以及 Hooks 的状态。

  • 在 React DevTools 中选中某个组件。
  • 查看组件的 Hooks 部分,你可以直观地看到每个 Hook 的当前值。
  • 当组件重新渲染且依赖项发生变化时,你可以观察到对应 Hook 的状态更新情况,从而帮助调试依赖项是否正确设置。

自定义 Hook 打印依赖项变化

ts 复制代码
import React, { useState, useEffect, useRef } from 'react';

/**
 * useTraceUpdate 用于打印 props 的变化情况,帮助调试依赖项
 * @param {object} props - 组件的 props 对象
 */
function useTraceUpdate(props) {
  const prevProps = useRef(props);

  useEffect(() => {
    // 使用 Object.entries 遍历所有属性
    const changedProps = Object.entries(props).reduce((acc, [key, value]) => {
      if (prevProps.current[key] !== value) {
        acc[key] = { from: prevProps.current[key], to: value };
      }
      return acc;
    }, {});

    if (Object.keys(changedProps).length > 0) {
      console.log('依赖项变化:', changedProps);
    }

    // 更新 prevProps 为最新的 props
    prevProps.current = props;
  });
}

// 示例组件:使用自定义 Hook 跟踪 props 变化
function ExampleComponent(props) {
  // 打印 props 的变化情况
  useTraceUpdate(props);

  return (
    <div>
      <h2>示例组件</h2>
      <p>当前 prop 值:{JSON.stringify(props)}</p>
    </div>
  );
}

export default ExampleComponent;

eslint-plugin-react-hooks

www.npmjs.com/package/esl...

建议安装 eslint-plugin-react-hooks 来帮助检查依赖是否正确,会给出提示。

小结

正确使用 useEffect 的关键在于深入理解其执行机制、精心配置依赖数组,以及合理管理清理函数。结合工具和最佳实践,规避常见陷阱,能够显著提升代码的健壮性和可维护性。

希望这份指南能为你高效、正确地使用 useEffect 提供有力支持!

相关推荐
百万蹄蹄向前冲1 小时前
Trae分析Phaser.js游戏《洋葱头捡星星》
前端·游戏开发·trae
朝阳5812 小时前
在浏览器端使用 xml2js 遇到的报错及解决方法
前端
GIS之路2 小时前
GeoTools 读取影像元数据
前端
ssshooter3 小时前
VSCode 自带的 TS 版本可能跟项目TS 版本不一样
前端·面试·typescript
你的人类朋友3 小时前
【Node.js】什么是Node.js
javascript·后端·node.js
Jerry3 小时前
Jetpack Compose 中的状态
前端
dae bal4 小时前
关于RSA和AES加密
前端·vue.js
柳杉4 小时前
使用three.js搭建3d隧道监测-2
前端·javascript·数据可视化
lynn8570_blog4 小时前
低端设备加载webp ANR
前端·算法
LKAI.5 小时前
传统方式部署(RuoYi-Cloud)微服务
java·linux·前端·后端·微服务·node.js·ruoyi