前置知识
1. 为什么我们需要学 useEffect?
在 React 的世界里,我们总是追求组件的纯粹性 ------给定相同的 props,永远返回相同的 UI。但现实往往是骨感的,我们需要处理数据获取、订阅消息、手动修改 DOM 等"脏活累活"。
在编程中,这些与外部世界交互、产生额外影响 的操作,就被称为副作用。
为了协调纯粹的 UI 渲染与复杂的现实需求 ,React 提供了 useEffect Hook。在深入它之前,我们需要先厘清两个核心概念。
2. 保持组件纯粹性与副作用
问题:什么是副作用函数,什么是纯函数?(面试题)
纯函数定义:
- 相同的输入永远会得到相同的输出。这意味着函数的行为是可预测的。
- 只负责自己的任务,无副作用出现:它只关心自己的内部逻辑(第一点),绝不会去修改函数外部的任何状态(比如全局变量、DOM、文件系统等)。
typescript
// 纯函数:只负责计算,不依赖也不修改外部状态
const sum = (x: number, y: number): number => x + y;
sum(1, 2); // 始终返回 3
副作用函数定义:
一个函数在执行过程中,除了返回值 之外,还对外部环境产生了可观察的影响 或修改,这个函数就是副作用函数。
常见的副作用行为包括:
- 修改外部变量:修改全局变量、修改传入的参数对象(引用类型)。
- I/O 操作:发起网络请求(AJAX/Fetch)、读写文件、读写数据库。
- DOM 操作:修改网页标题、手动更改 DOM 节点结构。
- 系统交互 :设置定时器(
setTimeout)、订阅事件(addEventListener)、打印日志(console.log)。 - 非确定性 :依赖随机数(
Math.random)或当前时间(new Date),导致相同输入得到不同输出。
typescript
let total: number = 0; // 外部变量
// 副作用函数:修改了外部的 'total' 变量
function apple(value: number): number {
total += value;
return total;
}
3. React 组件应该是纯函数
React 的核心设计理念之一就是:组件是一个纯函数
核心原则:
组件的职责非常单一------根据 props 和 state 计算并返回 JSX。在渲染阶段,组件不应包含任何副作用。
注意:
这里说的"不能有副作用",是指渲染过程中不能有。渲染阶段只管教一件事------根据 props 和 state 算出 UI。
javascript
// 纯组件:相同的 props 总是渲染出相同的结果
function Greeting({ name }) {
return <h1>你好,欢迎{name}学习React!</h1>;
}
React 依赖这个纯粹的约定来实现很多优化(比如跳过不必要的重新渲染),如果组件在渲染过程中偷偷做了额外的事情,就会打破这个约定,导致难以追踪的 bug。
javascript
// 错误示范:在渲染过程中执行副作用
function UserProfile({ userId }) {
// 每次渲染都会发起请求,而且无法去控制时机和清理
fetch(`/api/users/${userId}`).then(...)
return <div>用户资料</div>;
}
为什么这样做是危险的?
如果在渲染阶段直接写副作用,会引发以下严重后果:
-
请求失控(发多少次你说了不算)
- React 可能会因为并发模式或父组件更新而多次调用组件函数。如果在函数体内直接请求,会导致重复请求,浪费资源。
-
内存泄漏(关不掉)
- 如果在渲染时开启了定时器或订阅,当组件被卸载时,这些任务可能仍在后台运行。这不仅浪费性能,还可能试图更新一个不存在的组件状态,导致报错。
-
缺乏控制力
- 渲染的时机由 React 决定。将副作用放在渲染主体中,意味着你无法控制它何时执行 、执行几次 以及依赖什么条件。
React 组件应当保持纯粹,只负责 UI 的映射。而处理副作用(如请求数据、定时器)的任务,需要交给专门的机制------也就是我们接下来要学习的 useEffect。
4. useEffect 基本用法
scss
useEffect(setup, dependencies?)
参数
- setup(必选) :Effect处理函数,可以返回一个清理函数(cleanup)。组件挂载时执行setup,依赖项更新时先执行cleanup再执行setup,组件卸载时执行cleanup。
- dependencies(可选) :setup中使用到的响应式值列表(props、state等)。必须以数组形式编写如[dep1, dep2]。不传则每次重渲染都执行Effect。
React 内部是用
Object.is(类似===)来对比依赖项有没有变化的。
scss
useEffect(() => {
// 副作用代码
}, [/* 依赖项数组 */]);
返回值
useEffect 返回undefined
javascript
let a = useEffect(() => {})
console.log('a', a) //undefined
使用步骤:
先从 React 中导入 useEffect Hook:
javascript
import { useEffect } from 'react';
再在组件顶部调用, 并在其中加入一些代码:(不要在循环或条件判断中调用 Hook。)
javascript
function MyComponent() {
useEffect(() => {
// 每次渲染后都会执行此处的代码
// 这里就是你的"副作用"发生的地方
});
return <div>我的组件</div>;
}
每当你的组件渲染时,React 会先更新页面,然后再运行 useEffect 中的代码。换句话说,useEffect 会"延迟"一段代码的运行,直到渲染结果反映在页面上 。它是异步执行的,不会阻塞浏览器绘制屏幕
执行时机:什么是"渲染后"?
理解 useEffect 的核心在于理解它的执行时机。
React 的工作流程是这样的:
- 渲染阶段:React 调用你的组件函数,计算出需要更新的 JSX(虚拟 DOM)。
- 提交阶段:React 将变更应用到真实的 DOM 上,屏幕上的画面更新了。
- 副作用阶段 :React 执行你在
useEffect中定义的代码。
核心概念 :
useEffect会"延迟"一段代码的运行,直到渲染结果已经反映在页面上。这意味着,如果你的副作用代码需要获取 DOM 节点的尺寸、位置,或者需要触发一个不阻塞浏览器绘制的网络请求,
useEffect是完美的选择。
4. useEffect 的三种执行模式
useEffect 的行为完全取决于第二个参数(依赖项数组) 。
1. 不传参数:每次渲染都执行
- 特点:没有任何限制,组件只要更新(无论是哪个 state 变了),它就会跑。
- 类比 :
componentDidMount+componentDidUpdate
javascript
import { useEffect, useState } from "react";
const App = () => {
const [count, setCount] = useState(0);
const [text, setText] = useState("");
// 没有第二个参数:只要组件重新渲染(count 或 text 变动),这里都会执行
useEffect(() => {
console.log("只要渲染,我就执行");
});
return (
<div>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
<input value={text} onChange={(e) => setText(e.target.value)} />
</div>
);
};
2. 空数组 []:只在挂载时执行一次
- 特点:相当于"初始化"操作,只跑一次,后面不管怎么更新都不管了。
- 类比 :
componentDidMount
javascript
import { useEffect, useState } from "react";
const App = () => {
const [count, setCount] = useState(0);
// 空数组:只有组件第一次显示在页面上时执行,后续 count 变化不会触发
useEffect(() => {
console.log("仅执行一次(适合初始化、请求接口)");
}, []);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
};
3. 有依赖项 [value]:数据变了才执行
- 特点:精确监听。只有数组里的变量发生变化,才会触发。
- 类比 :特定的
componentDidUpdate
javascript
import { useEffect, useState } from "react";
const App = () => {
const [count, setCount] = useState(0);
const [text, setText] = useState("");
// 依赖 count:只有 count 变化时才执行。text 变化不会触发。
useEffect(() => {
console.log("只有 count 变了,我才执行");
}, [count]);
return (
<div>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
<input value={text} onChange={(e) => setText(e.target.value)} />
</div>
);
};
清理函数:善后工作
副作用往往需要"打扫战场"(比如清除定时器、移除监听)。useEffect 允许你返回一个函数,这就是清理函数。
-
执行时机:
组件卸载时。
依赖项变化,下一次 Effect 执行前(先清理旧的,再执行新的)。
场景:定时器清理
javascript
import { useEffect, useState } from "react";
const App = () => {
const [count, setCount] = useState(0);
useEffect(() => {
// 1. 创建副作用(开启定时器)
const timer = setInterval(() => {
setCount((c) => c + 1);
}, 1000);
// 2. 返回清理函数(清除定时器)
// 当组件卸载 或 依赖变化重跑前,React 会自动调用这个函数
return () => {
clearInterval(timer);
console.log("定时器已清理");
};
}, []); // 空数组保证只开启一次定时器
return <div>计时:{count}</div>;
};
场景:解决"请求竞态"问题
当用户快速输入时,我们希望取消上一次还没发完的请求,只保留最后一次的请求。
javascript
import { useEffect, useState } from "react";
const Search = () => {
const [keyword, setKeyword] = useState("");
useEffect(() => {
// 1. 模拟开启一个定时器(代表网络请求)
const timer = setTimeout(() => {
console.log(`发送请求:${keyword}`);
}, 500);
// 2. 清理函数:如果用户又输入了新字,这里会先执行,清除上一次的定时器
return () => {
clearTimeout(timer);
console.log("取消上一次过期的请求");
};
}, [keyword]); // 依赖 keyword,每次输入变化都会触发
return <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />;
};