跳至正文
来两杯美式
返回

React 核心(5):useEffect 完全指南

By 来两杯美式
发布于

一、useEffect 做的事就一件:给组件添加”做事”的能力

useEffect 是 React 给组件开的”后门”——让组件能跟外部世界交互。

React 组件是纯函数:输入 props + state,输出 JSX。但真实的应用需要跟外部世界打交道——请求接口、操作 DOM、监听事件、开定时器。这些事不在”输入→输出”的纯函数范围内,React 管它们叫副作用(Side Effect)

function ChatRoom({ roomId }) {
  // 组件的"本职工作":返回 JSX(纯函数部分)
  return <div>当前房间:{roomId}</div>;

  // 但你还需要:连接聊天服务器、监听消息……
  // 这些就是"副作用"——useEffect 的领地
}

类比:组件像一个人,useState 给了他记忆(能记住事情),useEffect 给了他行动力(能做事)。没有 useEffect,组件只会坐在那里回忆,什么也干不了。

1.1 什么是”副作用”?

任何跟组件渲染结果无关、但程序运行需要做的事,都是副作用:

副作用类型例子
数据获取fetch('/api/users')
DOM 操作修改标题、聚焦输入框、测量元素尺寸
事件监听window.addEventListener('resize', ...)
定时器setIntervalsetTimeout
订阅WebSocket 连接、Observable 订阅
修改外部变量写 localStorage、改全局状态

这些事的共同特点:它们不参与”计算 JSX”的过程,但程序需要它们才能正常工作

1.2 为什么不能直接写在组件函数体里?

组件函数每次渲染都会执行。直接写副作用 = 每次渲染都重新连接、重新订阅、重新开定时器。副作用需要在正确的时机执行,而不是每次渲染都执行。useEffect 就是 React 提供的”正确时机”机制:你告诉 React “做什么”和”什么时候做”,React 在渲染完成后帮你执行。

二、与 useState 的对比:记忆 vs 行动

