生命周期与 useEffect

组件从"出生"(挂载)、"长大"(更新)到"死亡"(卸载),这个过程叫生命周期。在这些时刻,我们常常需要做点"额外的事":挂载后发请求、更新后同步标题、卸载前清理定时器。React 用 useEffect 这个 Hook 统一管理这些"副作用"——这是 React 第二重要的 Hook(仅次于 useState)。

1. 什么是副作用

React 组件的本职是"根据 props/state 渲染 UI",纯函数,不该有外部影响。但真实应用里,组件不可避免要"和外部世界打交道":

// useEffect:在"渲染完成后"执行副作用
import { useState, useEffect } from "react";

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

  // 组件每次渲染完,这个函数都会跑一遍(默认行为)
  useEffect(() => {
    document.title = `点击了 ${count} 次`;
  });

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

// 副作用 = 和"外部世界"打交道的事:
// 改 document.title、发请求、开定时器、订阅事件、操作 DOM 等

这些操作(发请求、改 DOM、开定时器、订阅事件)统称副作用(side effect)。React 不允许你把它们写在组件主体里(渲染时不该有副作用),而是要求用 useEffect 包起来——渲染完成后才执行。

2. 三种执行时机

useEffect 的第二个参数(依赖数组)决定它什么时候执行。这是这个 Hook 的核心难点:

import { useState, useEffect } from "react";

function Demo({ userId }) {
  const [data, setData] = useState(null);

  // 情况 1:不传第二个参数 → 每次渲染后都跑(几乎不用,性能差)
  useEffect(() => {
    console.log("每次渲染都跑");
  });

  // 情况 2:传空数组 [] → 只在"挂载"时跑一次(常用于初始请求)
  useEffect(() => {
    console.log("组件挂载了,只跑一次");
    fetchData();
  }, []);

  // 情况 3:传依赖数组 [deps] → 挂载时 + 依赖变化时跑
  useEffect(() => {
    console.log("userId 变了,重新请求");
    fetch(`/api/users/${userId}`).then(setData);
  }, [userId]);
  // 第一次挂载会跑,之后只有 userId 变化才重跑

  return <div>{data ? data.name : "加载中"}</div>;
}

记忆口诀:不传 → 每次渲染都跑(几乎不用);空数组 → 只挂载时跑一次;有依赖 → 依赖变才重跑。日常 90% 的场景,你要么用空数组(初始请求),要么用依赖数组(数据驱动副作用)。

3. 清理函数:卸载时的扫尾

副作用经常有"扫尾"需求:开的定时器要关、订阅的事件要退、监听的 WebSocket 要断。如果不清理,组件卸载后这些资源还在跑,导致内存泄漏和诡异 bug。useEffect返回一个清理函数来处理:

import { useState, useEffect } from "react";

function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    // 开启定时器(副作用)
    const id = setInterval(() => {
      setSeconds(s => s + 1);
    }, 1000);

    // 返回一个"清理函数":组件卸载或重新跑 effect 之前执行
    return () => {
      clearInterval(id);   // 关掉定时器,避免内存泄漏!
    };
  }, []);   // 空数组:只挂载时开定时器,卸载时清理

  return <p>已运行 {seconds} 秒</p>;
}

// 不清理的后果:
// 组件卸载后定时器还在跑 → 不停改 state → 报错 "Can't update unmounted component"
// 内存泄漏 + 性能问题

清理函数的执行时机:(1) 组件卸载时;(2) effect 下次重新执行前(清理上一次的副作用)。所以无论依赖怎么变,副作用都不会"堆积",每次都是干净的。

4. 经典副作用模式

记住这三种最常见的模式,日常开发覆盖 90%:

// 模式 1:数据请求(挂载时 + 依赖变化时)
useEffect(() => {
  let cancelled = false;
  fetch(`/api/users/${userId}`)
    .then(res => res.json())
    .then(data => {
      if (!cancelled) setUser(data);   // 防止卸载后还 setState
    });
  return () => { cancelled = true; };  // 卸载时标记取消
}, [userId]);

