首页 / React 学习笔记 / 09

REACT · Vol.II · LESSON 09 · Hooks 精讲

useEffect 与生命周期:副作用该放哪?

进阶核心#Hooks#副作用

渲染是纯函数,副作用该放哪?

第 8 篇的 Todo 实战跑通,卷一「核心原语」正式收官。从本篇起进入卷二「Hooks 精讲」,第一个要正面攻克的,就是坑倒无数转型同学的 useEffect

先把第 5 篇埋下的伏笔补完:每次渲染,React 都把组件函数从头到尾重新执行一遍。这隐含一条纪律——组件函数必须是纯函数:同样的 props 和 state,永远渲染出同样的 JSX;渲染过程不碰「外部世界」:不发请求、不动 document、不开定时器。反过来说,下面这种写法就是头号大忌:

UserCard.tsx · ❌ 反例:把请求塞进渲染
import { useState } from "react";

function UserCard({ userId }: { userId: number }) {
  // ❌ 渲染期间发请求:fetch 返回的是 Promise,
  // 既不纯,user.name 也压根不存在
  const user = fetch(`/api/users/${userId}`).then(r => r.json());

  return <div>{user.name}</div>;
}

这段代码有两宗罪。其一,不纯:同样的输入不再保证同样的输出。其二,更隐蔽——React 不保证组件函数只执行一次:并发渲染下计算可能被丢弃重来,StrictMode 还会故意双调用(最后一站细说)。塞进渲染的副作用会莫名跑两遍、白做一遍。

这类「与外部世界的交互」叫副作用(Side Effect):网络请求、读写 document.titlelocalStorage、定时器、订阅(WebSocket)、埋点日志,都是典型代表。React 的态度不是禁止副作用,而是给它划一块专属场地useEffect。取数的完整套路(竞态、清理、重试)第 32 篇专门讲,托管方案 TanStack Query 在第 33 篇。

后端类比:GET 请求不该有副作用

写 REST 接口你一定遵守过:GET 必须幂等、无副作用,写操作走 POST/PUT/DELETE 专属通道。React 的渲染就是那次「GET」——只读、可重放;useEffect 是那条「写通道」。把请求塞进渲染,等于在 GET 接口里偷偷改库,大忌。

useEffect:画完屏幕,再帮我做这件事

useEffect(fn, deps) 的语义一句话:「React,等你把界面渲染完、画到屏幕上之后,帮我执行这个函数。」它把副作用从渲染里剥出来,推迟到渲染之后执行。

PageTitle.tsx · 把购物车数量同步到浏览器标签页标题
import { useEffect, useState } from "react";

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

  useEffect(() => {
    // 副作用:改的是 document(渲染之外的世界)
    document.title = `购物车(${count})`;
  }, [count]); // 依赖数组:count 变了才重新执行

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

第二个参数 deps(依赖数组)是 useEffect 的「阀门」:React 拿新旧 deps 逐项做 Object.is 比较,全部相同就跳过本次 effect。四种常见形态,务必背下来:

写法执行时机Java 心智对照
useEffect(fn)(不传 deps)挂载后 + 每次渲染后都执行AOP 的 afterReturning 通知:每次方法调用完都执行——基本失控,别用
useEffect(fn, [])首次挂载后执行一次@PostConstruct:初始化完成后执行一次
useEffect(fn, [a])挂载后 + a 变化后再执行@PostConstruct + 监听 a:变了就销毁重建
useEffect(fn, [a, b])挂载后 + 任一依赖变化后执行同上,多监听源,任一触发

把这张表翻译成 Spring 的语言:[] 就是 @PostConstruct,依赖数组就是「监听变化、销毁重建」,清理函数就是 @PreDestroy。区别是:Bean 的生命周期只走一轮,effect 的「初始化 → 销毁」由依赖数组驱动,可以反复多轮。

一个组件里可以声明多个 effect,它们的执行顺序很朴素:按书写顺序依次执行——先声明的先跑;同一轮渲染里,各 effect 的 cleanup 也按声明顺序先于各自的新一轮执行。不存在并发,也不存在「谁依赖先满足谁先跑」。