useStateuseEffect
给组件什么能力记忆(记住数据)行动(执行副作用)
做的事存数据、改数据、触发重渲染跟外部世界交互
返回值[值, 改值函数]无(undefined
触发时机你调 setter 时触发重渲染渲染完成后执行副作用
纯函数?是(setter 触发重渲染,渲染本身是纯的)否(副作用就是不纯的)

两者构成组件的完整能力:useState 管”记住什么”,useEffect 管”做什么”。一个组件 = 纯函数渲染 + 状态记忆 + 副作用行动。

三、基本语法

useEffect(setup, dependencies?)
//        ↑       ↑
//     副作用函数  依赖数组(可选)

3.1 setup 函数

setup 是你要执行的副作用逻辑。它可以返回一个清理函数(cleanup),也可以不返回。

useEffect(() => {
  // 副作用逻辑写这里
  const connection = createConnection(roomId);
  connection.connect();

  // 可选:返回清理函数
  return () => {
    connection.disconnect();
  };
}, [roomId]);

3.2 依赖数组

依赖数组告诉 React:“什么时候重新执行这个 effect”。三种情况:

// ① 不传依赖数组:每次渲染后都执行
useEffect(() => {
  console.log("每次渲染后都执行");
});

// ② 传空数组 []:只在挂载后执行一次
useEffect(() => {
  console.log("只在组件第一次渲染后执行");
}, []);

// ③ 传依赖 [a, b]:a 或 b 变化时执行
useEffect(() => {
  console.log(`roomId 变了:${roomId}`);
}, [roomId]);

心智模型:依赖数组是你跟 React 签的合同——“我只依赖这些东西,它们不变你就别重新执行”。React 信任你,不会帮你检查你是不是撒谎了(但 ESLint 会,后面讲)。

四、依赖数组三种情况详解

4.1 不传依赖数组:每次渲染后都执行

useEffect(() => {
  console.log("渲染完成");
});

组件每渲染一次,这个 effect 就执行一次。几乎不用——因为大多数副作用不需要每次渲染都跑。唯一合理的场景是调试。

4.2 传空数组 []:只在挂载后执行一次

useEffect(() => {
  // 组件第一次出现在页面上时执行
  document.title = "欢迎来到我的应用";
}, []);

空数组 = “我什么都不依赖,所以永远不需要重新执行”。这是最常见的用法之一——初始化逻辑放这里。

4.3 传依赖 [a, b]:依赖变化时执行

useEffect(() => {
  document.title = `当前房间:${roomId}`;
}, [roomId]);
//  ↑ roomId 变了 → 重新执行 effect
//    roomId 没变 → 跳过

React 用 Object.is() 逐个比较依赖数组中的值。任何一个变化,就重新执行 effect。

useEffect(() => {
  fetchTodos(userId);
}, [userId]);
// userId 从 1 变成 2 → 重新 fetch
// userId 还是 1 → 不执行

关键理解:依赖数组不是”我用了哪些变量”的声明,而是”这个 effect 什么时候需要重新执行”的触发条件。但两者在实践中是一回事——effect 里用了什么变量,就应该把什么变量放进依赖数组。

五、清理函数(Cleanup)

5.1 为什么需要清理?

类比:你租了一间房(副作用),退租时必须打扫干净(清理函数)。如果不打扫,下一个租客进来就会看到你的垃圾——上一个 effect 的残留。

useEffect(() => {
  const connection = createConnection(roomId);
  connection.connect();

  // 清理函数:退租打扫
  return () => {
    connection.disconnect();
  };
}, [roomId]);

5.2 执行时机

清理函数在两种时机执行:

  1. 下次 effect 执行前:依赖变化导致 effect 重新执行时,先执行上一次的清理函数,再执行新的 effect
  2. 组件卸载时:组件从页面消失时,执行最后一次清理函数
挂载 → 执行 effect
  roomId 变化 → 执行清理函数(断开旧连接)→ 执行新 effect(连接新房间)
  roomId 变化 → 执行清理函数(断开旧连接)→ 执行新 effect(连接新房间)
卸载 → 执行清理函数(断开连接)

5.3 不清理会怎样?

// ❌ 没有 cleanup:每次 roomId 变化都创建新连接,旧连接还在
useEffect(() => {
  const connection = createConnection(roomId);
  connection.connect();
  // roomId 从 'A' 变成 'B':
  //   连接 A 还在(没人断开)
  //   连接 B 建立了
  //   结果:同时连着两个房间!
}, [roomId]);

// ✅ 有 cleanup:先断旧连接,再建新连接
useEffect(() => {
  const connection = createConnection(roomId);
  connection.connect();
  return () => connection.disconnect();
}, [roomId]);

原则:如果副作用创建了需要”收尾”的资源(连接、监听、定时器),就必须返回清理函数。问自己一个问题:“如果这个 effect 再执行一次,上一次的残留会造成问题吗?“会 → 写 cleanup。

六、常见模式

6.1 数据获取

挂载时获取

function UserList() {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    fetch("/api/users")
      .then(res => res.json())
      .then(data => setUsers(data));
  }, []); // 空数组 = 只在挂载时获取一次

  return (
    <ul>
      {users.map(u => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  );
}

依赖变化时获取

function UserDetail({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data));
  }, [userId]); // userId 变了就重新获取

  if (!user) return <div>加载中...</div>;
  return <div>{user.name}</div>;
}

实际项目中,数据获取推荐用 TanStack Query——它帮你处理了加载状态、缓存、重试、去重等 useEffect 手写容易遗漏的细节。useEffect + fetch 是理解原理的基础,不是生产方案。

6.2 事件监听

function WindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    function handleResize() {
      setWidth(window.innerWidth);
    }

    window.addEventListener("resize", handleResize);

    // 清理:移除监听
    return () => {
      window.removeEventListener("resize", handleResize);
    };
  }, []); // 只在挂载时添加,卸载时移除

  return <div>窗口宽度:{width}px</div>;
}

模式addEventListenerremoveEventListener 必须成对出现。忘了 removeEventListener = 内存泄漏 + 重复执行。

6.3 定时器

