别说你懂 useEffect:从底层机制到生命周期管理,这篇全讲透了

0 阅读9分钟

别说你懂 useEffect:从底层机制到生命周期管理,这篇全讲透了

摘要:useEffect 是 React 函数组件中处理“副作用”的核心工具。本文从底层机制出发,拆解三种触发时机、清理函数的关键作用、依赖数组的常见陷阱,并结合浏览器本地存储的实战场景,帮你彻底掌握这个 React 最重要的 Hook。

📑 目录

  • useEffect 的本质:延迟执行的同步回调
  • 三种触发时机:依赖数组决定一切
  • 清理函数:防止内存泄漏的关键
  • setTimeout 与 setInterval:本质不同的两种定时器
  • 依赖数组的引用类型陷阱
  • 多个 useEffect:按逻辑拆分而非合并
  • 常见误区纠正
  • 互动讨论

useEffect 的本质:延迟执行的同步回调

很多初学者把 useEffect 当成“异步函数”,这是一个需要纠正的认知偏差。

正确的理解是:useEffect 是一个“延迟执行的同步回调函数”。

它的执行机制是这样的:

text

组件渲染(Render)
    ↓
更新 DOM(浏览器绘制)
    ↓
【执行 useEffect 回调】  ← 延迟执行,而非异步

useEffect 的回调函数在浏览器完成 DOM 绘制之后才执行。它不阻塞浏览器的渲染过程,因此表现出“异步”的行为特征——但它不能像真正的 async 函数那样直接使用 await(除非在内部再定义异步函数)。

javascript

useEffect(() => {
  console.log('组件挂载后执行');
}, []);

这段代码会在组件渲染完成并绘制到页面后才执行 console.log。这符合 React 的设计理念:先让用户看到界面,再执行副作用操作——比如请求数据、订阅事件、操作 DOM 等。

三种触发时机:依赖数组决定一切

useEffect 通过第二参数(依赖数组)控制触发时机,分为三种情况:

依赖数组触发时机典型场景
无第二参数组件初次挂载 + 每一次更新(每次渲染后都执行)谨慎使用,容易引发性能问题
[](空数组)只在初次挂载时执行一次初始化数据、订阅外部事件
[dep1, dep2]初次挂载 + 指定依赖项发生变化时执行监听特定状态变化,如搜索、分页

在 Todo 应用的代码中,展示了四种不同的 useEffect 用法:

javascript

// 1. 监听到 count 更新就会执行
useEffect(() => {
  console.log('挂载后执行');
  console.log('count改变也会执行');
}, [count]);

// 2. 只在挂载后执行一次
useEffect(() => {
  console.log('只在挂载后执行');
}, []);

