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

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

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

📑 目录

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

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

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

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

它的执行机制是这样的:

text

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

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

javascript

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

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

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

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

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

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

javascript

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

javascript 复制代码
// 存入数据------必须序列化为字符串
localStorage.setItem('todos', JSON.stringify(todos));

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

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

javascript

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

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

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

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

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

什么是清理函数?

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

javascript

scss 复制代码
useEffect(() => {
  // 主逻辑
  
  return () => {
    // 清理逻辑
  };
}, [deps]);

清理函数的执行时机

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

清理函数的三种写法

javascript

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

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

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

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

常见清理场景

清理定时器:

javascript

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

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

取消网络请求(AbortController):

javascript

scss 复制代码
useEffect(() => {
  const controller = new AbortController();

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

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

移除事件监听:

javascript

javascript 复制代码
useEffect(() => {
  const handleResize = () => console.log(window.innerWidth);
  window.addEventListener('resize', handleResize);

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

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

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

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

错误对比示例:

javascript

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

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

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

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

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

依赖数组的引用类型陷阱

问题代码

javascript

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

根本原因

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

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

解决方案

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

正确示例:

javascript

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

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

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

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

javascript

scss 复制代码
// ✅ 良好实践:分离关注点
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 状态到第三方库。但需要确保这些操作不会触发新的状态更新,否则会陷入无限循环。

相关推荐
先吃饱再说1 小时前
从“传事件”到“只传值”:React + TypeScript 组件 Props 设计的两次进化
react.js·前端框架·前端工程化
Seven_Ting11 小时前
React-Hooks笔记
前端·笔记·react.js
Revolution611 天前
React 组件重新渲染时,到底重新执行了什么
前端·react.js·面试
林焱_RPAAI1 天前
影刀RPA技术深度:CSS选择器高级实战指南——伪类属性选择器与性能对比完全解析
vue.js·react.js
名字还没想好☜1 天前
React 受控输入框光标跳到末尾:格式化输入时的 selection 丢失 bug 与修复
前端·javascript·react.js·bug·react·next.js
10share1 天前
React 新一代样式隔离方案 —— 编译时、零运行时、原生写法
前端·react.js
光影少年1 天前
RN 的EventEmitter 双向通信
前端·react native·react.js
禅思院1 天前
流式 Markdown 渲染完全指南【引子】
前端·架构·前端框架
GuWenyue2 天前
90%前端写React+TS都踩坑!从组件类型、单向数据流到本地存储完整实战
前端·react.js