function Clock() {
  const [time, setTime] = useState(new Date());

  useEffect(() => {
    const id = setInterval(() => {
      setTime(new Date());
    }, 1000);

    // 清理:清除定时器
    return () => clearInterval(id);
  }, []); // 只启动一次定时器

  return <div>{time.toLocaleTimeString()}</div>;
}

模式setInterval / setTimeout 必须用 clearInterval / clearTimeout 清理。否则组件卸载后定时器还在跑,尝试更新已卸载的组件状态会报警告。

6.4 订阅 / 取消订阅

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();

    // 订阅消息
    connection.onMessage(msg => {
      setMessages(prev => [...prev, msg]);
    });

    // 清理:断开连接
    return () => {
      connection.disconnect();
    };
  }, [roomId]); // roomId 变了就重新连接

  return (
    <ul>
      {messages.map((msg, i) => (
        <li key={i}>{msg.text}</li>
      ))}
    </ul>
  );
}

模式总结:所有副作用的模式都是同一个套路——setup + cleanup。签合同(setup)+ 退租打扫(cleanup)。忘了 cleanup = 资源泄漏。

七、依赖数组的陷阱

这是 useEffect 最容易出错的地方,也是面试高频考点。

7.1 遗漏依赖导致 Stale Closure

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      // ❌ count 永远是 0!
      // 这个回调函数在 effect 首次执行时创建,
      // 闭包里的 count 是第一次渲染时的值(0)
      // 后续渲染的 count 变化,这个闭包看不到
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []); // 空数组 = 只执行一次 → count 永远是闭包里的 0

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

问题:effect 只在挂载时执行了一次,里面的 count 是第一次渲染时的 0。之后 count 变了,但 effect 不会重新执行,闭包里的 count 还是 0。所以 setCount(count + 1) 永远是 setCount(0 + 1) = setCount(1)

修复方法一:把 count 加进依赖数组

useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1); // ✅ 每次拿到最新的 count
  }, 1000);
  return () => clearInterval(id);
}, [count]); // count 变了就重新执行 effect
// 但这有个问题:每秒 count 变一次 → effect 重新执行 → 清除旧定时器 → 创建新定时器
// 功能正确,但不够优雅

修复方法二:用函数式更新,不依赖 count

useEffect(() => {
  const id = setInterval(() => {
    setCount(prev => prev + 1); // ✅ 拿最新值,不需要 count 在闭包里
  }, 1000);
  return () => clearInterval(id);
}, []); // 空数组就行,定时器只创建一次

规律:当新值依赖旧值时,用 prev => prev + 1 的函数形式——和 useState 的闭包陷阱是同一个解法。

7.2 对象/函数作为依赖导致无限循环

