12k
All articles

为什么你的 useEffect 会执行两次

了解React StrictMode为何在开发环境中让useEffect运行两次,并学习通过正确清理修复请求、监听器及其他副作用。

OpenReplay Team
OpenReplay Team
为什么你的 useEffect 会执行两次

在开发环境中,React StrictMode 会先运行每个 Effect 的设置(setup)逻辑,接着运行其清理(cleanup)逻辑,然后再次运行设置逻辑。因此,任何执行数据请求、订阅或日志输出的 Effect 都会明显地发生两次。

于是,你在 Effect 中只写了一个 fetch,打开 Network 面板却看到两个完全相同的请求,控制台里也有两条日志。这看起来像是你代码里的 bug,或者是 React 的 bug。

其实两者都不是。执行两次是 React 有意为之,目的是检验你的清理逻辑。本文将介绍 StrictMode 具体在做什么,如何修复三类最常见的未通过检验的 Effect,哪些“修复”只是掩盖了问题,以及如何判断你已经修复完毕。如果你需要先回顾这个 Hook 本身,可以从这篇 React useEffect Hook 指南 开始。

核心要点

  • StrictMode 会为每个 Effect 额外执行一次设置和清理,且仅在开发环境中生效,React 18 和 React 19 均是如此。
  • StrictMode 需要手动启用,但 Vite React 模板默认会用 <StrictMode> 包裹应用,Next.js App Router 也默认开启了它。
  • 正确的清理函数应当精确撤销设置逻辑所做的事情:中止请求、移除监听器、清除定时器或关闭连接。
  • 即使修复正确,开发环境下 Network 面板中出现两个请求也是正常现象。关键在于只有最新的响应能够更新状态。
  • 移除 StrictMode 或添加 useRef“已执行”守卫,只会掩盖缺失的清理逻辑,而不会真正修复它。

症状:请求被发送了两次

大多数人都是通过一个没有清理逻辑的数据请求 Effect 发现这一行为的。下面这个组件在生产环境中运行正常,但在开发环境中会触发两次:

import { useState, useEffect } from 'react';

function ProductDetails({ id }) {
  const [product, setProduct] = useState(null);

  useEffect(() => {
    console.log('setup');
    fetch(`/api/products/${id}`)
      .then((res) => res.json())
      .then((data) => setProduct(data));
  }, [id]);

  return <h2>{product?.name}</h2>;
}

你会看到两条 setup 日志和两个请求。由于没有任何代码告诉 React 如何取消第一个请求,两个请求都会完整执行。

为什么 useEffect 在开发环境中会执行两次?

React StrictMode 参考文档 中说明,开启 StrictMode 后,React 会在开发环境中为每个 Effect 额外增加一轮设置和清理。你的 Effect 会依次执行设置、清理、再设置,就像组件挂载后立即卸载、然后又重新挂载一样。

这种重复执行只发生在开发构建中。在生产环境中,Effect 会在组件挂载时执行一次,之后只有在依赖项变化或组件重新挂载时才会再次执行。

StrictMode 需要手动启用,它只作用于被 <StrictMode> 包裹的组件。不过,很多项目默认就已经被包裹了。Vite React 模板的 main.jsx 会在 <StrictMode> 内渲染 <App />。Next.js App Router 自 Next.js 13.5.1 起默认启用 StrictMode。而 Pages Router 应用则需要通过 reactStrictMode: true 手动开启。

这一轮额外的执行用于检验你的清理逻辑是否真正有效。如果一个 Effect 在 React 执行设置、清理、设置时出现异常,那么当用户离开页面再返回时,它同样会出现异常。在开发过程中修改代码导致 Effect 重新执行时,它也会出现异常:Next.js Fast Refresh 文档 指出,Effect 应当能够容忍偶尔的重复执行,并提到 StrictMode 会强制检验这一点。如果想深入了解清理函数相对于浏览器绘制的具体触发时机,请参阅 useEffect 与 useLayoutEffect 对比。