自测时序:组件有两个 effect,甲的依赖是 [roomId],乙的依赖是 []。挂载时谁先执行?切换 roomId 时,乙会被带着重跑吗?——答案:甲先(声明在前的话);乙的依赖没变,本轮被跳过,只有甲清理并重跑。每个 effect 的「阀门」各管各的,互不牵连。

清理函数:effect 自带的 finally 块

副作用常伴着「开资源」——定时器、订阅、连接——就必然要「关资源」。useEffect 的返回值就是干这个的:返回一个函数(cleanup),它会在下一次 effect 执行前、以及组件卸载前被调用

Timer.tsx · 定时器必须清理
import { useEffect, useState } from "react";

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

  useEffect(() => {
    const id = setInterval(() => {
      setSeconds(s => s + 1);
    }, 1000);

    // cleanup:下一轮 effect 前 / 卸载前,先干掉旧定时器
    return () => clearInterval(id);
  }, []);

  return <p>已停留 {seconds} 秒</p>;
}

不写 cleanup 会怎样?组件卸载了,定时器还活着,继续 setState——内存泄漏加控制台警告,和后端「借了连接不归还」是同一种事故。

后端类比:try-with-resources 与 @PreDestroy

Java 里你早有两个熟悉的机制:try-with-resourcesconn.close() 托管给语法,无论正常返回还是抛异常都保证归还资源;@PreDestroy 保证容器关闭时 Bean 释放资源。cleanup 是两者合体,触发更细:不只是卸载时,每次 effect 因依赖变化重新执行前,也会先跑上一轮的 cleanup——每一轮 effect 都活在自己的 try-with-resources 块里。

完整的执行时序如下图,关键是更新那一行:先清理旧 effect,再执行新 effect

挂载 更新 卸载 渲染(纯) 提交 DOM·绘制 effect(首次) 此后进入等待 渲染(纯) 提交 DOM·绘制 cleanup(旧) effect(新) 组件卸载 cleanup(最后) 没有下一轮了 更新时先清上一轮,再跑新一轮:每轮 effect 都是一次完整的「开资源 → 关资源」
图 1useEffect 完整时序:挂载执行 effect;更新时先跑上一轮 cleanup 再跑新一轮;卸载跑最后一轮 cleanup
cleanup = 「擦掉自己留下的痕迹」:改了标题就恢复、加了监听就移除。能恢复原状的 effect 才「可重入」——StrictMode 双调用也就伤不到你了。

一个 effect 一件事:拆分与两个典型误用

需要同步标题、又要订阅 WebSocket?拆成两个 effect——各自的依赖数组和清理函数,互不牵连:

Dashboard.tsx · 按用途拆分:单一职责
// 假设 subscribeRoom 返回一个带 close() 的订阅句柄

useEffect(() => {
  document.title = `未读消息 ${unread}`;
  return () => { document.title = "我的应用"; };
}, [unread]);

useEffect(() => {
  const ws = subscribeRoom(roomId);
  return () => ws.close();
}, [roomId]);

这和「一个方法只做一件事」同构:标题逻辑和订阅逻辑揉进一个 effect,unread 一变,毫不相干的 WebSocket 连接就被拆了重建。effect 的粒度,按用途切。

误用一:把「事件驱动」的逻辑写进 effect

OrderPage.tsx · ❌ 用 effect 监听「提交了」状态
function OrderPage() {
  const [submitted, setSubmitted] = useState(false);

  // ❌ 绕远路:点击 → 改状态 → effect 发现状态变了 → 才发请求
  useEffect(() => {
    if (submitted) {
      fetch("/api/orders", { method: "POST" });
    }
  }, [submitted]);
}

「用户点了提交」是一个事件,不是状态。明确知道是哪个交互触发的,就直接写在事件处理器里:

OrderPage.tsx · ✅ 事件归事件,effect 归同步
function OrderPage() {
  // ✅ 交互触发的写操作,放事件处理器(第 6 篇)
  function handleSubmit() {
    fetch("/api/orders", { method: "POST" });
  }

  return <button onClick={handleSubmit}>提交订单</button>;
}

