如何解决 React 18 中 useEffect 执行两次的问题?

在开发基于 React 的应用程序时,我们经常依赖 useEffect hook 来处理副作用,比如数据获取、订阅事件或者手动更改 DOM。然而,从 React 18 升级后,你可能会惊讶地发现:为什么我的 useEffect 竟然运行了两次?这不仅可能导致控制台重复输出日志,甚至可能在处理 API 请求或清理订阅时引发意想不到的错误。在本文中,我们将深入探讨这一现象背后的根本原因,并一起探索多种行之有效的解决方案,帮助你构建更加健壮的应用程序。

目录

  • 为什么我的 useEffect 运行两次?
  • 如何调试与修复重复执行问题
  • React 18 Strict Mode 的双重渲染机制
  • 移除 Strict Mode 是否是明智之举?
  • 其他导致重复执行的潜在原因
  • 最佳实践与性能优化建议

环境准备:创建一个 React 应用

在开始深入代码之前,让我们先创建一个全新的 React 项目,以便你可以跟随我们的步骤进行实际操作。我们将使用 Vite 来快速搭建开发环境,它比传统的 Create React App 更为轻量和高效。

步骤 1: 在终端中运行以下命令来初始化项目。我们选择 vite 作为构建工具:

npm create vite@latest

步骤 2: 系统会提示你输入项目名称。你可以输入类似 use-effect-demo 的名字。创建完成后,进入项目根目录:

cd 

步骤 3: 安装项目所需的依赖包:

npm install
``

现在,启动开发服务器 (`npm run dev`),我们就可以开始编写代码并观察 `useEffect` 的行为了。

---

### 为什么 useEffect 运行两次?

首先,让我们复现这个问题。在 React 中,`useEffect` 是设计用来处理副作用的,它的设计初衷是在组件渲染到屏幕之后执行某些操作。如果使用不当,它可能会导致无限循环或多次执行,这通常是我们不希望看到的。

让我们看一个简单的例子。在你的 `App.jsx` 或 `App.js` 中写入以下代码:

javascript

import { useEffect } from "react";

function App() {

useEffect(() => {

console.log("副作用已触发:正在调用接口或执行初始化逻辑…");

}, []); // 空依赖数组表示仅在组件挂载时运行

return (

React 18 useEffect 演示

);

}

export default App;


**观察结果:**

如果你现在打开浏览器的控制台,你会惊讶地看到相同的日志输出了**两次**。这对于习惯了 React 17 或更早版本的开发者来说,可能会让人误以为是代码出了 Bug 或者导致了内存泄漏。