如何修复执行两次的 useEffect?

要修复执行两次的 useEffect,就要为它提供一个清理函数,撤销设置逻辑所启动的操作。每种常见问题都有对应的修复方式:

开发环境中的现象真正的问题修复方式
两次请求,可能出现过期数据没有任何逻辑取消进行中的请求在清理函数中使用 AbortController 或 ignore 标志
监听器或订阅触发两次从未被移除在清理函数中移除
计数器最终变为 2这段逻辑本身并不是副作用移到事件处理函数中

请求被发送两次

对于在 useEffect 中被发送两次的请求,有两种修复方式。第一种是在清理函数中中止请求。当 fetch 被中止时,它会以 AbortError DOMException 拒绝(reject),你可以放心地忽略这个错误:

useEffect(() => {
  const controller = new AbortController();
  fetch(`/api/products/${id}`, { signal: controller.signal })
    .then((res) => res.json())
    .then((data) => setProduct(data))
    .catch((err) => {
      if (err.name !== 'AbortError') throw err;
    });
  return () => controller.abort();
}, [id]);

另一种方式是使用 ignore 标志,这也是 React 官方“使用 Effect 进行同步”指南 中用于数据请求的模式:

useEffect(() => {
  let ignore = false;
  fetch(`/api/products/${id}`)
    .then((res) => res.json())
    .then((data) => {
      if (!ignore) setProduct(data);
    });
  return () => {
    ignore = true;
  };
}, [id]);

无论采用哪种修复方式,在开发环境中你仍然会看到两个请求。使用 AbortController 时,第一个请求会被中止;使用 ignore 时,两个请求都会完成,但只有第二个请求被允许更新状态。生产环境中只会发送一个请求。同样的清理逻辑还能在 id 变化时,防止较慢的旧响应覆盖较新的响应。

监听器泄漏

在 useEffect 中添加的事件监听器,如果没有在清理函数中移除,就会发生泄漏。请在 Effect 内部声明处理函数,以确保清理函数移除的正是设置逻辑添加的同一个函数引用:

useEffect(() => {
  function handleResize() {
    setWidth(window.innerWidth);
  }
  window.addEventListener('resize', handleResize);
  return () => window.removeEventListener('resize', handleResize);
}, []);

计数器重复递增

// Before: ends at 2 in development
useEffect(() => {
  setCount((c) => c + 1);
}, []);

在开发环境中设置逻辑会执行两次,因此递增也会执行两次。没有任何清理函数能够“撤销递增”,这恰恰说明这个 Effect 根本就不应该存在。应该把递增操作放在触发它的事件发生的地方:

<button onClick={() => setCount((c) => c + 1)}>Add</button>

应该移除 StrictMode 或添加 useRef 守卫吗?

不应该。这两种做法都能让重复执行消失,但底层的 bug 依然存在。

关闭 StrictMode,无论是删除包裹组件,还是在 Next.js 配置中设置 reactStrictMode: false,都只是移除了这项检验,Effect 依然缺少清理逻辑。

useRef 守卫则是更具诱惑力的做法:

// Avoid: hides missing cleanup
const hasRun = useRef(false);

useEffect(() => {
  if (hasRun.current) return;
  hasRun.current = true;
  subscribe();
}, []);

这个守卫会在开发环境中跳过第二次设置,因此控制台看起来很干净。但用 useRef 标志跳过第二次执行并不能修复缺失的清理逻辑。它只是在开发环境中掩盖了问题,而在每一次真实的重新挂载中,泄漏依然存在。ref 属于单个组件实例。当生产环境中某个路由卸载后再次挂载时,新实例会获得一个新的 ref,因此 Effect 会再次执行。由于仍然没有清理逻辑,旧的订阅永远不会被移除。

在一个缺少 Effect 清理逻辑的应用的会话回放(session replay)中,这个 bug 可能表现为前进/后退导航后出现重复请求或过期数据。这与 StrictMode 在开发环境中暴露出的正是同一个 bug。

