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

30 阅读5分钟

背景

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

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

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

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

避免无限循环

依赖项与状态更新

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

// ❌ 错误示例:每次更新 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 不会触发

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

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

正确处理异步操作

避免直接使用 async 函数

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

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

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

处理加载与错误状态

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

// 定义状态变量,用于跟踪加载状态
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 吗?

副作用清理与性能优化

清理资源

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

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

减少不必要执行

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

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

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

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

使用 useReducer 解耦复杂逻辑

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

const [state, dispatch] = useReducer(reducer, initialState);

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

依赖项传递函数

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

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

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

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

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

// 使用 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 存储。

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 只关注一个责任,便于维护和调试。

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 先于父组件执行。

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

调试

react-devtools

react.dev/learn/react…

npm install -g react-devtools

react-devtools

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

<html>  
    <head>  
        <script src="http://localhost:8097"></script>

image.png

image.png

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

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

自定义 Hook 打印依赖项变化

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 提供有力支持!