目 录CONTENT

文章目录

React(十一)useEffect

React(十一):深入理解 useEffect —— 副作用处理的终极指南

目录

  1. 核心定义:什么是 useEffect
  2. 实践使用:四大高频场景
  3. 注意事项:五大高频易错点
  4. 常见问题答疑(FAQ)
  5. 实操练习题

一、核心定义:什么是 useEffect

1.1 通俗理解 useEffect 的定位

React 函数组件本身是一个"纯渲染函数"——给定 props 和 state,返回要渲染的 UI。但真实世界的应用远不止渲染这么简单:

  • 你需要从服务器拉取数据显示在页面上
  • 你需要监听浏览器窗口大小变化来调整布局
  • 你需要订阅 WebSocket 消息实现实时通信
  • 你需要操作 DOM 节点(比如设置焦点、测量元素尺寸)
  • 你需要设置定时器轮询后台状态

这些操作在 React 中统称为 "副作用"(Side Effect)——因为它们发生在正常的渲染流程之外,会影响组件的外部世界或被外部世界影响。

useEffect 就是 React 为函数组件提供的"副作用处理通道"。 它让你在组件渲染完成后,安全地执行这些副作用操作,而不会阻塞浏览器绘制 UI。

一句话总结:useEffect 是 React 函数组件中处理副作用的唯一标准钩子,解决了函数组件"只会渲染、不会做杂活"的痛点。

1.2 useEffect 与 class 生命周期方法的对应关系

如果你有 class 组件的学习背景,可以把 useEffect 统一看作以下三个生命周期方法的合并体

class 组件生命周期 useEffect 中的对应形态 触发时机
componentDidMount useEffect(() => {...}, []) 空依赖数组 组件首次渲染完成后执行一次
componentDidUpdate useEffect(() => {...}, [dep]) 指定依赖 依赖项变化后执行
componentWillUnmount useEffect(() => { ... return () => {...} }, []) 返回清理函数 组件卸载前执行清理逻辑

关键区别在于:class 组件是按 "生命周期阶段" 拆分的,而 useEffect 是按 "关注点" 拆分的。同一个关注点的挂载、更新、卸载逻辑可以写在一个 useEffect 里,代码更内聚。

1.3 官方语法格式逐行拆解

useEffect(setup: () => void | (() => void), dependencies?: DependencyList);

逐行拆解每个参数的含义:

第一个参数:setup 函数

() => void | (() => void)
  • 它是一个函数,在组件渲染到屏幕之后异步执行(不会阻塞浏览器绘制)
  • 函数体内部是你想要执行的副作用逻辑(发请求、绑事件、设定时器等)
  • 这个函数可以返回另一个函数(称为"清理函数"或"销毁函数"),React 会在组件卸载时、或下一次副作用执行前调用这个清理函数
useEffect(() => {
  // 这里是副作用逻辑 —— 相当于 "挂载" + "更新"
  console.log("副作用执行了");

  return () => {
    // 这里是清理逻辑 —— 相当于 "卸载前清理"
    console.log("清理上一次的副作用");
  };
});

第二个参数:dependencies(可选依赖项数组)

dependencies?: DependencyList
  • 类型any[],一个包含变量引用的数组,比如 [count, userName]
  • 作用:它是控制副作用执行时机的核心开关。React 会用 Object.is() 对数组中每一项做浅比较:
    • 如果依赖项没有变化,React 会跳过本次副作用执行
    • 如果依赖项发生了变化,React 会先执行上一次的清理函数,再执行新的 setup 函数
  • 三种传参情况(后续 2.5 节有详细对比表):
传参方式 含义
不传第二个参数 每次渲染后都执行副作用
传空数组 [] 只在首次渲染后执行一次
[a, b] 首次渲染后执行,且之后只有当 ab 变化时才重新执行

1.4 React 19 严格模式下的执行规则变化

React 19 引入了并发特性(Concurrent Features),对严格模式(<StrictMode>)下的 useEffect 行为做了重要调整:

核心规则:在开发环境下,当组件被 <StrictMode> 包裹时,useEffect 的 setup 函数会被额外执行一次(即:setup → cleanup → setup)。

这背后的设计初衷是什么?

React 19 支持"可中断渲染"——组件可能会被挂载、卸载、再挂载,而用户看到的只是最终状态。如果副作用没有正确的清理逻辑,这种"隐式卸载再挂载"就会导致内存泄漏或状态错乱。

React 通过开发环境下多执行一轮 setup → cleanup → setup,来模拟这种场景,帮助你提前暴露问题:

// 开发环境 + StrictMode 下的执行顺序:
// 1. 组件渲染
// 2. setup 执行(第一次)
// 3. cleanup 执行(模拟卸载)
// 4. setup 执行(第二次,模拟重新挂载)
// 5. 组件正常展示

// 生产环境下的执行顺序:
// 1. 组件渲染
// 2. setup 执行(只执行一次)
// 3. 组件正常展示