何时不需要 Effect

很多执行两次的 Effect 原本就不应该是 Effect。Effect 的用途是:因为组件显示在屏幕上,而需要与 React 外部的某些系统进行同步。你可能不需要 Effect 一文介绍了两大类不必要的 Effect。

由事件驱动的操作应该放在事件处理函数中。提交表单、点击“加入购物车”后显示提示消息(toast)、递增计数器,这些操作的发生是因为用户做了某件事,而不是因为组件完成了渲染。

可以通过 props 或 state 计算得出的值,应该在渲染过程中计算:

// Before: extra state, extra render, effect runs twice
const [fullName, setFullName] = useState('');
useEffect(() => {
  setFullName(first + ' ' + last);
}, [first, last]);

// After: computed during render
const fullName = first + ' ' + last;

快速检查:你的 Effect 正确吗?

要检查一个 useEffect 是否正确,可以在设置逻辑和清理函数中分别输出日志:

useEffect(() => {
  console.log('setup');
  return () => console.log('cleanup');
}, []);

在开发环境中,控制台应当显示:

setup
cleanup
setup

生产构建只会输出一次 setup。如果你的日志显示为 setup、cleanup、setup,并且 UI 最终状态正确,那么这个 Effect 就在按预期工作,无需任何修复。

结论

在开发环境中执行两次的 useEffect,是 StrictMode 在检验你的清理逻辑是否有效。不要让它静默。逐一检查每个执行两次的 Effect,让它通过这项检验:中止或忽略过期请求,移除你添加的内容,并将事件驱动的逻辑和派生值彻底移出 Effect。在 Effect 能够正确清理之后,再添加 React 错误边界(Error Boundaries),这样当某个组件在渲染时抛出错误,也不会导致整个页面崩溃。

常见问题

在关闭了 StrictMode 的生产环境中,为什么我的 useEffect 还是执行了两次?

在生产环境中,当某个依赖项的值发生变化或组件重新挂载时,Effect 会再次执行。只要任何依赖项与上一次渲染时不同,Effect 就会重新执行,因此在渲染过程中创建的对象和函数每次都会被视为新值。修改 key 属性,或者通过条件渲染移除并重新添加组件,也会导致组件重新挂载,从而再次执行设置逻辑。

我应该阻止分析(analytics)事件在开发环境中触发两次吗?

不应该。React 官方文档建议将页面访问分析调用保留在其 Effect 中。用户无法分辨它执行了一次还是两次,而生产构建中每次访问只会发送一次。况且,你的开发机器本就不应该向生产环境的指标系统发送事件。如果需要调试这些事件,可以在以生产模式运行的预发布(staging)构建上测试,或者暂时关闭 StrictMode。

在 StrictMode 下,useLayoutEffect 也会执行两次吗?

会。React 的 useLayoutEffect 参考文档描述了与 useEffect 相同的开发环境行为:开启 StrictMode 后,React 会先执行一轮设置和清理,然后再执行真正的设置。Layout Effect 同样需要与之对应的清理逻辑,例如断开观察器(observer)的连接或销毁第三方组件实例。改用 useLayoutEffect 改变的是 Effect 相对于浏览器绘制的执行时机,而不会改变它在开发环境中的执行次数。

我能只对某一个组件关闭 StrictMode,而在应用的其他部分保留它吗?

不能。一旦某棵组件树被 StrictMode 包裹,其中的每个组件都会接受检查,任何单个组件都无法选择退出。你可以做的是将 StrictMode 包裹组件下移,使其只覆盖应用的部分区域。如果 StrictMode 没有包裹根组件,React 会在首次挂载时省略额外的 Effect 执行。否则,这次额外执行会导致子组件的 Effect 触发两次,而父组件的 Effect 只触发一次,这种情况在生产环境中是不可能发生的。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.