React18 的 useEffect 会执行两次的 Feature
背景描述
前段时间在一个 React 项目中,在编码的过程中遇到一个很奇怪的“Bug”。 其中简化版的代码如下所示。
1
2
3
4
5
6
7
8
9
10
// 入口文件
import { StrictMode } from 'react';
import * as ReactDOMClient from 'react-dom/client';
import App from './App';
const root = ReactDOMClient.createRoot(document.getElementById('root'));
root.render(
<StrictMode>
<App />
</StrictMode>
);
1
2
3
4
5
6
7
8
9
// 组件代码
import React, { useEffect } from 'react';
const App = () => {
useEffect(() => {
console.log('Hello world!');
}, []);
return <>Hello world!</>;
};
就这样几行简单的组件竟然会触发一个“Bug”, 表现为在 Chrome 控制台里发现 “Hello world!” 被打印了两次。
React18 useEffect 新特性
这里看到的 console.log 执行两次是 React 18 在 开发模式 (Development Mode) 下的 <StrictMode> (严格模式) 的一个Feature,并不是 Bug。
模拟组件的挂载-卸载-重新挂载: 在组件首次挂载时,严格模式会进行额外的“设置 + 清理”周期(setup + cleanup cycle)。它会模拟组件:
- 首次挂载并执行 Effect 的
Setup函数(即你的useEffect中的回调函数)。 - 立即执行 Effect 的
Cleanup函数(虽然你的例子中没有,但如果 Effect 有返回函数就会执行)。 - 第二次挂载 并再次执行 Effect 的
Setup函数。
验证 Effect 的健壮性 React 故意这样做是为了压力测试 (stress-test) 你的 Effect 代码,确保它在组件被意外卸载和重新挂载时(例如,未来的 React 可能会自动执行这种操作来保持状态或在 UI 部分之间切换)能够正确地工作。
如果你的 Effect 缺少 清理函数 (cleanup function),并且在第二次执行时导致了意料之外的结果(比如重复的 API 调用、双重订阅、内存泄漏),那么这个双重执行的行为就会帮助你发现这个错误。
useEffect是 React 提供的一个 Hook,它允许你在函数组件中执行 副作用 (side effects)。副作用 是指那些在 React 渲染过程之外发生的操作,例如:
- 数据获取 (Data Fetching): 从 API 请求数据。
- 订阅 (Subscriptions): 比如 WebSocket 连接或 Redux 存储监听。
- 手动更改 DOM: 例如设置文档标题 (
document.title) 或使用原生 DOM API。- 定时器 (Timers): 如
setTimeout或setInterval。
useEffect接收两个参数:
- Setup 函数 (Effect Callback): 包含副作用逻辑的函数。
- 依赖数组 (Dependency Array): 一个可选的数组,包含 Effect 所依赖的变量。
1 2 3 4 5 6 7 8 9 useEffect(() => { // 这是 Setup 函数,包含副作用逻辑 console.log('副作用执行了'); // 【可选】返回清理函数 (Cleanup function) return () => { console.log('清理函数执行了'); }; }, [/* 依赖数组 */]); // 👈 控制副作用何时重新执行
依赖数组状态 执行时机 (Setup 函数) 行为类比 (Class Component) [] (空数组) 仅在组件首次挂载 ( mount) 时执行一次。componentDidMount未传入 (省略) 在组件首次挂载和每次更新 ( update) 后都执行。componentDidMount+componentDidUpdate“[a, b] (有依赖项)” 首次挂载时执行;以及当依赖项 a 或 b 发生变化时重新执行。 componentDidMount+ 条件性componentDidUpdate
在我们这个案例中:
1
2
3
4
5
6
const App = () => {
useEffect(() => {
console.log('组件挂载完成!'); // 这一行被执行了两次
}, []); // 依赖数组为空,模拟 componentDidMount
return <>Hello world!</>;
};
- 第一次执行: 模拟挂载 -> console.log(‘组件挂载完成!’)
- 模拟卸载(未执行 cleanup): 你的 useEffect 没有返回 cleanup 函数。
- 第二次执行: 实际挂载 -> console.log(‘组件挂载完成!’)
为什么 useEffect 需要清理函数?
useEffect返回的函数就是 清理函数 (Cleanup Function)。它的主要作用是撤销 (undo) 或 停止 (stop) 由 Setup 函数启动的Effect,从而防止资源泄漏和不一致的行为。
- 内存泄漏 (Memory Leaks): 如果没有清理函数,某些副作用会一直运行,即使组件已经被卸载, 例如: 设置了一个
setInterval但没有清除它,它会一直触发- 避免竞态条件 (Race Conditions) 和不一致性: 当组件因为依赖项变化而重新渲染时,Effect 会重新执行。清理函数确保在新的Effect运行之前,上一次的Effect已被妥善处理。假设有一个 Effect 依赖于一个
prop id。当 id 从1变为2时,旧的 Effect 会执行清理函数,然后新的 Effect 才会运行。如果没有清理函数,你可能会同时运行两个Effect,如果它们操作的是同一个资源,就会导致不确定的行为。- 取消进行中的异步操作: 对于异步操作(如
fetch API调用),组件可能在请求完成之前就被卸载了。如果在请求结束后尝试设置组件的状态,React 会发出警告,这可能导致潜在的错误。
解决方案
正确的做法是,不要试图阻止它执行两次,而是确保 Effect 即使执行两次也不会导致问题。而且这个测试逻辑只会在开发模式 (Development Mode) 下的
<StrictMode>(严格模式)执行
清理函数实现
任何时候,只要你的 Effect 执行了任何需要停止、撤销、或者释放外部资源的操作,你就必须返回一个清理函数。下面是一些常见的清理函数实现
清理事件监听
如果 Effect 涉及到订阅、定时器、或事件监听器等,务必 返回一个清理函数来撤销 Effect 所做的事情。这是 React 团队推荐的修复由严格模式暴露出来的问题的方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
useEffect(() => {
const timer = setInterval(() => {
// ...
}, 1000);
console.log('Setup: 启动定时器');
// 返回清理函数
return () => {
clearInterval(timer);
console.log('Cleanup: 清除定时器');
};
}, []);
重置页面数据和状态
对于一些页面属性的变更,在返回函数内部将其变更的属性进行还原。
1
2
3
4
5
6
7
useEffect(() => {
const node = ref.current;
node.style.opacity = 1; // Trigger the animation
return () => {
node.style.opacity = 0; // Reset to the initial value
};
}, []);
涉及到元素状态的,比如播放器之类,需要对(元素)播放器的状态进行重置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { useEffect, useRef } from 'react';
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
});
return <video ref={ref} src={src} loop playsInline />;
}
如果是默认弹窗类,这种也算是元素状态,同样需要对其(弹出)状态进行重置。
1
2
3
4
5
useEffect(() => {
const dialog = dialogRef.current;
dialog.showModal();
return () => dialog.close();
}, []);
取消进行中的异步操作
API 调用 使用 AbortController 在清理函数中取消进行中请求
1
2
3
4
5
6
7
8
9
useEffect(() => {
const controller = new AbortController();
fetchData(id, { signal: controller.signal });
return () => {
// 组件卸载或id变化时,取消未完成的请求
controller.abort();
};
}, [id]);
在开发场景对两次执行的处理
外部请求
如果 Effect 中有数据获取(如 API 调用),而你不希望它在开发模式下请求两次(例如这个接口不具备幂等性),你可以使用一些策略:
- 使用现代状态管理库或数据获取库:如 React Query (TanStack Query) 或 SWR。它们内置了重复请求的去重和缓存机制,能很好地处理严格模式下的双重调用。
- 使用 AbortController 取消请求:在清理函数中取消第一个 Effect 启动的进行中的请求。
- 手动对请求缓存
useEffect的主要目的是同步 React 状态和外部系统。理想情况下,
useEffect应该用于以下场景:
- 数据获取 (Data Fetching): 使用 GET 请求从服务器拉取数据。这是只读的。
- 订阅 (Subscriptions): 监听外部状态(如浏览器的 resize 事件或 Redux store),并将状态同步到组件。
- 浏览器副作用: 修改
document.title或添加/移除事件监听器。- 利用
useRef来确保你的副作用逻辑在严格模式下记录是否是首次执行, 并只在首次执行的时候执行一次。然后封装为自定义Hook使用对于任何修改外部状态或响应用户意图的操作(例如,提交表单、删除记录、为计数器 +1),应该将其放在事件处理函数 (如
onClick,onSubmit) 中。如果出现了在
useEffect调用写入/修改接口(不具备幂等性的接口)的情况下优先考虑重构组件在事件处理函数中处理数据修改
例如: 设置一个 标识位ignore,做到对重复请求返回的数据使用缓存 cache.current
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const cache = useRef(null);
useEffect(() => {
let ignore = false;
async function startFetching() {
if (!cache.current) {
// cache.current = await fetchTodos(userId);
cache.current = true;
await fetchTodos(userId);
}
if (!ignore) {
setTodos(cache.current);
}
}
startFetching();
return () => {
ignore = true;
};
}, [userId]);
或者使用 useRef自定义一个 Hook useComponentDidMount
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { useEffect, useRef } from 'react';
/**
* 封装了 useEffect 和 useRef 的逻辑,以模拟 Class 组件的 componentDidMount 行为,
* 并在 React 18 的 StrictMode 下阻止双重执行。
* * @param {function} callback - 仅在组件首次实际挂载时执行的函数。
*/
export const useComponentDidMount = (callback) => {
// 使用 useRef 来追踪 Effect 是否是首次“实际”执行
const hasMounted = useRef(false);
useEffect(() => {
// 检查 ref 的值
if (hasMounted.current === false) {
// 首次执行:设置 ref 为 true,然后执行回调
hasMounted.current = true;
callback();
}
// 注意:这里不需要 else 块,因为 cleanup 函数和依赖项数组为空 []
// 确保了它只在挂载和卸载时运行,不会在更新时运行。
// 如果 callback 中有需要清理的资源(例如定时器),则需要返回清理函数
// return () => {
// // ... cleanup logic (在组件卸载时执行)
// };
}, [callback]); // 依赖项包含 callback,确保它不会过期
};
useComponentDidMount使用案例:
1
2
3
4
5
6
7
8
9
10
11
12
13
import React from 'react';
import { useComponentDidMount } from './useComponentDidMount'; // 假设文件路径
const TrackingComponent = () => {
useComponentDidMount(() => {
// 这里的 count() 只会在组件首次加载时执行一次,
// 且不受 StrictMode 的双重执行影响。
console.log('✅ 组件已加载,精确地执行一次统计 API 调用');
// countDatabaseIncrement();
});
return <div>内容正在展示...</div>;
};
虽然 React 没有提供一个类似于 Class 组件的 componentDidMount() 的无副作用 Hook 作为内置功能,但通过结合 useEffect 和 useRef,可以轻松地创建一个功能完全相同的自定义 Hook
禁用严格模式
如果确定不想使用此功能(例如在调试旧代码时),你可以从你的入口文件中移除 <StrictMode> 标签:
1
2
3
4
5
6
7
8
9
10
// import { StrictMode } from 'react'; // 不再需要
import * as ReactDOMClient from 'react-dom/client';
import App from './App';
const root = ReactDOMClient.createRoot(document.getElementById('root'));
root.render(
// 移除 <StrictMode>
<App />
);
React官方强烈不建议在开发过程中禁用严格模式,因为它有助于发现潜在的 Bug。