实践意义:如果你在开发环境看到 useEffect 里的 console.log 打印了两次,不要惊慌——这是 React 19 刻意为之,说明你的组件被 StrictMode 包裹,React 正在帮你检查副作用是否正确清理。


二、实践使用:四大高频场景

2.1 仅执行一次的副作用(类似 componentDidMount)

场景描述:组件首次渲染完成后,从后端接口获取文章列表数据并显示,整个生命周期只请求一次。

import { useState, useEffect } from "react";

interface Article {
  id: number;
  title: string;
  content: string;
}

function ArticleList() {
  // 状态管理:文章列表、加载状态、错误信息
  const [articles, setArticles] = useState<Article[]>([]);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    // 标记组件是否已卸载,用于避免在卸载后更新状态
    let cancelled = false;

    // 定义异步获取数据的函数
    async function fetchArticles() {
      try {
        setLoading(true);
        setError(null);

        // 发起网络请求
        const response = await fetch("/api/articles");

        // 检查响应状态
        if (!response.ok) {
          throw new Error(`请求失败,状态码:${response.status}`);
        }

        const data: Article[] = await response.json();

        // 只在组件未卸载时更新状态(避免内存泄漏警告)
        if (!cancelled) {
          setArticles(data);
        }
      } catch (err) {
        // 捕获网络异常、JSON 解析异常等
        if (!cancelled) {
          setError(err instanceof Error ? err.message : "未知错误");
        }
      } finally {
        // 无论成功或失败,都结束 loading
        if (!cancelled) {
          setLoading(false);
        }
      }
    }

    fetchArticles();

    // 清理函数:组件卸载时将 cancelled 置为 true
    return () => {
      cancelled = true;
    };
  }, []); // 空依赖数组 → 只在首次渲染后执行一次

  // 渲染逻辑:根据状态展示不同 UI
  if (loading) {
    return <div className="loading">加载中...</div>;
  }

  if (error) {
    return <div className="error">出错了:{error}</div>;
  }

  return (
    <ul className="article-list">
      {articles.map((article) => (
        <li key={article.id}>
          <h3>{article.title}</h3>
          <p>{article.content}</p>
        </li>
      ))}
    </ul>
  );
}

执行时机拆解

  1. 组件首次渲染 → 生成初始 DOM
  2. 浏览器绘制完成后 → useEffect 的 setup 函数被调用
  3. fetchArticles() 发起网络请求
  4. 请求返回后 → setArticles(data) 触发重新渲染 → 页面展示文章列表
  5. 由于依赖数组是 [],后续任何 state 变化都不会再触发这个 useEffect
  6. 组件卸载时 → cleanup 函数执行,cancelled = true 阻止卸载后的 setState

关键技巧cancelled 标志位是一种常见的"竞态条件"处理模式,防止组件卸载后异步操作仍然尝试更新状态。


2.2 依赖状态更新时执行(类似 componentDidUpdate)

场景描述:搜索框输入关键词后自动触发联想搜索。用户每输入一个字符,都自动向服务端请求联想词列表。为避免请求过于频繁,加入防抖逻辑优化。

import { useState, useEffect } from "react";

interface Suggestion {
  id: number;
  text: string;
}

function SearchBox() {
  // 用户输入的关键词
  const [keyword, setKeyword] = useState<string>("");
  // 防抖后的关键词(真正用于请求的)
  const [debouncedKeyword, setDebouncedKeyword] = useState<string>("");
  // 联想结果
  const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
  // 加载状态
  const [loading, setLoading] = useState<boolean>(false);

  // ---- 副作用 1:防抖逻辑 ----
  // 当用户输入变化时,延迟 300ms 再更新 debouncedKeyword
  useEffect(() => {
    // 如果关键词为空,立即清空联想结果
    if (keyword.trim() === "") {
      setDebouncedKeyword("");
      setSuggestions([]);
      return;
    }

    // 设置 300ms 防抖定时器
    const timer = setTimeout(() => {
      setDebouncedKeyword(keyword);
    }, 300);

    // 清理函数:用户在新 300ms 内继续输入,清除上一个定时器
    return () => {
      clearTimeout(timer);
    };
  }, [keyword]); // 依赖 keyword 变化 → 每次输入都重新执行

  // ---- 副作用 2:发起搜索请求 ----
  // 当 debouncedKeyword 变化时,向后端请求联想词
  useEffect(() => {
    // 空关键词不请求
    if (debouncedKeyword.trim() === "") {
      return;
    }

    let cancelled = false;

    async function fetchSuggestions() {
      try {
        setLoading(true);
        const response = await fetch(
          `/api/suggestions?q=${encodeURIComponent(debouncedKeyword)}`,
        );

        if (!response.ok) {
          throw new Error("请求失败");
        }

        const data: Suggestion[] = await response.json();

        if (!cancelled) {
          setSuggestions(data);
        }
      } catch (err) {
        if (!cancelled) {
          setSuggestions([]);
        }
      } finally {
        if (!cancelled) {
          setLoading(false);
        }
      }
    }

    fetchSuggestions();

    return () => {
      cancelled = true;
    };
  }, [debouncedKeyword]); // 依赖防抖后的关键词 → 防抖结束后才请求

  return (
    <div className="search-box">
      <input
        type="text"
        value={keyword}
        onChange={(e) => setKeyword(e.target.value)}
        placeholder="请输入关键词搜索..."
      />
      {loading && <span className="loading">搜索中...</span>}
      <ul className="suggestions">
        {suggestions.map((item) => (
          <li key={item.id}>{item.text}</li>
        ))}
      </ul>
    </div>
  );
}

