『React』组件副作用,useEffect讲解
liuian 2025-08-01 18:42 39 浏览
在 React 开发中,有时候会听到“副作用”这个词。特别是用到 useEffect 这个 Hook 的时候,官方就明确说它是用来处理副作用的。那什么是副作用?为什么我们要专门管控它?今天就聊聊 React 中的组件副作用。
什么是“副作用”?
其实“副作用”并不是 React 特有的东西,在原生 JS 里也很常见。
副作用的”反义词“是纯函数。纯函数的意思是:相同的输入,永远得到相同的输出,且不影响函数外部的任何状态。
举个例子
function add(a, b) {
return a + b
}
上面这个就是纯函数,无论执行多少次 add(1, 2),输出的结果永远是 3。
那么什么是副作用呢?
let count = 0;
function add() {
count += 1; // 修改了外部变量 count,产生了副作用
return count;
}
add() // 1
add() // 2
这里 add() 除了返回值之外,还改变了 count 这个函数外部的变量,就算是副作用。每次执行它都会得到不一样的结果,这就是副作用。
常见的副作用行为包括:
- 修改全局变量 / 外部变量
- 修改对象属性(引用类型)
- 发起网络请求(HTTP 请求)
- 定时器(setInterval)
- 操作本地存储(localStorage.setItem)
- 操作 DOM
- 读写文件(在 Node.js 里)
- 使用 Date.now()、Math.random() 这种非确定性函数
- 等......
为什么要管理副作用?
如果不合理管理副作用,React 应用可能会遇到:
- 内存泄漏
- 重复订阅
- 异步任务未清理
- 数据竞争问题
- UI 不一致
副作用是和组件生命周期息息相关的,所以 React 提供了 useEffect 来专门管理副作用行为。
♂ 错误示范
这个例子展示了一个定时器功能,每隔1秒就会更新一下当前时间,并在页面中展示。
import { useState } from 'react';
function App() {
const [dateTime, setDateTime] = useState(new Date());
const id = setInterval(() => {
setDateTime(new Date());
}
, 1000);
console.log(id);
return (
<div>{dateTime.toLocaleString('zh-CN')}</div>
)
}
export default App;
如果只看页面展示的情况,看上去是没问题的。但打开控制台一看的话会发现随着时间的推移,每秒输出的id数量会增加。
这个例子的问题在于,当组件的 state 发生变化时,整个组件的代码都会重新执行一次,这就相当于反复执行了 setInterval 好多次。当这个页面运行时间长了就会导致内存溢出。
useEffect:管理副作用的标准方案
使用 useEffect 可以解决上面「错误示范」的副作用问题。
import { useEffect, useState } from 'react';
function App() {
const [dateTime, setDateTime] = useState(new Date());
useEffect(() => {
const id = setInterval(() => {
setDateTime(new Date());
}
, 1000);
console.log(id);
}, [])
return (
<div>{dateTime.toLocaleString('zh-CN')}</div>
)
}
export default App;
从上面的例子可以看出,页面的值是会变化的,但控制台并不会一直打印 id 。
useEffect 可以传入2个参数,第一个参数是要执行的代码,第二个参数可以传入一个空数组。这样 useEffect 里的代码就会在组件第一次加载的时候执行一次,因为里面执行的是一个定时器函数,所以定时器函数会自己继续执行,更新完页面后也不会再次执行新的定时器。
如果没传入第二个参数的话就和上面的「错误示范」的效果是一样的。
useEffect 的执行时机
在使用函数式组件时,结合 useEffect 函数,组件的生命周期只需关注以下几个:
- 组件第一次加载
- 组件重新渲染(更新)
- 组件卸载
useEffect 就是在以上时机里做一些副作用。
- useEffect 不传第二个参数时,意味着它会在组件每次 state 或者 props 发生变化时执行一次。
- 而第二个参数时空数组时,意味着它不依赖与任何 state 或者 props 的变化,只在组件第一次加载时执行它的副作用(里面的函数)。
- 如果第二个参数是数组,且依赖其他状态的话,那么其依赖的其中一个状态发生变化时,useEffect 里的代码都会重新执行一次。
关于最后一点举个例子 。
import { use } from 'react';
import { useEffect, useState } from 'react';
function App() {
const [count, setCount] = useState(0);
const [doubleCount, setDoubleCount] = useState(0);
useEffect(() => {
setDoubleCount(() => {
return count * 2;
});
}
, [count]);
return (
<div>
<div>{doubleCount}</div>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
)
}
export default App;
在这个例子中,useEffect 的第二个参数是 [count],表示这个 useEffect 依赖了 count ,当 count 发生变化时就会执行 useEffect 第一个参数的函数。
前面也提到,useEffect 的第二个参数可以依赖多个状态,当其中一个状态发生变化时也会执行第一个参数的代码。可以自己手动试试~
清理副作用
在副作用函数中返回一个函数,用于在下一次执行副作用之前或组件卸载时进行清理。
示例1:只在挂载/卸载时清理
- 因为依赖数组是空的,effect 执行一次后就不再重新运行。
- 返回的清理函数只会在组件卸载时被调用一次。
import React, { useEffect } from 'react';
function Countdown({ start }) {
useEffect(() => {
const timerId = setInterval(() => {
console.log('倒计时:', new Date().toLocaleTimeString());
}, 1000);
// 清理函数:只在组件卸载时调用
return () => {
clearInterval(timerId);
console.log('倒计时已清理');
};
}, []); // 空依赖,effect 只在 mount 时执行,cleanup 只在 unmount 时执行
return <div>查看控制台的倒计时日志。</div>;
}
示例2:依赖变化时清理
- 当 userId 发生变化时,React 会先调用上一个 effect 返回的 controller.abort(),取消旧请求,然后执行新的 fetch 请求。
- 当组件卸载前,也会执行 controller.abort(),避免请求继续运行、然后试图更新已卸载的组件。
import React, { useState, useEffect } from 'react';
function FetchData({ userId }) {
const [data, setData] = useState(null);
useEffect(() => {
const controller = new AbortController();
// 发起数据请求
fetch(`/api/user/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(setData)
.catch(err => {
if (err.name === 'AbortError') {
console.log('请求已被取消');
} else {
console.error(err);
}
});
// 依赖变化时(userId 改变),或组件卸载时,调用清理函数
return () => {
controller.abort(); // 取消未完成的请求
};
}, [userId]); // 只有 userId 变动时才重新发起请求
return <div>用户名:{data ? data.name : '加载中...'}</div>;
}
useEffect 中使用异步函数
在 React 的函数式组件中,我们经常需要在组件挂载或更新时发起异步操作(如网络请求、读取本地储存、调用异步 API 等)。通常这些副作用逻辑都放在 useEffect 中。然而,useEffect 的回调函数本身不能被标记为 async(因为会返回一个 Promise,而 useEffect 期待的是可选的“清理函数”而非 Promise)。下面从原理、常见写法以及注意事项三个角度,详细讲解如何在 useEffect 中使用异步函数。
为什么不能直接把 useEffect 回调写成 async
function MyComponent() {
useEffect(async () => {
// 这样写是错误的
const res = await fetch('/api/data');
const data = await res.json();
// ...
}, []);
// ...
}
- 返回值的冲突:当你给 useEffect 传入一个 async 函数时,该函数会自动返回一个 Promise(因为 async 函数的返回值就是 Promise)。但 React 要求 useEffect 回调“要么直接返回 undefined,要么返回一个同步的清理函数(() => { ... })”,用来在组件卸载或依赖变化时执行清理。若回调返回了 Promise,React 无法识别这段 Promise 是清理逻辑还是误写,因而会抛出警告并导致逻辑混乱。
- 语义不清:清理函数(cleanup)本身必须是同步的,负责“撤销”前一次 effect 创建的资源(如取消订阅、清除定时器)。如果让 useEffect 回调变成 async,React 无法得知你是要在 await 之前进行清理,还是在 await 之后返回另一个函数,这会打乱生命周期的可预测性。
因此,千万不要将 useEffect 回调直接写成 async。接下来介绍几种常见的正确做法。
在 useEffect 里调用异步函数的常见写法
在 effect 内部定义并立即执行一个 async 函数
这是最常见也最推荐的方式:在 useEffect 回调体内,先定义一个 async 函数(可以用命名函数或箭头函数),然后马上调用它。
import React, { useEffect, useState } from 'react';
function UserProfile({ userId }) {
const [userData, setUserData] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
// 定义一个 async 函数
async function fetchUser() {
try {
const response = await fetch(`/api/user/${userId}`);
if (!response.ok) {
throw new Error(`网络错误:${response.status}`);
}
const json = await response.json();
setUserData(json);
} catch (err) {
setError(err);
}
}
// 立即执行
fetchUser();
// (可选)返回一个清理函数
return () => {
// 如果你想在 userId 变化时取消之前的请求,可以在这里处理
// 例如,使用 AbortController 来中止 fetch
};
}, [userId]); // 依赖列表中包含 userId,当 userId 改变时重新执行上述逻辑
if (error) {
return <div>加载出错:{error.message}</div>;
}
if (!userData) {
return <div>加载中...</div>;
}
return <div>用户名:{userData.name}</div>;
}
- 在 useEffect 中先定义 async function fetchUser(),然后同步地调用它。这样,useEffect 回调本身依旧是一个同步函数,返回值可以是 undefined 或者一个同步的清理函数。
- 如果需要在组件卸载或依赖变化时取消网络请求,可以配合 AbortController。
使用立即执行的箭头 async 函数(IIFE)
有些人喜欢用 “Immediately Invoked Function Expression”(IIFE) 的写法,更紧凑一些,但可读性与上一种等价:
useEffect(() => {
(async () => {
try {
const res = await fetch(`/api/user/${userId}`);
const data = await res.json();
setUserData(data);
} catch (err) {
setError(err);
}
})();
// 不返回任何清理函数,或者在此处也可返回同步清理
}, [userId]);
虽然这种写法更“短”,但对一些开发者而言可读性稍低。
将异步逻辑抽成自定义 Hook
为了让组件逻辑更清晰、可复用,我们可以把“异步请求 + 加载/错误状态管理”封装到自定义 Hook 里,然后在组件中直接使用。这样组件本身的 useEffect 只负责“调用 Hook”就好。
// useFetch.js
import { useState, useEffect } from 'react';
export function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function fetchData() {
setLoading(true);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw new Error(`请求失败:${res.status}`);
const json = await res.json();
setData(json);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err);
}
} finally {
setLoading(false);
}
}
fetchData();
// 清理:在组件卸载或 url 变更时取消请求
return () => {
controller.abort();
};
}, [url]);
return { data, loading, error };
}
组件使用时就非常简洁:
import React from 'react';
import { useFetch } from './useFetch';
function Dashboard({ userId }) {
const { data, loading, error } = useFetch(`/api/dashboard/${userId}`);
if (loading) return <div>加载中...</div>;
if (error) return <div>加载出错:{error.message}</div>;
return <div>欢迎,{data.userName}</div>;
}
- 关注点分离:组件只关心要什么数据,而不关心“如何发请求并管理状态”。
- 逻辑易复用:其他组件也可以复用 useFetch。
如果需要在 effect 中做取消逻辑(AbortController)
在一个组件可能在“请求还未返回”时就卸载,或者依赖改变需要取消前一次请求时,常见做法是借助原生的 AbortController,配合 fetch 的 signal 参数,让旧请求可被中止。
useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
async function loadData() {
try {
const response = await fetch(`/api/data/${id}`, { signal });
if (!response.ok) throw new Error(`错误:${response.status}`);
const json = await response.json();
setData(json);
} catch (err) {
if (err.name === 'AbortError') {
// 请求被取消时,fetch 会抛出 AbortError
console.log('Fetch 已取消');
} else {
setError(err);
}
}
}
loadData();
return () => {
// 在依赖改变或组件卸载时,调用 abort() 取消本次 fetch
controller.abort();
};
}, [id]);
- 在 effect 里先创建 AbortController,拿到 signal。
- 把 signal 传给 fetch,一旦执行 controller.abort(),该 fetch 就会立刻以 AbortError 终止。
- 在 catch 中判断 err.name === 'AbortError',即可区分“取消”与“真实网络错误”。
- 最后在 effect 的返回函数里调用 controller.abort(),实现“组件卸载或 id 变化时,取消当前请求”。
多个异步操作与清理
如果在同一个 useEffect 中做多次异步调用(例如先拿到 token,再根据 token 请求数据),可以串联 await,也要在最外层的 effect 返回一个总的清理逻辑:
useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
async function loadAll() {
try {
// 第一步:获取 token
const tokenRes = await fetch('/api/get-token', { signal });
const { token } = await tokenRes.json();
// 如果组件在这一步就卸载了,tokenRes 会被中止,下面 fetch 不会再执行
// 第二步:根据 token 请求数据
const dataRes = await fetch(`/api/data?token=${token}`, { signal });
const json = await dataRes.json();
setData(json);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err);
}
}
}
loadAll();
return () => {
controller.abort();
};
}, [dependencyA, dependencyB]);
使用 Promise.then 也可以
如果你不习惯 async/await,也可以使用链式 Promise 写法。但同样不能把 useEffect 回调改为异步:
useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
fetch(`/api/data/${id}`, { signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP 错误:${res.status}`);
return res.json();
})
.then(json => {
setData(json);
})
.catch(err => {
if (err.name === 'AbortError') {
console.log('请求被取消');
} else {
setError(err);
}
});
return () => {
controller.abort();
};
}, [id]);
功能与 async/await 等价,只是可读性略差。然而在一些需要兼容较旧环境或团队风格偏好 Promise 链时,也是常见写法。
以上就是本文的全部内容啦,如果本文对你有帮助的话,也可以转发给你的朋友~
相关推荐
- 赶紧收藏!编程python基础知识,本文给你全部整理好了
-
想一起学习编程Python的同学,趁我粉丝少,可以留言、私信领编程资料~Python基础入门既然学习Python,那么至少得了解下这门编程语言,知道Python代码执行过程吧。Python的历...
- 创建绩效改进计划 (PIP) 的6个步骤
-
每个经理都必须与未能达到期望的员工抗衡,也许他们的表现下降了,他们被分配了新的任务并且无法处理它们,或者他们处理了自己的任务,但他们的行为对他人造成了破坏。许多公司转向警告系统,然后在这些情况下终止。...
- PI3K/AKT信号通路全解析:核心分子、上游激活与下游效应分子
-
PI3K/AKT/mTOR(PAM)信号通路是真核细胞中高度保守的信号转导网络,作用于促进细胞存活、生长和细胞周期进程。PAM轴上生长因子向转录因子的信号传导受到与其他多条信号通路的多重交叉相互作用的...
- 互联网公司要求签PIP,裁员连N+1都没了?
-
2021年刚画上句号,令无数互联网公司从业者闻风丧胆的绩效公布时间就到了,脉脉上已然炸了锅。阿里3.25、腾讯二星、百度四挡、美团绩效C,虽然名称五花八门,实际上都代表了差绩效。拿到差绩效,非但不能晋...
- Python自动化办公应用学习笔记3—— pip工具安装
-
3.1pip工具安装最常用且最高效的Python第三方库安装方式是采用pip工具安装。pip是Python包管理工具,提供了对Python包的查找、下载、安装、卸载的功能。pip是Python官方提...
- 单片机都是相通的_单片机是串行还是并行
-
作为一个七年的从业者,单片机对于我个人而言它是一种可编程的器件,现在长见到的电子产品中几乎都有单片机的身影,它们是以单片机为核心,根据不同的功能需求,搭建不同的电路,从8位的单片机到32位的单片机,甚...
- STM32F0单片机快速入门八 聊聊 Coolie DMA
-
1.苦力DMA世上本没有路,走的人多了,便成了路。世上本没有DMA,需要搬运的数据多了,便有了DMA。大多数同学应该没有在项目中用过这个东西,因为一般情况下也真不需要这个东西。在早期的单片机中...
- 放弃51单片机,直接学习STM32开发可能会面临的问题
-
学习51单片机并非仅仅是为了学习51本身,而是通过它学习一种方法,即如何仅仅依靠Datasheet和例程来学习一种新的芯片。51单片机相对较简单,是这个过程中最容易上手的选择,而AVR单片机则更为复杂...
- STM32串口通信基本原理_stm32串口原理图
-
通信接口背景知识设备之间通信的方式一般情况下,设备之间的通信方式可以分成并行通信和串行通信两种。并行与串行通信的区别如下表所示。串行通信的分类1、按照数据传送方向,分为:单工:数据传输只支持数据在一个...
- 单片机的程序有多大?_单片机的程序有多大内存
-
之前一直很奇怪一个问题,每次写好单片机程序之后,用烧录软件进行烧录时,能看到烧录文件也就是hex的文件大小:我用的单片机芯片是STM32F103C8T6,程序储存器(flash)只有64K。从...
- 解析STM32单片机定时器编码器模式及其应用场景
-
本文将对STM32单片机定时器编码器模式进行详细解析,包括介绍不同的编码器模式、各自的优缺点以及相同点和不同点的应用场景。通过阅读本文,读者将对STM32单片机定时器编码器模式有全面的了解。一、引言...
- 两STM32单片机串口通讯实验_两个32单片机间串口通信
-
一、实验思路连接两个STM32单片机的串口引脚,单片机A进行发送,单片机B进行接收。单片机B根据接收到单片机A的指令来点亮或熄灭板载LED灯,通过实验现象来验证是否通讯成功。二、实验器材两套STM32...
- 基于单片机的智能考勤机设计_基于51单片机的指纹考勤机
-
一、设计背景随着科技水平的不断发展,在这么一个信息化的时代,智能化信息处理已是提高效率、规范管理和客观审查的最有效途径。近几年来,国内很多公司都在加强对企业人员的管理,考勤作为企业的基础管理,是公司...
- STM32单片机详细教学(二):STM32系列单片机的介绍
-
大家好,今天给大家介绍STM32系列单片机,文章末尾附有本毕业设计的论文和源码的获取方式,可进群免费领取。前言STM32系列芯片是为要求高性能、低成本、低功耗的嵌入式应用设计的ARMCortexM...
- STM32单片机的 Hard-Fault 硬件错误问题追踪与分析
-
有过单片机开发经验的人应该都会遇到过硬件错误(Hard-Fault)的问题,对于这样的问题,有些问题比较容易查找,有些就查找起来很麻烦,甚至可能很久都找不到问题到底是出在哪里。特别是有时候出现一次,后...
- 一周热门
-
-
【验证码逆向专栏】vaptcha 手势验证码逆向分析
-
Python实现人事自动打卡,再也不会被批评
-
Psutil + Flask + Pyecharts + Bootstrap 开发动态可视化系统监控
-
一个解决支持HTML/CSS/JS网页转PDF(高质量)的终极解决方案
-
再见Swagger UI 国人开源了一款超好用的 API 文档生成框架,真香
-
网页转成pdf文件的经验分享 网页转成pdf文件的经验分享怎么弄
-
C++ std::vector 简介
-
飞牛OS入门安装遇到问题,如何解决?
-
系统C盘清理:微信PC端文件清理,扩大C盘可用空间步骤
-
10款高性能NAS丨双十一必看,轻松搞定虚拟机、Docker、软路由
-
- 最近发表
- 标签列表
-
- python判断字典是否为空 (50)
- crontab每周一执行 (48)
- aes和des区别 (43)
- bash脚本和shell脚本的区别 (35)
- canvas库 (33)
- dataframe筛选满足条件的行 (35)
- gitlab日志 (33)
- lua xpcall (36)
- blob转json (33)
- python判断是否在列表中 (34)
- python html转pdf (36)
- 安装指定版本npm (37)
- idea搜索jar包内容 (33)
- css鼠标悬停出现隐藏的文字 (34)
- linux nacos启动命令 (33)
- gitlab 日志 (36)
- adb pull (37)
- python判断元素在不在列表里 (34)
- python 字典删除元素 (34)
- vscode切换git分支 (35)
- python bytes转16进制 (35)
- grep前后几行 (34)
- hashmap转list (35)
- c++ 字符串查找 (35)
- mysql刷新权限 (34)