// 3. 监听到 todos 更新就会执行——常用于本地存储同步
useEffect(() => {
  localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);

// 4. 每次更新都会执行(无依赖数组)
useEffect(() => {
  console.log('挂载后执行');
  console.log('每次更新都会执行');
});

本地存储:useEffect 的最佳拍档

第三个 useEffect 展示了一个非常经典的 React 模式:当数据变化时,自动同步到浏览器的 localStorage

浏览器提供了多种本地存储方案,其中最常用的是 localStorage

javascript

// 存入数据——必须序列化为字符串
localStorage.setItem('todos', JSON.stringify(todos));

// 读取数据——从字符串解析回来
const saved = JSON.parse(localStorage.getItem('todos'));

localStorage 以键值对的形式存储数据,键和值都必须是字符串。如果要存储数组或对象,必须使用 JSON.stringify() 序列化;读取时用 JSON.parse() 还原。这个特性直接影响了数据初始化的写法:

javascript

// ✅ 从 localStorage 读取初始数据
const [todos, setTodos] = useState(() => 
  JSON.parse(localStorage.getItem('todos')) || []
);

使用惰性初始化(传入函数)确保 localStorage 读取只在组件首次挂载时执行一次,而不是每次渲染都读取。|| [] 作为兜底——如果 localStorage 中没有数据(首次访问),就用空数组作为初始值。

这种“状态变化 → 自动持久化”的模式非常适合用户偏好设置、表单草稿、购物车数据等场景。用户刷新页面后,数据依然存在,体验非常流畅。

需要注意的是,localStorage 的存储空间大约只有 5MB,适合存储轻量级数据。如果需要存储更大的数据(比如端侧模型、大量操作历史),可以考虑 IndexedDB,它提供了类似数据库的存储能力,空间更大、支持更复杂的数据结构。不过对于 Todo 列表这样的场景,localStorage 完全够用。

清理函数:防止内存泄漏的关键

什么是清理函数?

useEffect 的回调函数可以返回一个清理函数,用于在组件卸载或下一次 effect 执行前执行收尾工作。

javascript

useEffect(() => {
  // 主逻辑
  
  return () => {
    // 清理逻辑
  };
}, [deps]);

清理函数的执行时机

依赖数组情况清理函数执行时机
无依赖 / 有依赖下一次 effect 执行之前(先清掉旧的,再执行新的)
[](空数组)组件卸载时(仅此一次)

清理函数的三种写法

javascript

// ✅ 写法一:匿名箭头函数(最常用)
return () => clearInterval(timer);

// ✅ 写法二:具名函数
return function cleanup() {
  clearInterval(timer);
};

// ✅ 写法三:提取为变量
const stopTimer = () => clearInterval(timer);
return stopTimer;

命名规范:没有强制命名规则,但行业通常遵循 cleanupstopXxxremoveXxx 等语义化命名——让代码的读者能一眼看出这个函数在做什么。

常见清理场景

清理定时器:

javascript

useEffect(() => {
  const interval = setInterval(() => {
    console.log('interval, is here');
  }, 1000);
  
  return () => {
    console.log('组件卸载前执行,做什么样内存清理工作');
    clearInterval(interval);
  };
}, []);

在 Demo 组件中,setInterval 每秒打印一次。如果组件被卸载时没有清理,这个定时器会永远运行下去——它的回调仍然会尝试执行,但组件已经不存在了,这就造成了内存泄漏。

取消网络请求(AbortController):

javascript

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

  fetch('/api/user', { signal: controller.signal })
    .then(res => res.json())
    .then(setUser);

  return () => {
    controller.abort();  // 取消正在进行的请求
  };
}, []);

移除事件监听:

javascript

useEffect(() => {
  const handleResize = () => console.log(window.innerWidth);
  window.addEventListener('resize', handleResize);

  return () => {
    window.removeEventListener('resize', handleResize);
  };
}, []);

setTimeout 与 setInterval:本质不同的两种定时器

这两个定时器虽然名字相似,但底层行为截然不同:

对比维度setTimeoutsetInterval
执行模式一次性炸弹:倒计时结束后执行一次重复播种机:每隔指定时间重复执行一次
不清理的后果组件卸载后仍试图执行,导致  “野指针”  和内存泄漏旧定时器未清除 + 新定时器不断创建,导致  “叠加爆炸” (打印速度指数级暴增)
清理 APIclearTimeout(timerId)clearInterval(timerId)

错误对比示例:

javascript

// ❌ 会导致野指针(组件卸载后仍尝试执行)
useEffect(() => {
  setTimeout(() => {
    setCount(count + 1); // 组件可能已卸载
  }, 1000);
}, []);

// ❌ 会导致叠加爆炸(每次渲染都创建新定时器,旧的没清理)
useEffect(() => {
  setInterval(() => {
    console.log('每秒打印');
  }, 1000);
}); // 无依赖,每次渲染都执行

// ✅ 正确写法:空数组 + 清理
useEffect(() => {
  const timer = setInterval(() => {
    console.log('每秒打印');
  }, 1000);

  return () => clearInterval(timer);
}, []);

clearTimeout 和 clearInterval 是浏览器自带的原生 Web API,不是 React 提供的功能。传入定时器 ID 即可清除对应的定时器。

依赖数组的引用类型陷阱

问题代码

javascript

