useEffect

副作用的处理中枢 - 连接 React 纯函数世界与外部命令式世界的桥梁

核心概述

React 组件的核心哲学是纯函数渲染:给定相同的 props,总是返回相同的 JSX。 但现实应用中,你需要与外部世界交互——获取数据、订阅事件、操作 DOM、设置定时器。 这些操作被称为副作用(Side Effects),因为它们会影响到组件外部, 或者在渲染之外产生可见效果。

在类组件时代,副作用分散在各个生命周期方法中(componentDidMount,componentDidUpdate, componentWillUnmount),导致相关逻辑被拆分到不同地方。useEffect 的出现革命性地解决了这个问题:它将相关的副作用逻辑组织在一起, 并通过依赖数组机制精确控制副作用的执行时机。

执行时机:useEffect 在浏览器完成绘制后异步执行, 这确保了副作用不会阻塞浏览器渲染,提升用户体验。 这也是为什么它被称为"Effect"而不是"Effect Immediately"——它发生在渲染"之后"。

适用场景:任何需要与外部世界同步的操作——数据获取、 手动 DOM 操作、事件订阅/取消订阅、WebSocket 连接、使用第三方库等。 但如果你需要在浏览器绘制前同步执行操作(如测量 DOM 布局),应该使用 useLayoutEffect

💡 心智模型

将 useEffect 想象成"渲染后的承诺":

  • • React 先完成渲染(把画面画好)
  • • 然后记住你的承诺:"渲染完去做这件事"
  • • 检查依赖是否变化——如果变化了,履行承诺
  • • 如果返回了清理函数,记住下次渲染前先执行清理

关键:依赖数组是判断"是否需要重新履行承诺"的唯一依据。

技术规格

类型签名

TypeScript
function useEffect(
  effect: () => (void | (() => void)),
  deps?: DependencyList
): void

参数说明

参数类型说明
effect() => (void | (() => void))副作用函数。必须返回一个清理函数(可选)或 undefined。 清理函数会在下次 effect 执行前或组件卸载时调用。
depsDependencyList依赖数组(可选)。数组中的值变化时会重新执行 effect。 省略时每次渲染后都执行;空数组 [] 时只在挂载时执行一次。

执行时机对比

Hook执行时机适用场景
useEffect浏览器绘制后异步执行大多数副作用(数据获取、订阅等)
useLayoutEffect浏览器绘制前同步执行需要同步读取 DOM 布局、避免闪烁
useInsertionEffectDOM 变化前、布局计算前CSS-in-JS 库作者

实战演练

1. 基础用法:三种依赖模式

TypeScript
// 模式 1: 每次渲染后都执行(省略依赖数组)
useEffect(() => {
  console.log('组件渲染了');
});

// 模式 2: 只在挂载时执行一次(空依赖数组)
useEffect(() => {
  console.log('组件挂载了');
}, []);

// 模式 3: 依赖变化时执行(指定依赖)
useEffect(() => {
  console.log('count 变化了:', count);
}, [count]);

2. 生产级案例:数据获取与清理

TypeScript
import { useState, useEffect } from 'react';
import type { User } from './types';

interface UserProfileProps {
  userId: number;
}

export function UserProfile({ userId }: UserProfileProps) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    // 定义异步获取函数
    const fetchUser = async () => {
      try {
        setLoading(true);
        setError(null);

        const response = await fetch(`/api/users/${userId}`);

        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }

        const data: User = await response.json();
        setUser(data);
      } catch (err) {
        setError(err instanceof Error ? err.message : '未知错误');
      } finally {
        setLoading(false);
      }
    };

    // 执行获取
    fetchUser();

    // 清理函数:组件卸载或 userId 变化时取消请求
    const controller = new AbortController();

    return () => {
      controller.abort(); // 取消进行中的请求
    };
  }, [userId]); // ✅ 正确:userId 是外部依赖

  if (loading) {
    return <div>加载中...</div>;
  }

  if (error) {
    return <div>错误: {error}</div>;
  }

  return (
    <div>
      <h1>{user?.name}</h1>
      <p>{user?.email}</p>
    </div>
  );
}

3. 生产级案例:事件订阅与清理

TypeScript
import { useState, useEffect, useRef } from 'react';

export function KeyboardListener() {
  const [key, setKey] = useState<string>('');
  const [count, setCount] = useState(0);

  // 使用 ref 存储最新的 count,避免闭包陷阱
  const countRef = useRef(count);
  countRef.current = count;

  useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      setKey(event.key);

      // 通过 ref 访问最新值,避免依赖 count
      console.log('当前 count:', countRef.current);
    };

    // 添加事件监听
    window.addEventListener('keydown', handleKeyDown);

    // 清理函数:移除事件监听
    return () => {
      window.removeEventListener('keydown', handleKeyDown);
    };
  }, []); // ✅ 空依赖数组:只在挂载时设置一次

  return (
    <div>
      <p>最后按下的键: {key}</p>
      <p>计数: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>增加</button>
    </div>
  );
}

4. 生产级案例:WebSocket 连接

TypeScript
import { useState, useEffect, useRef } from 'react';

type WebSocketStatus = 'connecting' | 'connected' | 'disconnected' | 'error';

interface ChatMessage {
  id: string;
  content: string;
  timestamp: number;
}