function ChatRoom() {
  const options = { roomId: "general" }; // ❌ 每次渲染创建新对象

  useEffect(() => {
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [options]); // 新引用 → effect 每次都执行 → 无限循环
}

原因:{ roomId: 'general' } !== { roomId: 'general' }——对象比较的是引用,不是内容。每次渲染 options 都是新的对象,React 认为”依赖变了”,重新执行 effect。

修复:把对象移到组件外面,或用 useMemo 缓存:

// 方法一:移到组件外面(不需要 props/state 时)
const options = { roomId: "general" }; // ✅ 引用永远不变

// 方法二:useMemo 缓存
function ChatRoom({ roomId }) {
  const options = useMemo(() => ({ roomId }), [roomId]); // ✅ roomId 不变则引用不变
  // ...
}

函数作为依赖也是同样的问题——每次渲染创建新函数 = 新引用。用 useCallback 缓存:

// ❌ 每次渲染创建新函数
const handleMessage = msg => {
  console.log(msg);
};

// ✅ useCallback 缓存
const handleMessage = useCallback(msg => {
  console.log(msg);
}, []); // 引用稳定

7.3 为什么 ESLint exhaustive-deps 规则存在

React 官方提供了 eslint-plugin-react-hooks,其中 exhaustive-deps 规则会强制你把 effect 里用到的所有外部变量都放进依赖数组

// ESLint 会警告:effect 里用了 roomId,但依赖数组里没有
useEffect(() => {
  connectToRoom(roomId);
}, []); // ⚠️ 缺少 roomId 依赖

这个规则存在的意义:防止 stale closure。你省略了依赖,React 就不会在依赖变化时重新执行 effect,effect 里的闭包就会读到旧值。

// ✅ ESLint 满意:所有用到的变量都在依赖里
useEffect(() => {
  connectToRoom(roomId);
}, [roomId]);

态度:不要把 exhaustive-deps 当成烦人的规则去绕过。它是在帮你——如果你发现必须省略某个依赖才能让代码”正常工作”,那大概率是代码本身有问题,应该用函数式更新、useMemouseCallback 等方式修复,而不是欺骗 ESLint。

八、React 18 Strict Mode 下的双重调用

开发环境下,如果用了 <React.StrictMode>,React 会故意挂载→卸载→再挂载组件:

挂载 → 执行 effect
卸载 → 执行 cleanup
挂载 → 执行 effect

这不是 bug,是 React 故意为之——帮你发现 cleanup 写没写对。

8.1 为什么这么做?

很多开发者写 effect 时不写 cleanup,开发时恰好没问题(因为组件不会频繁卸载)。但到了生产环境,组件可能因为路由切换、条件渲染等原因频繁卸载/重新挂载,没有 cleanup 就会出 bug。

Strict Mode 的双重调用让这类问题在开发阶段就暴露

8.2 怎么应对?

不需要应对——如果你的 cleanup 写对了,双重调用完全无害:

useEffect(() => {
  const connection = createConnection(roomId);
  connection.connect();
  return () => connection.disconnect(); // ✅ cleanup 正确,双重调用没问题
}, [roomId]);
// 双重调用:连接→断开→连接。最终状态和单次调用一样
// ❌ 没有 cleanup:双重调用 = 连接→(没断开)→连接 = 两个连接!
useEffect(() => {
  const connection = createConnection(roomId);
  connection.connect();
}, [roomId]);

注意:双重调用只在开发环境 + Strict Mode 下发生,生产环境不会。

九、useEffect vs useLayoutEffect

useEffectuseLayoutEffect
执行时机渲染提交到 DOM 之后(异步)渲染提交到 DOM 之后、浏览器绘制之前(同步)
是否阻塞绘制不阻塞阻塞
适用场景大多数副作用需要在绘制前读取/修改 DOM 布局

9.1 执行顺序

React 计算 Virtual DOM
  → 更新真实 DOM
    → useLayoutEffect 执行(同步,浏览器还没绘制)
      → 浏览器绘制屏幕
        → useEffect 执行(异步,浏览器已经绘制了)

9.2 什么时候用 useLayoutEffect?

需要读取 DOM 布局并同步修改的场景——比如 tooltip 定位、自动测量元素尺寸:

function Tooltip({ children }) {
  const ref = useRef(null);
  const [position, setPosition] = useState({ top: 0, left: 0 });

  useLayoutEffect(() => {
    const rect = ref.current.getBoundingClientRect(); // 读取 DOM 布局
    setPosition({ top: rect.bottom + 8, left: rect.left }); // 同步修改
  }, []); // 浏览器绘制前就定好位置,不会闪烁

  return (
    <>
      <div ref={ref}>{children}</div>
      <div
        style={{ position: "absolute", top: position.top, left: position.left }}
      >
        提示内容
      </div>
    </>
  );
}

useEffect 的话,浏览器先绘制位置不对的 tooltip,effect 执行后再重绘到正确位置——用户会看到闪烁useLayoutEffect 在绘制前修正,没有闪烁。

原则:99% 的情况用 useEffect。只有当你需要”先读 DOM 布局再修改,且不能让用户看到中间状态”时,才用 useLayoutEffect

十、与声明式的关系:useEffect 是”逃生舱”

React 的世界是声明式的——你描述”UI 应该长什么样”,React 帮你实现。但外部世界(网络请求、DOM API、定时器、第三方库)是命令式的——你必须告诉它们”一步步怎么做”。

useEffect 就是这两个世界之间的桥梁

声明式的 React 世界          命令式的外部世界
─────────────────          ────────────────
  状态变化                      fetch 请求
  组件渲染         ──useEffect──→  DOM 操作
  JSX 描述                      事件监听
                                定时器

你在声明式的世界里写组件,用 useEffect 打开一扇门,走进命令式的外部世界做事,做完再回来。

10.1 useEffect 不是”生命周期方法”

class 组件有 componentDidMountcomponentDidUpdatecomponentWillUnmount 三个生命周期方法。很多教程把 useEffect 等价于这三个方法的组合——这个心智模型是错的

// ❌ 错误心智模型:把 useEffect 当生命周期
useEffect(() => {
  // componentDidMount
}, []);

useEffect(() => {
  // componentDidUpdate
}, [someValue]);

useEffect(() => {
  return () => {
    // componentWillUnmount
  };
}, []);
// ✅ 正确心智模型:useEffect 是"同步"
// "我的 effect 依赖这些值,当这些值变化时,
//  我需要让外部世界和这些值保持同步"
useEffect(() => {
  const connection = createConnection(roomId);
  connection.connect();
  return () => connection.disconnect();
}, [roomId]);
// 不是"挂载时连接、更新时重连、卸载时断开"
// 而是"让连接状态和 roomId 保持同步"

区别在哪?生命周期思维关注”什么时候”(挂载、更新、卸载),同步思维关注”什么关系”(effect 和哪些值保持同步)。同步思维更符合 React 的声明式哲学——你描述”关系”,React 处理”时机”。

详见 声明式与React的实现机制——声明式的本质是”你只管描述结果,有人替你执行过程”。useEffect 是你在声明式世界里唯一需要关心”过程”的地方,所以它是”逃生舱”。

10.2 能不用就不用

useEffect 是”逃生舱”,意味着它应该是最后手段。很多你以为需要 useEffect 的场景,其实不需要:

场景❌ 用 useEffect✅ 更好的方式
根据 props 计算 derived stateuseEffect(() => setX(compute(props.a)), [props.a])直接算:const x = compute(props.a)
重置 stateuseEffect(() => setItems([]), [userId])const [items, setItems] = useState(() => []) + key 变化
用户事件处理useEffect(() => { if (data) doSomething() }, [data])在事件回调里直接调 doSomething()
数据获取useEffect(() => fetch(...), [id])TanStack Query

判断标准:如果一段逻辑是”响应渲染结果”(比如同步 DOM、发请求),用 useEffect;如果是”响应事件”(比如点击按钮),直接在事件处理函数里写,不需要 useEffect

一句话

useEffect 给组件添加”做事”的能力,让声明式的 React 能跟命令式的外部世界交互。记住三件事:依赖数组要诚实(别骗 ESLint)、有 setup 就要有 cleanup、能用其他方式解决的别用 useEffect


分享这篇文章:
通过邮件分享这篇文章✓ 链接已复制
所属专题
React
第 5 / 17 篇
查看系列全部文章
  1. 01.React 核心(1):React 组件与函数的本质区别
  2. 02.React 核心(2):声明式与 React 的实现机制
  3. 03.React 核心(3):Props 完全指南
  4. 04.React 核心(4):useState 完全指南
  5. 05.React 核心(5):useEffect 完全指南
  6. 06.React 核心(6):useRef 完全指南
  7. 07.React 核心(7):Context API 与状态共享
  8. 08.React 核心(8):useCallback 与 useMemo
  9. 09.React 核心(9):Hooks 规则与闭包陷阱
  10. 10.React 核心(10):自定义 Hook 设计
  11. 11.React 核心(11):Key 与列表渲染
  12. 12.React 核心(12):表单与受控/非受控组件
  13. 13.React 核心(13):React 渲染机制深入——Fiber 与调度
  14. 14.React 核心(14):React 18 并发特性
  15. 15.React 核心(15):Suspense 机制详解
  16. 16.React 核心(16):TanStack Query 入门指南
  17. 17.React 核心(17):TanStack Query 缓存机制详解

上一篇
React 核心(6):useRef 完全指南
下一篇
React 核心(4):useState 完全指南