![控制台输出两次的截图示例](https://media.geeksforgeeks.org/wp-content/uploads/20240430183350/error.jpeg)

---

### 核心原因:React 18 的 Strict Mode

这实际上不是一个 Bug,而是 React 18 引入的一个故意为之的行为。当你处于**开发模式**下,并且应用被包裹在 `` 组件中时,React 会故意**重复调用**你的 Effect 一次。

**为什么要这么做?**

React 团队引入这个特性是为了帮助开发者发现潜在的问题。在未来的版本中,React 可能会支持一种特性,即当用户离开标签页并回来时,通过重新挂载组件来恢复状态。如果 Effect 能正确处理“挂载 -> 卸载 -> 重新挂载”的过程,那么你的应用将更具弹性。因此,在开发阶段运行两次,是为了确保你的清理函数(cleanup function)写得正确,确保 Effect 在被销毁并重新创建时不会崩溃。

### 解决与调试:确保你的代码健壮

虽然这是预期行为,但它确实可能会干扰开发体验。让我们来看看如何调整代码以适应这一机制,或者如何处理真正由逻辑错误导致的重复执行。

#### 1. 确保 Effect 的可重入性

最正确的“修复”方法不是阻止它运行两次,而是确保你的代码能够安全地运行两次。

**场景:API 请求与取消**

如果你在 `useEffect` 中发起数据请求,如果组件卸载了,但请求还没返回,这就可能导致内存泄漏或状态更新警告。我们可以使用 `AbortController` 来解决这个问题。

javascript

import { useState, useEffect } from "react";

function UserProfile({ userId }) {

const [data, setData] = useState(null);

const [loading, setLoading] = useState(true);

useEffect(() => {

// 1. 创建一个控制器实例

const abortController = new AbortController();

const fetchData = async () => {

try {

console.log(正在获取用户 ${userId} 的数据...);

// 2. 将 signal 关联到 fetch 请求

const response = await fetch(

https://api.example.com/users/${userId},

{ signal: abortController.signal }

);

if (!response.ok) throw new Error(‘网络响应异常‘);

const result = await response.json();

// 3. 确保组件仍然挂载(或者请求未被取消)

setData(result);

} catch (error) {

// 4. 忽略由于取消导致的错误

if (error.name !== ‘AbortError‘) {

console.error("获取数据失败:", error);

}

} finally {

setLoading(false);

}

};

fetchData();

// 5. 清理函数:React 在卸载或再次执行 Effect 前调用此函数

return () => {

abortController.abort();

console.log("清理工作:取消上一次未完成的请求");

};

}, [userId]); // 依赖项数组

if (loading) return

加载中…

;

return

用户名: {data?.name}

;

}


在这个例子中,即便 React 在开发模式下立即触发了两次 Effect,第一次 Effect 的清理函数会立即执行并取消请求,从而防止了竞态条件。在生产环境中,这同样能防止组件快速切换时的问题。

#### 2. 优化事件监听器

另一个常见的场景是添加和移除事件监听器。如果不正确处理,可能会导致监听器被重复添加,导致内存泄漏。

javascript

useEffect(() => {

const handleResize = () => {

console.log("窗口大小改变了");

};

// 添加监听器

window.addEventListener("resize", handleResize);

// 关键:必须返回一个函数来移除监听器

return () => {

window.removeEventListener("resize", handleResize);

console.log("监听器已移除");

};

}, []);


**为什么这很重要?** 在 React 18 的 Strict Mode 下,Effect 会先挂载(添加监听器),然后立即卸载(移除监听器),然后再次挂载(再次添加监听器)。如果你忘记写 `return` 里的清理代码,你的窗口上就会绑定两个相同的监听器。如果你的清理逻辑完善,两次运行不仅无害,反而验证了你的代码质量。

### 方法二:移除 Strict Mode (不推荐,但可行)

如果你确定你的代码已经非常健壮,或者你正在维护一个遗留项目,暂时无法修复所有的 Effect,你可以选择禁用 Strict Mode。这将使 React 在开发环境下的行为恢复到 React 17 的样子(Effect 只运行一次)。

**如何操作?**

打开你的入口文件,通常是 `src/main.jsx` 或 `src/index.js`。你会看到类似下面的代码:

javascript

import React from ‘react‘;

import ReactDOM from ‘react-dom/client‘;

import App from ‘./App‘;

import ‘./index.css‘;

ReactDOM.createRoot(document.getElementById(‘root‘)).render(

);


**修改后:**

javascript

import React from ‘react‘;

import ReactDOM from ‘react-dom/client‘;

import App from ‘./App‘;

import ‘./index.css‘;

// 移除 标签包裹

ReactDOM.createRoot(document.getElementById(‘root‘)).render(

);


![移除 Strict Mode 后控制台仅输出一次](https://media.geeksforgeeks.org/wp-content/uploads/20240430190257/remvoestrict.png)

**注意:** 虽然这能立即消除控制台的重复日志,但我们建议你最终还是要保留 Strict Mode,因为它能帮助你发现像上一节提到的内存泄漏或未清理的副作用问题。它就像一个严格的代码审查员,虽然有时唠叨,但对你有好处。

### 其他导致 useEffect 重复运行的常见原因

如果移除了 Strict Mode 或者你觉得重复运行的频率比预期的要高(例如无限循环),那可能不仅仅是 Strict Mode 的问题。让我们排查其他常见原因。

#### 1. 依赖项数组 设置不当

这是最常见的人为错误。

**错误示例:**

javascript

function Counter() {

const [count, setCount] = useState(0);

useEffect(() => {

console.log("Effect 运行了");

// 假设我们想根据 count 做点什么,但在 Effect 里更新了 count

setCount(count + 1);

}, [count]); // 依赖 count

}


**分析:** 
1. 组件渲染,`count` 为 0。
2. Effect 运行,调用 `setCount(0 + 1)`。
3. 组件重新渲染,`count` 变为 1。
4. React 检测到依赖项 `count` 变了,再次运行 Effect。
5. 无限循环!

**修复建议:** 仔细检查 `useEffect` 的第二个参数数组。确保你不会在 Effect 内部直接修改数组中的依赖项,除非这是你预期的交互逻辑(比如设置定时器)。

#### 2. 父组件重新渲染导致子组件 Effect 触发

即使子组件的 props 没有变,如果父组件重新渲染,并且子组件没有被 `React.memo` 包裹,子组件也会重新渲染。如果子组件中有 `useEffect`,且依赖项数组虽然为空但 Effect 没有正确处理(或者子组件在每次渲染时都被视为“新”实例),可能会导致问题。

**示例场景:**

javascript

function Parent() {

const [name, setName] = useState("");

return (

setName(e.target.value)} />

{/ 每次Parent的name改变,Child也会重新渲染 /}

);

}

const Child = () => {

useEffect(() => {

console.log("Child 挂载或更新");

}, []); // 即使是空数组,如果组件重新挂载,Effect也会运行

return

子组件

;

};


**解决:** 使用 `React.memo` 来避免不必要的子组件渲染,或者确保 Key 属性稳定,防止 React 销毁并重建组件实例。

#### 3. 全局状态管理的变化

如果你正在使用 Redux 或 Context API,确保你正确地订阅了状态。

javascript

function MyComponent() {

// 错误的做法:在 Effect 外部订阅但在 Effect 内部处理

// 或者 Context 值每次都是新对象

const value = useMemo(() => ({ data: ‘some data‘ }), []); // 确保对象引用稳定

useEffect(() => {

// … 依赖 value

}, [value]);

}


如果 Context Provider 的 value 每次渲染都是一个新对象(例如 `{{}}`),那么 consuming 组件的 Effect 就会每次都运行。使用 `useMemo` 来保持引用稳定。

### 最佳实践总结与建议

在处理 React 18 中的 `useEffect` 时,我们可以遵循以下最佳实践来确保代码的清晰和高效:

1.  **拥抱 Strict Mode:** 不要急着关闭它。将其视为代码质量的试金石。如果它能运行两次而没有任何错误或警告,说明你的清理逻辑做得很好。

2.  **总是编写清理函数:** 即使你认为不需要,也养成在 `useEffect` 中返回一个清理函数的习惯。
    

javascript

useEffect(() => {

const timer = setInterval(() => console.log(‘Tick‘), 1000);

return () => clearInterval(timer); // 必不可少

}, []);

“INLINECODEdb63cf1beslint-plugin-react-hooksINLINECODE11a7c93cuseEffect。这样更容易追踪哪个 Effect 导致了重复渲染。

### 结语

React 18 中 useEffect` 运行两次的问题,实际上是 React 帮助我们写出更好代码的一种手段。通过理解 Strict Mode 的工作原理,我们不仅可以消除困惑,还能利用它来优化我们的组件生命周期管理。无论是通过编写可取消的请求、正确地移除事件监听器,还是排查依赖项陷阱,掌握这些技能都将使你在现代 React 开发中游刃有余。

希望这篇文章能帮助你解决开发中遇到的困惑!如果你有其他关于 React 性能优化的问题,欢迎继续探讨。

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。如需转载,请注明文章出处豆丁博客和来源网址。https://shluqu.cn/29024.html
点赞
0.00 平均评分 (0% 分数) - 0