React-核心 Hook:useEffect 副作用全指南与执行时机深度解析

61 阅读3分钟

前言

在函数式组件中,useEffect 是处理“副作用”的唯一窗口。无论是数据获取、订阅事件还是手动修改 DOM,都离不开它。理解它的依赖追踪机制和清除逻辑,是规避“无限渲染”黑洞的关键。

一、 核心概念与语法

useEffect 接收一个副作用函数和一个依赖数组。其本质是告诉 React: “在渲染完成后,如果这些值变了,请执行这个函数。”

import React, { useEffect, useState } from 'react';

const EffectDemo: React.FC = () => {
  const [count, setCount] = useState<number>(0);

  // 语法:useEffect(effectCallback, dependencyArray)
  useEffect(() => {
    console.log('执行副作用');
    
    // 清理函数(Optional)
    return () => {
      console.log('执行清理');
    };
  }, [count]); // 仅在 count 改变时执行

  return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
};

export default EffectDemo;

二、 依赖数组的三种境界

依赖情况执行时机模拟生命周期
不传参数每次渲染后都执行。容易导致死循环(渲染 -> 状态变 -> 触发渲染)。
空数组 []仅在挂载(Mount)后执行一次。componentDidMount
基本类型数组挂载后执行一次,且数组内任一值变化时重新执行。componentDidUpdate

三、 清理函数的触发逻辑

useEffectreturn 语句定义的函数称为清理函数。其触发分为两种场景:

  1. 卸载时:如果依赖是 [],仅在组件从 DOM 销毁时执行(模拟 componentWillUnmount)。
  2. 更新前:如果依赖不为空,每次副作用执行前,都会先执行上一次的清理函数,确保旧的订阅或定时器被移除。

四、 深度解析:多 Effect 的执行顺序

假设代码中编写了两个副作用函数,执行顺序为:Effect A -> Effect B,切这两个副作用函数均有依懒项,其执行逻辑如下:

1. 挂载阶段 (Mounting)

  1. 执行 Effect A 的副作用。
  2. 执行 Effect B 的副作用。

2. 更新/卸载阶段 (Updating/Unmounting)

注意:清理函数执行顺序不是按照useEffect在代码中的位置来的。

  1. 执行 Effect B 的清理函数 (Return)。
  2. 执行 Effect A 的清理函数 (Return)。
  3. (如果是更新)依次执行新的 Effect A 副作用 -> Effect B 副作用。

注意:React 18 严格模式下,开发环境会执行:挂载 -> 卸载 -> 重新挂载。这是为了帮助开发者尽早发现未清理的订阅或副作用。


五、 避坑指南:无限渲染的成因与对策

1. 引用类型陷阱

问题:依赖数组中包含对象 []、数组 {} 或函数,由于 React 进行的是浅比较(===) ,每次渲染引用地址都不同,会导致无限循环。

解决

  • 使用 useMemo 缓存对象/数组。
  • 使用 useCallback 缓存函数。

2. 依赖项与 SetState 的循环

问题:在副作用中更新状态,且该状态又是该副作用的依赖,会引发无限渲染的问题。

原因useEffect 运行 -> 状态更新 -> 重新渲染 -> useEffect 再次运行

解决

  • 使用 函数式更新setCount(c => c + 1)。这样可以从依赖数组中移除 count,打破循环。
//  优化示例
const AvoidLoop: React.FC = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      // ✅ 使用函数式更新,依赖数组可以保持为空
      setCount(prev => prev + 1);
    }, 1000);

    return () => clearInterval(timer);
  }, []); // 不需要把 count 放进来

  return <div>{count}</div>;
};

export default AvoidLoop;