// ❌ 这会导致无限循环!
useEffect(() => {
  console.log('执行了');
}, [{ name: '大许' }]); // 每次渲染都生成新对象

根本原因

JavaScript 中,{ name: '大许' } === { name: '大许' } 永远返回 false。React 的依赖比较使用 Object.is()(浅比较),比较的是内存地址而不是“值”。

每次渲染都创建新对象 → 新地址 → React 认为依赖“变了” → 执行 effect → 可能触发 setState → 再次渲染 → 无限循环。

解决方案

方案适用场景
拆解为基本类型将 { name: '大许' } 拆为 '大许' 作为依赖
使用 useMemo 缓存对象引用需要完整对象且依赖项稳定时
使用 useRef 存储不触发重新渲染的值值变化不需触发 effect 时

正确示例:

javascript

// ✅ 方案一:拆解为基本类型
useEffect(() => {
  console.log('执行了');
}, [name]); // name 是字符串

// ✅ 方案二:useMemo 缓存引用
const user = useMemo(() => ({ name: '大许' }), []);
useEffect(() => {
  console.log('执行了');
}, [user]);

多个 useEffect:按逻辑拆分而非合并

React 允许在一个组件中使用多个 useEffect,推荐按逻辑拆分而非合并:

javascript

// ✅ 良好实践:分离关注点
useEffect(() => {
  // 订阅用户状态
}, [userId]);

useEffect(() => {
  // 更新页面标题
}, [pageTitle]);

useEffect(() => {
  // 同步数据到本地存储
  localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);

好处:

  • 逻辑清晰,易于维护
  • 每个 effect 的依赖项更加明确,减少不必要的执行
  • 每个副作用都有独立的生命周期管理

常见误区纠正

❌ 误区✅ 真相
useEffect 是异步函数。”它是同步回调函数,只是延迟执行(在绘制之后)。
setTimeout 会导致速度越来越快。”setTimeout 是一次性的,不会叠加。setInterval 才是叠加爆炸的元凶。
“清理函数必须叫 cleanup。”命名完全自由,React 只关心你返回了一个函数
clearTimeout 是 React 提供的。”它是浏览器原生 Web API,与 React 无关。
“对象依赖和基本类型依赖没区别。”React 用浅比较,对象的“值相同 ≠ 引用相同”,容易触发无限循环。
“多个 useEffect 会按顺序执行。”所有 useEffect 都在 DOM 绘制之后执行,但执行顺序严格按照代码中声明的顺序。

互动讨论

💬 useEffect 和 useLayoutEffect 有什么区别?

useEffect 在浏览器绘制之后执行,不阻塞渲染;useLayoutEffect 在 DOM 更新后、绘制之前同步执行,会阻塞渲染。大多数场景用 useEffect,只有在需要精确测量 DOM 尺寸或防止闪烁时才用 useLayoutEffect

💬 为什么 useEffect 不能直接使用 async/await?

useEffect 期望回调函数返回一个清理函数(或 undefined),而 async 函数总是返回一个 Promise。如果写成 useEffect(async () => {...}, []),React 会收到一个 Promise 而不是清理函数。正确做法是在内部定义异步函数并调用。

💬 清理函数在开发环境为什么会执行两次?

React 18 的 Strict Mode 会在开发环境下故意让组件挂载、卸载、再挂载,以帮助发现副作用遗漏。这是为了让开发者提前发现“忘记清理”的问题,在生产环境中不会出现。

💬 localStorage 和 useEffect 结合时需要注意什么?

关键是懒初始化同步时机。用 useState(() => JSON.parse(localStorage.getItem('key')) || []) 确保只在挂载时读取一次。写入时用 useEffect 监听数据变化自动同步。另外要注意 localStorage 的 5MB 空间限制,以及它只能存储字符串——对象和数组需要用 JSON.stringify/parse 处理。

💬 什么时候使用无依赖的 useEffect?

极少数情况。通常用于“需要在每次渲染后都执行某些操作”的场景,比如:日志记录、同步 DOM 状态到第三方库。但需要确保这些操作不会触发新的状态更新,否则会陷入无限循环。