// 模式 2:订阅事件(挂载时订阅,卸载时退订)
useEffect(() => {
  const handleResize = () => setWidth(window.innerWidth);
  window.addEventListener("resize", handleResize);
  return () => window.removeEventListener("resize", handleResize);
}, []);

// 模式 3:操作 DOM(挂载后操作,因为这时 DOM 已经渲染好)
useEffect(() => {
  const canvas = canvasRef.current;
  const ctx = canvas.getContext("2d");
  drawChart(ctx, data);
}, [data]);

模式 1(数据请求)里那个 cancelled 标志很重要:组件卸载后请求才返回,这时再 setState 会报错。用 cancelled 标志跳过,是最简单的解法。更现代的做法是用 AbortController 直接取消请求。

5. 三个经典陷阱

// 陷阱 1:无限循环
const [count, setCount] = useState(0);

useEffect(() => {
  setCount(count + 1);   // 改 state → 触发重渲染 → effect 再跑 → 再改 → 死循环!
});                       // 没有依赖数组 = 每次渲染都跑

// 陷阱 2:漏写依赖 → 用到旧值(闭包陷阱)
useEffect(() => {
  const id = setInterval(() => {
    console.log(count);   // count 永远是 0!effect 只跑一次,捕获了旧的 count
  }, 1000);
  return () => clearInterval(id);
}, []);   // 漏了 count

// 陷阱 3:把函数/对象当依赖
useEffect(() => {
  doSomething(handleClick);   // handleClick 每次渲染都是新函数
}, [handleClick]);             // → effect 每次都跑,等于没优化

6. 装个 ESLint 插件自动检查依赖

新手最容易漏写依赖。React 官方提供了 eslint-plugin-react-hooks,它会自动检测你 effect 里用了哪些变量、有没有漏写进依赖数组——漏了就报黄字警告。配 VS Code 后,写错当场红字提示。这个插件是 React 项目的标配,装好它,你能少踩 90% 的 effect 坑。

7. 对比 class 生命周期(了解)

老教程里你会看到 class 组件的三个生命周期方法。函数组件的 useEffect 把它们统一了:

// React class 组件的生命周期(对比了解,新代码用函数组件)
class Comp extends React.Component {
  componentDidMount()   { /* 挂载后,等价于 useEffect(..., []) */ }
  componentDidUpdate()  { /* 更新后,等价于 useEffect(..., [deps]) */ }
  componentWillUnmount() { /* 卸载前,等价于 useEffect 的 return 清理 */ }
}

// 函数组件用 useEffect 统一了这三个:
useEffect(() => {
  // 挂载 + 更新 时干的事
  return () => {
    // 卸载 + 下次更新前 清理的事
  };
}, [deps]);

新代码一律用函数组件 + Hooks,不用学 class。但看老项目源码时要能对应得上。componentDidMount = useEffect(fn, []),componentWillUnmount = effect 的 return 清理函数——记住这两个对应关系就够。

8. 一个常见误区:effect 不是事件

新手常把 effect 当"事件监听器"用,什么逻辑都往里塞。记住:effect 是"状态变化后要同步做的事",不是"用户操作后要做的事"。用户点击、提交这种交互,直接在 onClick 里处理,不要绕到 effect。只有"props/state 变化 → 需要同步到外部"(如发请求、改标题)才用 effect。这个心智模型对了,你的 React 代码就会清爽很多。

小结

useEffect 管理副作用,三种时机:不传每次跑、空数组挂载时跑、依赖数组依赖变时跑。副作用有清理需求时返回一个函数。装 eslint-plugin-react-hooks 自动查依赖。掌握 useState + useEffect,你就掌握了现代 React 90% 的日常开发。

恭喜,React 系列到这里就结束了!回到 React 教程目录复习,或继续看我们的其他教程。

✈️💬