从生命周期到 useEffect:一次讲透 React 函数组件的"副作用哲学"

2 阅读7分钟

🚀 从生命周期到 useEffect:一次讲透 React 函数组件的"副作用哲学"

在 Hooks 时代,我们看似告别了 componentDidMountcomponentDidUpdatecomponentWillUnmount 这些又长又拗口的名字,但 useEffect 真的只是它们的语法糖吗?这篇文章从一个真实的用户名编辑组件出发,带你重新理解"副作用"这两个字在 React 心智模型中的分量。

一、开篇:一个看似简单的需求

最近在做一个 React + TypeScript 的练手项目,需求非常朴素:

  • 进入页面后,异步请求拿到用户名,展示一句 Hello, xxx
  • 提供一个输入框 + 按钮,允许用户修改名字并提交。

需求很简单,但它牵出了 React 函数组件里最容易被忽视、也最容易踩坑的两个概念:副作用生命周期

我们先看一下项目里 [App.tsx](file:///c:\Users\hu shu\Desktop\WROKSPACE-remote\fe\react\111\ts-demo\src\App.tsx) 的核心片段:

const App: React.FC = () => {
  const [name, setName] = useState<string>("aaa");
  const [editingName, setEditingName] = useState("defaultUserName");

  const loadUserName = () => {
    setTimeout(() => {
      setName("name from async call");
      setEditingName("editing name from async call");
    }, 2000);
  };

  // 副作用
  useEffect(() => {
    // 组件挂载之后
    // 组件第一要素是赶快显示出来,让用户觉得很快
    loadUserName();
  }, []);

  // ...
};

注释里那句"组件第一要素是赶快显示出来"非常关键,它揭示了 useEffect 的设计哲学。我们慢慢展开。


二、什么是"副作用"?为什么要单独拎出来?

2.1 纯函数与 UI = f(props)

React 的核心信条之一是:

UI = f(props)

也就是说,组件应该像一个纯函数:给相同的 props,就渲染相同的 UI。这种"纯"让 React 可以放心地做 diff、做并发渲染、做时间旅行调试。

但真实业务里,组件不可能只做"算 UI"这一件事:

  • 发起 HTTP 请求拿数据
  • 订阅 WebSocket / EventBus
  • 操作 DOM(测量宽高、聚焦输入框)
  • 启动定时器、轮询
  • 读写 localStorage

这些东西不直接参与本次渲染,却会"间接"地影响后续渲染(比如请求回来数据后 setState)。在函数式编程里,它们统称为 Side Effect(副作用)

React 的态度很明确:渲染阶段必须纯净,副作用必须被隔离。所以 useEffect 应运而生——它是一个"逃生舱",专门把副作用推到渲染流程之外去执行。

2.2 为什么不能直接写在函数体里?

很多初学者会写:

const App: React.FC = () => {
  const [name, setName] = useState("aaa");
  // ❌ 错误:直接在函数体里发起异步
  fetch("/api/user").then(r => r.json()).then(d => setName(d.name));
  return <Hello username={name} />;
};

问题在于,函数组件每次渲染都会完整执行一遍函数体。你点个按钮触发了 setState,父组件传了新 props,父组件自己 setState 了……这些都会让组件重新执行,结果就是上面的 fetch 被疯狂触发,接口被打爆,还可能出现"请求回来后 setState 在已卸载组件上"的经典警告。

useEffect 的第一个承诺就是:保证你的副作用只在"渲染完成后"才跑一次(具体跑几次还看依赖数组,下面细讲)。


三、回到生命周期的视角

3.1 类组件的三大生命周期

在类组件时代,我们是这样组织副作用的:

阶段钩子典型用途
挂载后componentDidMount请求数据、订阅、启动定时器
更新后componentDidUpdate根据 props 变化发起新请求
卸载前componentWillUnmount清除定时器、取消订阅、abort 请求

类组件的一个痛点是:同一段逻辑被强行劈成三段。比如"订阅 + 取消订阅"本来是一对,却被写到了 DidMountWillUnmount 两个方法里;DidUpdate 里还要手写 prevProps !== this.props 的比较,代码又长又容易漏。

3.2 useEffect 如何"合并"这三个钩子

useEffect 的签名非常简洁:

useEffect(setup: () => Teardown, deps?: DependencyList): void;

它用一个函数 + 一个依赖数组,把"挂载、更新、卸载"三件事统一表达:

  • 挂载后:组件第一次渲染完成,执行 setup
  • 更新后:如果 deps 中的某个值变了,先执行上一轮 setup 返回的清理函数,再执行新的 setup
  • 卸载前:执行最后一次 setup 返回的清理函数。

项目里的写法是典型的"只在挂载时执行一次":

useEffect(() => {
  loadUserName();
}, []);  // 空依赖数组 => 只在 mount 时跑

它等价于类组件的:

componentDidMount() {
  this.loadUserName();
}

而"挂载时启动、卸载时清理"的经典模式则长这样:

useEffect(() => {
  const timer = setInterval(() => console.log("tick"), 1000);
  return () => clearInterval(timer);  // cleanup
}, []);

这个 return 出去的清理函数,就是 componentWillUnmount 的等价物。


四、依赖数组:useEffect 的"灵魂开关"

useEffect 一共就三种用法,理解了依赖数组,你就掌握了 80% 的 useEffect。

4.1 三种形态

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

// 形态 B:传空数组 —— 只在挂载后执行一次
useEffect(() => {
  loadUserName();
}, []);

// 形态 C:传依赖数组 —— 挂载后执行 + 依赖变化时执行
useEffect(() => {
  document.title = `Hello, ${name}`;
}, [name]);
形态等价生命周期触发时机
不传数组DidMount + 每次 DidUpdate每次渲染后
[]仅 DidMount仅挂载后
[a, b]DidMount + "当 a 或 b 变化时"的 DidUpdate挂载后 + 依赖变化后

4.2 闭包陷阱:为什么"少写依赖"是个坑

来看一个看似合理但会埋雷的写法:

useEffect(() => {
  const timer = setInterval(() => {
    console.log(name);  // ❌ 永远是初始值 "aaa"
  }, 1000);
  return () => clearInterval(timer);
}, []);  // 空数组,闭包锁死了第一次渲染时的 name

这里的 name 是闭包捕获的"第一次渲染时的快照",永远是 "aaa",哪怕你后面 setName 了一百次也感知不到。这就是臭名昭著的 Stale Closure(闭包陈旧值) 问题。

解法有两个方向:

解法一:把依赖写全

useEffect(() => {
  const timer = setInterval(() => {
    console.log(name);
  }, 1000);
  return () => clearInterval(timer);
}, [name]);  // name 变了就重启定时器

缺点:每次 name 变化都会清掉旧定时器、新建一个,频繁时性能不友好。

解法二:用 ref 持有最新值

const nameRef = useRef(name);
useEffect(() => {
  nameRef.current = name;  // 每次渲染同步最新值
}, [name]);

useEffect(() => {
  const timer = setInterval(() => {
    console.log(nameRef.current);  // ✅ 始终是最新的
  }, 1000);
  return () => clearInterval(timer);
}, []);

经验法则:依赖数组该写什么,就写什么。能借力 eslint-plugin-react-hooksexhaustive-deps 规则就开起来,它会帮你揪出缺失的依赖。


五、清理函数:被低估的"另一半"

很多人写 useEffect 只关心 setup,却忘了 cleanup。但在真实项目里,cleanup 往往比 setup 更重要——它直接决定了你会不会内存泄漏。

5.1 cleanup 的执行时机

cleanup 不是"只在卸载时执行",它执行得更频繁:

  1. 每次 setup 重新执行前(当依赖变化触发重新执行)
  2. 组件卸载时

也就是说,假设依赖是 [id],id 变了 3 次,组件最终卸载,cleanup 一共会执行 4 次:3 次是"上一次的清理",1 次是"卸载时的最终清理"。

这个设计非常优雅:保证每次 setup 都跑在一个干净的环境里

5.2 典型场景:取消未完成的请求

useEffect(() => {
  let cancelled = false;
  fetch(`/api/user/${id}`)
    .then(r => r.json())
    .then(data => {
      if (!cancelled) setUser(data);  // ✅ 只在还活跃时更新
    });
  return () => { cancelled = true; };  // 切换 id 时忽略旧请求
}, [id]);

这种 cancelled 标志位是最朴素的"防竞态"写法。更现代的做法是用 AbortController:

useEffect(() => {
  const controller = new AbortController();
  fetch(`/api/user/${id}`, { signal: controller.signal })
    .then(r => r.json())
    .then(setUser)
    .catch(err => {
      if (err.name !== "AbortError") throw err;
    });
  return () => controller.abort();  // 真正取消网络请求
}, [id]);

区别在于:标志位只是"忽略结果",请求还在跑;AbortController 是真正把请求掐断,更省带宽。


六、回到项目:状态提升带来的 useEffect 体验

项目里有一个非常精彩的设计演进,值得单独拎出来讲。它经历了三个版本。

版本一:子组件直接把 event 传给父组件

// 父组件
<NameEditComponent
  username={username}
  onChange={(e) => setUserName(e.target.value)}  // 父组件处理事件
/>

问题:父组件被迫处理 React.ChangeEvent<HTMLInputElement>,关注点被污染了。父组件的使命应该是"持有状态 + 提供修改方法",不应该关心输入框的 DOM 细节。

版本二:子组件拥有私有状态

// NameEditComponent.tsx
const [editingName, setEditingName] = useState(props.initialUserName);
const onNameSubmit = () => {
  props.onNameUpdated(editingName);  // 只把"值"交给父组件
};

父组件变得清爽,但子组件有了私有状态,props.initialUserName 后续变化时,子组件不会响应(因为 useState 初始值只在挂载时取一次)。

版本三:状态完全提升,子组件纯展示

// App.tsx
const [editingName, setEditingName] = useState("defaultUserName");

<NameEditingComponent
  editingName={editingName}
  onNameUpdated={setUserNameState}
  onEditingNameUpdated={setEditingName}
  disabled={editingName === '' || editingName === name}
/>
// NameEditingComponent.tsx —— 子组件零状态
const onChange = (e) => onEditingNameUpdated(e.target.value);

这一刻,子组件真正变成了 UI = f(props),纯展示、可复用、易测试。而异步加载用户名这件事,被干净利落地留在了 App 的 useEffect 里:

useEffect(() => {
  loadUserName();  // 一次性把 name 和 editingName 都填上
}, []);

因为状态都在父组件,useEffect 只需要在一个地方发请求、一次性更新两个状态,子组件自动响应。这就是**"状态提升 + useEffect 集中管理副作用"**带来的整洁——它不是 React 的语法糖,而是架构层面的胜利。


七、StrictMode 的"双重执行"陷阱

[main.tsx](file:///c:\Users\hu shu\Desktop\WROKSPACE-remote\fe\react\111\ts-demo\src\main.tsx) 里用了 <StrictMode>:

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

在开发环境下,StrictMode 会故意把 setup 执行两次(mount → unmount → mount),用来帮你发现副作用是否写得不纯净、是否漏了 cleanup。

很多人第一次遇到会慌:"我的接口怎么被请求了两次?" 别急,这只是开发期的行为,生产构建里不会出现。它的潜台词是:你的 useEffect 必须写成"可重复执行而不出错"的形态

对照检查清单:

  • ✅ setup 里启动了定时器 / 订阅 → cleanup 里有没有清掉?
  • ✅ setup 里发起请求 → cleanup 里有没有 abort 或置 cancelled?
  • ✅ setup 里改了全局状态(比如 document.title)→ cleanup 里有没有还原?

只要这三条满足,StrictMode 的双重执行对你就是透明的。


八、useEffect 的常见反模式

最后总结几个高频踩坑点,自检一下:

❌ 反模式 1:把 useEffect 当 watch 用

useEffect(() => {
  if (name !== "") {
    fetch(`/api/search?q=${name}`);
  }
}, [name]);

听起来很合理,但这其实是"派生状态触发副作用"。如果 name 还能被别的逻辑改,链路会越来越乱。先问自己:这个计算能不能放在事件处理函数里?能就别放 useEffect。

❌ 反模式 2:在 useEffect 里同步更新 state

const [a, setA] = useState(1);
const [b, setB] = useState(2);
useEffect(() => {
  setB(a * 2);  // 多余的一次渲染
}, [a]);

如果 b 完全由 a 推导,直接 const b = a * 2 即可,不需要 state,也不需要 useEffect。这就是 React 官方反复强调的"优先用渲染期计算,而不是 effect 同步"。

❌ 反模式 3:依赖数组里塞对象/函数

useEffect(() => {
  doSomething(options);
}, [options]);  // options 每次渲染都是新对象引用 => 等于没写依赖

对象和函数每次渲染都是新引用,会让 effect 每次都跑。解法:要么把 optionsuseMemo 包起来,要么把依赖收敛到 primitive 字段(options.id 这种)。

✅ 正确姿势:effect 应该"自洽"

一个写得好的 effect,应该满足:

  1. 依赖数组完整(eslint exhaustive-deps 不报错)。
  2. setup 有对应的 cleanup。
  3. 多次执行不会产生副作用累积。
  4. 不在 effect 里做"本可以放在事件处理器里"的事。

九、收尾:useEffect 不是生命周期,而是一种"同步"

很多人把 useEffect 理解成"函数组件版的生命周期",这只对了一半。更准确的心智模型是:

useEffect 是把你组件里的某些东西(state),和组件外的某些东西(数据源、订阅、DOM、网络)做"同步"。

依赖数组就是"同步的触发条件"——当这些外部/内部依赖变了,就重新同步一次。

带着这个视角再看项目里的 loadUserName:

useEffect(() => {
  loadUserName();
}, []);

它表达的是:组件挂载这件事发生后,我要去把外部的用户数据同步到本地 state。空数组表示"只同步一次,后续不关心"。一旦你把依赖写进去,意思就变成了"这些值一变,我就要重新同步"。

记住这句话,你会发现 React 文档里那些"don't use effects for derived state"的劝告,全都在说同一件事:能算的不用同步,能放事件里的不放 effect 里。useEffect 是逃生舱,不是万能锤。


📚 延伸阅读 & 参考资料


写到这里,我对 useEffect 的理解从"生命周期平替"升级成了"声明式同步"。希望这篇笔记能帮你少踩几个坑,也欢迎在评论区交流你的 useEffect 心得 🚀