执行时机拆解

  1. 用户在输入框输入 "a" → keyword 变为 "a" → 触发副作用 1,启动 300ms 定时器
  2. 用户紧接着输入 "b"(距上一次小于 300ms)→ keyword 变为 "ab" → 副作用 1 的清理函数清除旧定时器 → 启动新定时器
  3. 用户停止输入满 300ms → 定时器触发 → debouncedKeyword 变为 "ab" → 触发副作用 2,发起实际的网络请求
  4. 请求返回后,setSuggestions 更新 UI

设计要点:将"防抖"和"请求"拆成两个独立的 useEffect,各司其职,逻辑清晰且易于维护。


2.3 组件卸载前清理(类似 componentWillUnmount)

场景描述:同时演示两个典型清理场景——监听浏览器窗口 resize 事件和订阅 WebSocket 消息,在组件卸载时分别移除事件监听和断开连接。

import { useState, useEffect } from "react";

function WindowSizeMonitor() {
  // 存储窗口尺寸
  const [windowSize, setWindowSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  // 存储最新一条 WebSocket 消息
  const [lastMessage, setLastMessage] = useState<string>("暂无消息");

  useEffect(() => {
    // ==================== 场景 A:监听窗口 resize 事件 ====================

    // 定义事件处理函数(抽成具名函数,方便后续移除)
    function handleResize() {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    }

    // 绑定事件监听
    window.addEventListener("resize", handleResize);

    // ==================== 场景 B:订阅 WebSocket 消息 ====================

    // 创建 WebSocket 连接
    const socket = new WebSocket("wss://example.com/socket");

    // 连接建立成功的回调
    socket.onopen = () => {
      console.log("WebSocket 连接已建立");
    };

    // 收到消息的回调
    socket.onmessage = (event) => {
      try {
        const data = JSON.parse(event.data);
        setLastMessage(data.content);
      } catch {
        setLastMessage(event.data);
      }
    };

    // 连接发生错误的回调
    socket.onerror = (error) => {
      console.error("WebSocket 错误:", error);
    };

    // ==================== 清理函数:组件卸载时执行 ====================
    return () => {
      // 移除 resize 事件监听 → 防止内存泄漏
      window.removeEventListener("resize", handleResize);

      // 关闭 WebSocket 连接 → 释放网络资源
      // readyState 检查:如果连接未关闭才调用 close()
      if (
        socket.readyState === WebSocket.OPEN ||
        socket.readyState === WebSocket.CONNECTING
      ) {
        socket.close();
        console.log("WebSocket 连接已关闭");
      }
    };
  }, []); // 空依赖数组 → setup 只在挂载时执行,cleanup 只在卸载时执行

  return (
    <div className="monitor">
      <h3>窗口尺寸监听</h3>
      <p>宽度:{windowSize.width}px</p>
      <p>高度:{windowSize.height}px</p>

      <hr />

      <h3>WebSocket 最新消息</h3>
      <p>{lastMessage}</p>

      <p className="hint">
        💡 尝试缩放浏览器窗口,观察数值变化;组件卸载后事件和连接会自动清理。
      </p>
    </div>
  );
}

为什么清理逻辑是必要的?

不写清理逻辑的后果 解释
内存泄漏 事件监听器引用着组件的作用域,组件卸载后监听器仍在内存中,导致垃圾回收无法释放
状态更新警告 组件卸载后事件仍然可能触发 setState,React 会在控制台警告 "Can't perform a React state update on an unmounted component"
WebSocket 连接泄漏 不断打开新连接却不关闭,服务端和客户端资源持续浪费

执行时机拆解

  1. 组件首次挂载 → setup 执行 → 绑定 resize 事件 + 建立 WebSocket 连接
  2. 组件正常渲染,事件和消息持续更新 UI
  3. 组件从 DOM 中移除 → React 调用 cleanup 函数 → 移除事件 + 关闭 WebSocket

2.4 清理逻辑随依赖更新触发

场景描述:实现一个轮询后台状态的组件。用户可以动态调整轮询间隔(比如 1 秒、3 秒、5 秒)。当间隔参数变化时,自动清除旧定时器,按新间隔生成新定时器。

import { useState, useEffect } from "react";

interface ServerStatus {
  cpu: number;
  memory: number;
  uptime: string;
}

function ServerStatusPolling() {
  const [status, setStatus] = useState<ServerStatus | null>(null);
  // 轮询间隔(单位:毫秒),默认 3000ms
  const [pollInterval, setPollInterval] = useState<number>(3000);
  const [lastUpdate, setLastUpdate] = useState<string>("");

  useEffect(() => {
    // 标记组件是否已卸载
    let cancelled = false;

    // 定义轮询函数
    async function poll() {
      try {
        const response = await fetch("/api/server-status");
        if (!response.ok) throw new Error("获取状态失败");
        const data: ServerStatus = await response.json();

        if (!cancelled) {
          setStatus(data);
          setLastUpdate(new Date().toLocaleTimeString());
        }
      } catch (err) {
        if (!cancelled) {
          console.error("轮询出错:", err);
        }
      }
    }

    // 立即执行一次拉取(避免等待第一个间隔)
    poll();

    // 启动定时轮询
    const timerId = setInterval(poll, pollInterval);

    // 清理函数:组件卸载 或 pollInterval 变化时执行
    return () => {
      cancelled = true;
      clearInterval(timerId); // 清除旧定时器,避免多个定时器叠加
      console.log(`已清除 ${pollInterval}ms 的轮询定时器`);
    };
  }, [pollInterval]); // 依赖 pollInterval → 间隔变化时重新执行整个流程

  return (
    <div className="server-monitor">
      <h3>服务器状态监控(轮询)</h3>

      {/* 间隔选择器 */}
      <div className="interval-selector">
        <span>轮询间隔:</span>
        {[1000, 3000, 5000, 10000].map((ms) => (
          <button
            key={ms}
            onClick={() => setPollInterval(ms)}
            disabled={pollInterval === ms}
            style={{
              fontWeight: pollInterval === ms ? "bold" : "normal",
              marginRight: 8,
            }}
          >
            {ms / 1000}秒
          </button>
        ))}
      </div>

      {/* 状态展示 */}
      {status ? (
        <div className="status-card">
          <p>CPU 使用率:{status.cpu}%</p>
          <p>内存使用率:{status.memory}%</p>
          <p>运行时长:{status.uptime}</p>
          <p className="update-time">最后更新:{lastUpdate}</p>
        </div>
      ) : (
        <p>正在获取服务器状态...</p>
      )}
    </div>
  );
}

执行时机拆解(以用户从 3 秒切换到 5 秒为例)

  1. 组件挂载 → useEffect 执行 → setInterval(poll, 3000) 启动 3 秒定时器
  2. 用户点击 "5秒" 按钮 → setPollInterval(5000) → 触发重新渲染
  3. React 检测到 pollInterval 从 3000 变为 5000
  4. 先执行上一次的 cleanupclearInterval(timerId) 清除 3 秒定时器
  5. 再执行新的 setupsetInterval(poll, 5000) 创建 5 秒定时器
  6. 组件卸载 → cleanup 执行 → clearInterval(timerId) 清除最后一个定时器

关键认知:cleanup 函数不仅会在卸载时执行,每次依赖项变化导致副作用重新执行之前也会先执行 cleanup。这保证了"旧的副作用"总是先被清理。


2.5 依赖数组传参对比总结表

传参方式 代码示例 首次渲染后 每次重渲染后 依赖变化后 组件卸载时 典型场景
不传第二个参数 useEffect(() => {...}) ✅ 执行 ✅ 执行 cleanup 执行 极少数场景(一般不推荐)
传空数组 [] useEffect(() => {...}, []) ✅ 执行 ❌ 跳过 ❌ 无依赖 cleanup 执行 初始数据加载、事件绑定、订阅
传具体依赖 useEffect(() => {...}, [a, b]) ✅ 执行 ❌ 跳过(依赖未变时) ✅ 执行(a 或 b 变化时) cleanup 执行 搜索联想、表单联动、轮询间隔调整

执行流程图解

          ┌──────────────┐
          │  组件渲染完成  │
          └──────┬───────┘
                 │
          ┌──────▼──────────────────────────┐
          │  依赖数组是空数组 [] ?           │
          └──┬──────────────────┬───────────┘
             │ 是               │ 否
     ┌───────▼──────┐   ┌──────▼───────────────────┐
     │ 首次渲染?    │   │ 依赖数组未传?              │
     └──┬──────┬────┘   └──┬────────────────┬───────┘
        │ 是   │ 否       │ 是              │ 否(有依赖数组)
   ┌────▼──┐ ┌─▼──┐  ┌───▼────┐     ┌──────▼──────────────┐
   │ 执行   │ │跳过 │  │ 每次执行│     │ 依赖项与上次不同?    │
   │ setup  │ │     │  │ setup  │     └──┬──────────────┬───┘
   └────────┘ └────┘  └────────┘        │ 是           │ 否
                                    ┌────▼────┐    ┌───▼──┐
                                    │ 先 cleanup│    │ 跳过  │
                                    │ 再 setup │    └──────┘
                                    └──────────┘

三、注意事项:五大高频易错点

3.1 依赖项缺失的警告与修复

问题描述:useEffect 内部使用了组件内的变量,但没有把它们放入依赖数组。

❌ 错误示例

function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/users/${userId}`) // 使用了外部的 userId
      .then((res) => res.json())
      .then((data) => {
        setUser(data);
        setLoading(false);
      });
  }, []); // BUG:依赖数组为空,但内部用到了 userId

  // 问题:当 userId prop 从 "1" 变成 "2" 时,
  //       因为依赖数组是 [],useEffect 不会重新执行,
  //       页面仍然显示 userId=1 的数据
  return <div>{/* 渲染用户信息 */}</div>;
}

React 的依赖检查规则:useEffect 内部使用的所有来自组件作用域的响应式变量(state、props、以及由它们计算出的其他变量),都必须作为依赖项声明。

✅ 正确写法

function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);

    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then((data) => {
        if (!cancelled) {
          setUser(data);
          setLoading(false);
        }
      });

    return () => {
      cancelled = true;
    };
  }, [userId]); // ✅ 把 userId 加入依赖数组

  return <div>{/* 渲染用户信息 */}</div>;
}

useCallback 和 useMemo 的配合用法

当 useEffect 的依赖是一个函数对象时,如果不做处理,每次渲染都会重新创建引用,导致 useEffect 无意义地重复执行:

import { useState, useEffect, useCallback, useMemo } from "react";

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

  // ❌ 不用 useCallback:fetchData 每次渲染都是新引用
  // 会导致下面 useEffect 每次渲染都重新执行
  // async function fetchData() { ... }

  // ✅ 用 useCallback 稳定函数引用
  const fetchData = useCallback(async () => {
    console.log("fetching...", count); // count 也需要作为 useCallback 的依赖
  }, [count]);

  useEffect(() => {
    fetchData();
  }, [fetchData]); // fetchData 引用稳定,只在 count 真正变化时才执行

  // ✅ 用 useMemo 稳定对象引用(类似原理)
  const config = useMemo(
    () => ({ pageSize: 20, sortBy: "date" }),
    [], // 如果对象内部没有响应式依赖,传空数组
  );

  useEffect(() => {
    console.log("配置更新:", config);
  }, [config]);

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

3.2 在副作用内部修改依赖状态导致的无限循环

问题描述:在 useEffect 里修改某个 state,又把这个 state 作为依赖项。

❌ 错误示例

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

  useEffect(() => {
    setCount(count + 1); // 修改了 count
  }, [count]); // count 是依赖 → 修改后触发重新渲染 → 再执行 useEffect → 再修改 → 死循环

  // 这会导致:
  // 渲染 → useEffect → setCount(1) → 渲染 → useEffect → setCount(2) → 渲染 → ...
  // 无限循环,页面卡死!

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

死循环的执行链

count = 0 → 渲染 → useEffect 检测到 count = 0 → setCount(1)
→ count = 1 → 渲染 → useEffect 检测到 count 从 0 变 1 → setCount(2)
→ count = 2 → 渲染 → useEffect 检测到 count 从 1 变 2 → setCount(3)
→ ... 无穷无尽

✅ 解决方案一:使用函数式更新去除依赖

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

  useEffect(() => {
    // ✅ 使用 setCount((prev) => prev + 1),不依赖外部的 count
    const timer = setInterval(() => {
      setCount((prev) => prev + 1);
    }, 1000);

    return () => clearInterval(timer);
  }, []); // ✅ 依赖数组为空,只执行一次

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

✅ 解决方案二:添加条件判断阻止不必要的更新

function ConditionalUpdate() {
  const [data, setData] = useState(null);
  const [id, setId] = useState(1);

  useEffect(() => {
    async function loadData() {
      const response = await fetch(`/api/data/${id}`);
      const result = await response.json();

      // ✅ 添加条件判断:只在数据真正不同时才更新
      setData((prevData) => {
        // 如果新旧数据相同,返回旧数据避免不必要的渲染
        if (JSON.stringify(prevData) === JSON.stringify(result)) {
          return prevData;
        }
        return result;
      });
    }

    loadData();
  }, [id]); // 依赖 id,但不依赖 data → 不会循环

  return <div>{/* 渲染 */}</div>;
}

3.3 异步函数直接作为 setup 参数的错误

问题描述:直接把 async 函数传给 useEffect 的第一个参数。

❌ 错误示例

// ❌ 这是错误的!async 函数的返回值总是一个 Promise,
// 而 useEffect 期望的是 void 或 cleanup 函数,不是 Promise
useEffect(async () => {
  const response = await fetch("/api/data");
  const data = await response.json();
  setData(data);
}, []);
// React 控制台会警告:Effect callbacks are synchronous to prevent race conditions

为什么会报错?

  • async 函数本质上返回一个 Promise<void>
  • useEffect 的第一个参数签名是 () => void | (() => void),不接受 Promise
  • React 需要 setup 函数返回的清理函数在同步执行时就能拿到,而 async 函数的返回值只能是 Promise,React 无法从中解析出清理函数

✅ 正确写法

useEffect(() => {
  // ✅ 在 setup 内部定义一个 async 函数
  async function fetchData() {
    try {
      const response = await fetch("/api/data");
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }
      const data = await response.json();
      setData(data);
    } catch (err) {
      setError(err instanceof Error ? err.message : "请求失败");
    } finally {
      setLoading(false);
    }
  }

  // ✅ 然后在 setup 内部调用它
  fetchData();

  // ✅ 清理函数可以正常返回(同步)
  return () => {
    // 清理逻辑,比如取消请求
  };
}, []);

进阶技巧:使用 AbortController 取消请求

useEffect(() => {
  const controller = new AbortController();

  async function fetchData() {
    try {
      const response = await fetch("/api/data", {
        signal: controller.signal, // 传入信号
      });
      const data = await response.json();
      setData(data);
    } catch (err) {
      // AbortError 是正常取消,不需要报错
      if (err instanceof DOMException && err.name === "AbortError") {
        return;
      }
      setError("请求失败");
    }
  }

  fetchData();

  return () => {
    controller.abort(); // 组件卸载或依赖变化时取消请求
  };
}, []);

3.4 React 19 严格模式下的重复执行问题

问题描述:开发环境升级到 React 19 后,发现 useEffect 里的逻辑总是执行两次,不确定是 bug 还是特性。

这到底是不是 bug?

不是 bug,这是 React 19 + StrictMode 的特性。React 刻意在开发环境下多执行一轮 setup → cleanup → setup,用来帮助开发者发现副作用逻辑中的潜在问题。

场景重现:一个会暴露问题的例子

import { useEffect } from "react";

function LeakyComponent() {
  useEffect(() => {
    // 场景:连接 WebSocket
    const socket = new WebSocket("wss://example.com/socket");
    console.log("WebSocket 连接已创建");

    socket.onmessage = (event) => {
      console.log("收到消息:", event.data);
    };

    // ❌ 没有返回清理函数!没有 socket.close()
    // React 19 开发环境会:创建连接 → 不关闭 → 再创建连接
    // 结果:两个 WebSocket 连接同时存在 → 内存泄漏!
  }, []);

  return <div>Leaky Component</div>;
}

✅ 正确写法:始终提供清理逻辑

function CleanComponent() {
  useEffect(() => {
    const socket = new WebSocket("wss://example.com/socket");
    console.log("WebSocket 连接已创建");

    socket.onmessage = (event) => {
      console.log("收到消息:", event.data);
    };

    // ✅ 提供清理函数
    return () => {
      console.log("正在关闭 WebSocket 连接");
      socket.close();
    };
  }, []);

  // React 19 开发环境下会执行:
  // 1. "WebSocket 连接已创建"
  // 2. "正在关闭 WebSocket 连接"    ← StrictMode 模拟卸载
  // 3. "WebSocket 连接已创建"      ← StrictMode 模拟重新挂载
  // 最终只有一个连接,不会泄漏!

  return <div>Clean Component</div>;
}

如何识别你的代码是否有潜在问题?

检查项 潜在风险 修复方法
useEffect 没有 return 清理函数,但内部有订阅/事件/定时器 严格模式下会产生多个连接/监听器 添加 cleanup 函数
异步请求返回后直接 setState,没有检查组件是否已卸载 卸载后更新状态报警告 使用 cancelled 标志位或 AbortController
手动创建了 DOM 节点的引用但没有在卸载时重置 残留引用阻止 GC 在 cleanup 中置 null

如果你确实想禁用严格模式(不推荐),可以在入口文件移除 <StrictMode> 包裹,但这意味着你放弃了 React 19 帮你提前发现问题的机会,生产环境可能产生难以追踪的 bug。


3.5 在循环、条件判断或嵌套函数中调用 useEffect

问题描述:把 useEffect 写在 if 判断、for 循环或普通函数内部。

这是 React Hooks 的铁律之一:Hooks 必须在函数组件或自定义 Hook 的顶层调用,不能在条件判断、循环或嵌套函数中使用。

❌ 错误示例

function Component({
  shouldFetch,
  userId,
}: {
  shouldFetch: boolean;
  userId: string;
}) {
  // ❌ 错误 1:在条件判断中调用 useEffect
  if (shouldFetch) {
    useEffect(() => {
      fetch(`/api/users/${userId}`);
    }, [userId]);
  }
  // React 规则:每次渲染时 Hooks 的调用顺序必须完全一致。
  // 当 shouldFetch 从 true 变成 false 时,这个 useEffect 不见了,
  // React 内部的 hooks 链表就会错乱。

  // ❌ 错误 2:在循环中调用 useEffect
  const items = [1, 2, 3];
  items.forEach((item) => {
    useEffect(() => {
      // 这是不允许的!
    }, [item]);
  });

  // ❌ 错误 3:在嵌套函数中调用 useEffect
  function handleClick() {
    useEffect(() => {
      // 这是不允许的!
    }, []);
  }

  return <div>...</div>;
}

为什么 React 有这个规则?

React 依赖 Hooks 的调用顺序来区分不同的 Hook 实例。React 内部用链表存储 Hook 状态,按调用顺序一一对应:

第一次渲染:useState(A) → useState(B) → useEffect(C)
第二次渲染:useState(A) → (被跳过)  → useEffect(C)  ← 顺序乱了!React 无法匹配

✅ 正确写法:把条件移到 useEffect 内部

function Component({
  shouldFetch,
  userId,
}: {
  shouldFetch: boolean;
  userId: string;
}) {
  useEffect(() => {
    // ✅ 把条件判断写在 useEffect 里面
    if (!shouldFetch) {
      return; // 不能满足条件就提前返回,但 useEffect 本身始终被调用
    }

    let cancelled = false;
    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then((data) => {
        if (!cancelled) {
          // 处理数据
        }
      });

    return () => {
      cancelled = true;
    };
  }, [shouldFetch, userId]); // ✅ 依赖数组包含所有相关变量

  // ✅ 对于多个独立条件,用多个 useEffect 分别处理
  useEffect(() => {
    if (userId) {
      // 处理 userId 相关的副作用
    }
  }, [userId]);

  useEffect(() => {
    if (shouldFetch) {
      // 处理 shouldFetch 相关的副作用
    }
  }, [shouldFetch]);

  return <div>...</div>;
}

记忆口诀:条件写在 Hook 里面,Hook 写在组件顶层。


四、常见问题答疑(FAQ)

Q1:useEffect 和 useLayoutEffect 有什么区别?

useLayoutEffect 在浏览器绘制之前同步执行,useEffect 在绘制之后异步执行。绝大多数场景使用 useEffect 即可。只在需要读取 DOM 布局信息并同步修改(避免闪烁)时才用 useLayoutEffect。

Q2:useEffect 的回调能是 async 函数吗?

不能。async 函数返回 Promise,而 useEffect 期望返回 void 或 cleanup 函数。需要在 setup 函数内部定义 async 函数再调用(见 3.3 节)。

Q3:为什么我的 useEffect 执行了两次?

在 React 19 开发环境 + StrictMode 下,这是正常行为——React 刻意多执行一轮来帮助发现内存泄漏问题(见 3.4 节)。生产环境只执行一次。

Q4:依赖数组可以不放 state setter(setXxx)吗?

可以。React 保证 useState 返回的 setter 函数引用永远稳定,不会在重渲染时改变,因此可以安全地省略。

Q5:useEffect 和事件处理函数的区别是什么?

事件处理函数由用户交互触发(如点击),useEffect 由渲染本身触发。数据获取这类"渲染后就该发生"的事情应该放在 useEffect 中,而不是 onClick 里。

Q6:如何在 useEffect 中拿到最新的 state 而不把它加入依赖?

使用函数式更新 setState((prev) => prev + 1),或者使用 useRef 存储最新值:

const latestValue = useRef(value);
latestValue.current = value; // 始终保持最新

useEffect(() => {
  // 通过 latestValue.current 获取最新值,不会触发重执行
}, []);

Q7:多个 useEffect 的执行顺序是怎样的?

按代码中声明的顺序从上到下依次执行。每个 useEffect 的 cleanup 也在下一次执行前按同样顺序调用。

Q8:useEffect 里可以调用其他 Hooks 吗?

不可以。Hooks 只能在组件或自定义 Hook 的顶层调用。useEffect 内部不能调用 useState、useEffect 等 Hooks。

Q9:空数组 []undefined(不传)有什么区别?

  • []:只在首次渲染后执行一次
  • 不传第二个参数:每次渲染后都执行(几乎等同于没有任何优化)

Q10:componentWillUnmount 的对应写法是哪个?

useEffect(() => { ... return () => { /* cleanup */ }; }, []) 中的 cleanup 函数。注意 cleanup 不仅会在卸载时执行,依赖变化时也会先执行。


五、实操练习题

练习一:带清理功能的鼠标位置监听组件

要求

  1. 在组件中实时显示鼠标在页面中的坐标(x, y)
  2. 组件卸载时自动移除事件监听
  3. 证明你的代码在 React 19 严格模式下不会出现内存泄漏

提示:监听 documentmousemove 事件;使用 useState 存储坐标;在 useEffect 的 cleanup 中调用 removeEventListener

📌 点击查看参考答案
import { useState, useEffect } from "react";

function MouseTracker() {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  useEffect(() => {
    function handleMouseMove(event: MouseEvent) {
      setPosition({ x: event.clientX, y: event.clientY });
    }

    // 绑定事件
    document.addEventListener("mousemove", handleMouseMove);

    // 清理:移除事件监听
    return () => {
      document.removeEventListener("mousemove", handleMouseMove);
    };
  }, []); // 空依赖 → 只在挂载/卸载时执行

  return (
    <div
      style={{
        padding: 20,
        border: "2px solid #333",
        borderRadius: 8,
        textAlign: "center",
        fontSize: 20,
      }}
    >
      <p>鼠标当前位置:</p>
      <p>
        X: {position.x}, Y: {position.y}
      </p>
    </div>
  );
}

export default MouseTracker;

练习二:带防抖的搜索组件

要求

  1. 实现一个搜索输入框,输入内容后延迟 500ms 再发起搜索
  2. 在搜索过程中显示加载状态
  3. 当输入框内容清空时,立即清空搜索结果
  4. 使用 AbortController 实现请求取消(组件卸载或新请求发出时取消旧请求)

提示:使用两个 useEffect 分别处理防抖和请求;请求使用 fetch + AbortController

📌 点击查看参考答案
import { useState, useEffect } from "react";

function DebouncedSearch() {
  const [input, setInput] = useState("");
  const [debouncedInput, setDebouncedInput] = useState("");
  const [results, setResults] = useState<string[]>([]);
  const [loading, setLoading] = useState(false);

  // 副作用 1:防抖
  useEffect(() => {
    if (input.trim() === "") {
      setDebouncedInput("");
      setResults([]);
      return;
    }

    const timer = setTimeout(() => {
      setDebouncedInput(input);
    }, 500);

    return () => clearTimeout(timer);
  }, [input]);

  // 副作用 2:发起请求(含 AbortController)
  useEffect(() => {
    if (debouncedInput.trim() === "") return;

    const controller = new AbortController();
    setLoading(true);

    async function search() {
      try {
        const response = await fetch(
          `/api/search?q=${encodeURIComponent(debouncedInput)}`,
          { signal: controller.signal },
        );

        if (!response.ok) throw new Error("搜索失败");

        const data: string[] = await response.json();
        setResults(data);
      } catch (err) {
        if (err instanceof DOMException && err.name === "AbortError") {
          // 正常取消,不报错
          return;
        }
        setResults([]);
      } finally {
        setLoading(false);
      }
    }

    search();

    return () => controller.abort();
  }, [debouncedInput]);

  return (
    <div>
      <input
        type="text"
        value={input}
        onChange={(e) => setInput(e.target.value)}
        placeholder="输入关键词搜索..."
      />
      {loading && <span> 搜索中...</span>}
      <ul>
        {results.map((item, index) => (
          <li key={index}>{item}</li>
        ))}
      </ul>
    </div>
  );
}

export default DebouncedSearch;

练习三:综合练习——计时器应用

要求

  1. 实现一个秒表,包含"开始/暂停"和"重置"功能
  2. 使用 useEffect 管理定时器的生命周期
  3. 正确处理组件卸载时的定时器清理
  4. 思考:依赖数组应该放什么?为什么?(答案在参考代码中)
📌 点击查看参考答案
import { useState, useEffect } from "react";

function Stopwatch() {
  const [seconds, setSeconds] = useState(0);
  const [isRunning, setIsRunning] = useState(false);

  useEffect(() => {
    // 不运行时无需设置定时器
    if (!isRunning) return;

    // 启动定时器
    const timer = setInterval(() => {
      // ✅ 使用函数式更新,不依赖外部 seconds
      setSeconds((prev) => prev + 1);
    }, 1000);

    // 清理:停止运行或卸载时清除定时器
    return () => clearInterval(timer);
  }, [isRunning]);
  // 思考:依赖数组只放 isRunning,不用放 seconds
  // 因为 setSeconds 用了函数式更新 (prev) => prev + 1,不依赖外部 seconds 值

  function handleStartPause() {
    setIsRunning((prev) => !prev);
  }

  function handleReset() {
    setIsRunning(false);
    setSeconds(0);
  }

  return (
    <div style={{ textAlign: "center", fontFamily: "monospace" }}>
      <h2>秒表</h2>
      <div style={{ fontSize: 48, margin: "20px 0" }}>
        {Math.floor(seconds / 60)
          .toString()
          .padStart(2, "0")}
        :{(seconds % 60).toString().padStart(2, "0")}
      </div>
      <button onClick={handleStartPause}>{isRunning ? "暂停" : "开始"}</button>
      <button onClick={handleReset} style={{ marginLeft: 12 }}>
        重置
      </button>
    </div>
  );
}

export default Stopwatch;

总结:useEffect 是 React 函数组件中最重要的 Hook 之一。理解它的核心机制——声明副作用 + 依赖控制 + 清理函数——是写好 React 应用的关键。记住三条黄金法则:

  1. 把副作用写在 useEffect 里,不要在渲染期间执行
  2. 诚实地声明所有依赖,不要欺骗 React
  3. 始终考虑清理逻辑,不给应用留后遗症

养成这些习惯,你就能轻松驾驭 React 19 中的副作用处理。

0
博主关闭了当前页面的评论