文章

React18 的 useEffect 会执行两次的 Feature

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): 如 setTimeoutsetInterval

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,从而防止资源泄漏和不一致的行为。

  1. 内存泄漏 (Memory Leaks): 如果没有清理函数,某些副作用会一直运行,即使组件已经被卸载, 例如: 设置了一个 setInterval 但没有清除它,它会一直触发
  2. 避免竞态条件 (Race Conditions) 和不一致性: 当组件因为依赖项变化而重新渲染时,Effect 会重新执行。清理函数确保在新的Effect运行之前,上一次的Effect已被妥善处理。假设有一个 Effect 依赖于一个 prop id。当 id 从 1 变为 2 时,旧的 Effect 会执行清理函数,然后新的 Effect 才会运行。如果没有清理函数,你可能会同时运行两个Effect,如果它们操作的是同一个资源,就会导致不确定的行为。
  3. 取消进行中的异步操作: 对于异步操作(如 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 作为内置功能,但通过结合 useEffectuseRef,可以轻松地创建一个功能完全相同的自定义 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。

参考

本文由作者按照 CC BY 4.0 进行授权