经验法则:effect 用于「让组件与外部系统保持同步」(标题、订阅、定时器这类持续关系);「响应某个具体动作」属于事件处理器。判断标准:是「用户做了某下操作」触发的,还是「数据处于某种状态」要求的?前者进事件处理器,后者进 effect。

误用二:用 effect 做派生状态

Profile.tsx · ❌ 多余的 state 加多余的 effect
function Profile({ first, last }: { first: string; last: string }) {
  const [fullName, setFullName] = useState("");

  // ❌ fullName 完全由 props 推得出来,不需要 state + effect
  useEffect(() => {
    setFullName(first + " " + last);
  }, [first, last]);
}
Profile.tsx · ✅ 渲染时直接算
function Profile({ first, last }: { first: string; last: string }) {
  const fullName = first + " " + last; // 普通局部变量,随每次渲染自动更新
  return <h1>{fullName}</h1>;
}

这正是第 4 篇「数据能推出来,就不要放进 state」的 effect 版:能算出来的就在渲染时算,别用 effect 复制一遍——那只会引入「晚一拍」的中间状态和多余重渲染。计算很贵怎么办?第 11 篇 useMemo 的主场,也不是 effect 的。

StrictMode:为什么我的 effect 执行了两次?

开发环境里,Timer 的 effect 日志会成对出现:挂载 → 清理 → 再挂载。这不是 bug,是 <StrictMode>(第 3 篇入口文件里见过)在 React 18+ 的开发模式下,故意对每个组件做一轮「挂载 → 卸载 → 再挂载」演练:

main.tsx · 入口处的 StrictMode
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <App />
  </StrictMode>
);

演练的目的:其一,提前暴露隐患——双调用会出事的 effect(漏 cleanup、不幂等),放到真实世界的「快速切路由、反复开弹窗」场景一样会出事;其二,为未来「组件树可复用、状态可保留」铺路。生产构建完全不受影响,effect 只执行一轮。正确态度是把 cleanup 写全、让 effect 可重入,而不是摘掉 StrictMode——那等于拆掉烟雾报警器。

最后认识一位「急脾气亲戚」:useLayoutEffect。它和 useEffect 写法一模一样,唯一区别在时机——DOM 提交之后、浏览器绘制之前同步执行。普通 effect 是「先让用户看到画面,回头再补副作用」;useLayoutEffect 则抢在绘制前动手。只有一种场景值得用它:你需要先测量或改动 DOM 布局,又不想让用户看到「改之前」的闪烁一帧(比如按内容宽度校正 tooltip 位置)。其余所有场景——请求、定时器、订阅、改标题——一律用 useEffect:它不阻塞绘制,快。useLayoutEffect 是罕见的特例工具,不是默认选项。

核心要点
  • 渲染必须是纯函数;副作用统一放进 useEffect,在「画完屏幕之后」执行。
  • deps 四种形态:不传=每次渲染、[]=仅挂载(≈@PostConstruct)、[a]=a 变化时、[a, b]=任一变化时。
  • cleanup 是 effect 的「finally」:下一轮执行前 + 卸载前必跑,≈ try-with-resources + @PreDestroy
  • 一个 effect 一件事;事件驱动的逻辑放事件处理器;能算出来的值不要用 effect 同步成 state。
  • 多个 effect 按声明顺序执行,依赖阀门各管各的,互不牵连。
  • StrictMode 的开发模式双调用是健壮性体检,cleanup 写全即可免疫;useLayoutEffect 只留给「绘制前同步改 DOM」的罕见场景。

本章回顾

卷二开篇,你拿到了 effect 的完整手册:渲染管「声明界面」,effect 管「与外部系统同步」,事件处理器管「响应用户动作」;依赖数组是阀门,cleanup 善后,StrictMode 体检。但「何时重跑」取决于「依赖的值有没有变」——而 JS 闭包会让 effect 偷偷读到「上一个版本的值」。下一篇第 10 篇,直击后端转前端公认的第一困惑点:闭包陷阱(stale closure)。见。

Comments · 评论