export function ChatRoom({ roomId }: { roomId: string }) {
  const [status, setStatus] = useState<WebSocketStatus>('disconnected');
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const wsRef = useRef<WebSocket | null>(null);

  useEffect(() => {
    // 建立 WebSocket 连接
    const ws = new WebSocket(`wss://example.com/chat/${roomId}`);
    wsRef.current = ws;

    ws.onopen = () => {
      setStatus('connected');
    };

    ws.onmessage = (event) => {
      const message: ChatMessage = JSON.parse(event.data);
      setMessages(prev => [...prev, message]);
    };

    ws.onerror = () => {
      setStatus('error');
    };

    ws.onclose = () => {
      setStatus('disconnected');
    };

    // 清理函数:关闭连接
    return () => {
      if (wsRef.current?.readyState === WebSocket.OPEN) {
        wsRef.current.close();
      }
    };
  }, [roomId]); // roomId 变化时重新连接

  const sendMessage = (content: string) => {
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      wsRef.current.send(JSON.stringify({ content, timestamp: Date.now() }));
    }
  };

  return (
    <div>
      <div>状态: {status}</div>
      <ul>
        {messages.map(msg => (
          <li key={msg.id}>{msg.content}</li>
        ))}
      </ul>
    </div>
  );
}

避坑指南

陷阱 1: 依赖数组缺少响应式值

问题:effect 内部使用了响应式值(如 props、state),但没有添加到依赖数组。 React 会报警告,并可能导致使用旧的闭包值。

TypeScript
// ❌ 错误:缺少 userId 依赖
useEffect(() => {
  const subscription = dataSource.subscribe(userId);
  return () => subscription.unsubscribe();
}, []); // 缺少 userId!
TypeScript
// ✅ 正确:添加所有依赖
useEffect(() => {
  const subscription = dataSource.subscribe(userId);
  return () => subscription.unsubscribe();
}, [userId]); // userId 变化时重新订阅

ESLint 规则:使用 exhaustive-deps 规则自动检查依赖数组。 不要刻意使用 // eslint-disable-next-line 来忽略警告,99% 的情况下警告是正确的。

陷阱 2: setState 导致无限循环

问题:在 effect 中调用 setState,而该 state 又在依赖数组中, 导致无限循环:渲染 → effect → setState → 重新渲染 → effect ...

TypeScript
// ❌ 致命错误:无限循环
const [count, setCount] = useState(0);

useEffect(() => {
  setCount(count + 1);
}, [count]); // count 变化 → effect 执行 → count 变化 → ...
TypeScript
// ✅ 解决方案 1:移除依赖(如果你真的想每次都执行)
useEffect(() => {
  setCount(c => c + 1);
}, []); // 不依赖 count

// ✅ 解决方案 2:添加条件判断
useEffect(() => {
  if (someCondition) {
    setCount(count + 1);
  }
}, [count, someCondition]);

// ✅ 解决方案 3:重构逻辑
// 如果需要在特定条件下更新状态,考虑是否应该用 useReducer

陷阱 3: 误解清理函数执行时机

常见误区:认为清理函数只在组件卸载时执行。 实际上,清理函数在每次新的 effect 执行前都会执行。

TypeScript
useEffect(() => {
  console.log('1. effect 执行');

  return () => {
    console.log('2. 清理函数执行');
  };
}, [userId]);

// 执行顺序:
// 1. 首次渲染 → effect 执行
// 2. userId 变化 → 清理函数执行 → effect 执行
// 3. 组件卸载 → 清理函数执行

实际意义:这意味着你可以在清理函数中"撤销"上一次 effect 的操作, 然后在新的 effect 中建立新操作。例如,userId 变化时取消旧的订阅,建立新的订阅。

陷阱 4: effect 函数不能是 async

问题:effect 函数必须返回清理函数或 undefined,不能返回 Promise。

TypeScript
// ❌ 错误:不能使用 async 作为 effect 函数
useEffect(async () => {
  const data = await fetchData(userId);
  setState(data);
}, [userId]);
TypeScript
// ✅ 正确:在内部定义 async 函数
useEffect(() => {
  const fetchDataAsync = async () => {
    const data = await fetchData(userId);
    setState(data);
  };

  fetchDataAsync();
}, [userId]);

最佳实践

✅ 推荐模式

  • 总是添加清理函数(即使只是返回 undefined)
  • 使用 ESLint 的 exhaustive-deps 规则
  • 每个 effect 只做一件事(单一职责)
  • 将相关逻辑组织到同一个 effect 中
  • 使用自定义 Hook 封装复杂副作用
  • 在 effect 内使用 ref 读取最新值

❌ 避免模式

  • 不要忽略依赖数组的警告
  • 不要在 effect 中执行计算(用 useMemo)
  • 不要忘记清理副作用(内存泄漏风险)
  • 不要在渲染中执行副作用
  • 不要让 effect 函数返回 Promise
  • 不要过度使用 effect(能用状态推导就不用)

何时使用其他 Hook

useLayoutEffect vs useEffect

如果需要在浏览器绘制前同步执行操作:

  • 读取 DOM 布局(如 getBoundingClientRect)
  • 避免视觉闪烁的 DOM 操作
  • 测量元素尺寸
TypeScript
import { useLayoutEffect } from 'react';

useLayoutEffect(() => {
  // 在浏览器绘制前执行
  const rect = ref.current.getBoundingClientRect();
  setState({ width: rect.width });
}, []);

useInsertionEffect vs useEffect

这是 React 19 新增的 Hook,主要用于库作者:

  • 在 DOM 变化前、布局计算前执行
  • 用于注入动态样式(CSS-in-JS)
  • 不能访问 ref(因为 DOM 还没创建)

延伸阅读

这篇文章有帮助吗?