🐛 React StrictMode 下 useEffect 执行两次:不是 Bug,是特性

问题场景

开发环境刚把项目升级到 React 18,突然发现一个诡异现象:所有页面的接口请求都发了两遍

tsx 复制代码
// 一个再普通不过的组件
function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    console.log('fetch user', userId);
    fetch(`/api/users/${userId}`).then(res => res.json()).then(setUser);
  }, [userId]);

  return <div>{user?.name ?? 'loading...'}</div>;
}

控制台输出:

sql 复制代码
fetch user 123
fetch user 123

日志打印了两次、请求发了两遍,排查半天发现业务代码没任何问题------罪魁祸首是 main.tsx 里包着组件的 <StrictMode>

更让人困惑的是:上线到生产环境后,这个现象又消失了。为什么只在开发环境出现?这是 React 的 Bug 吗?

原因分析

1. StrictMode 是「开发期体检工具」,不是运行时功能

<StrictMode> 只在**开发模式(development)**下生效,生产构建(production)中它什么都不做。这正是"线上正常、本地异常"的原因。

它的设计初衷是帮你提前暴露组件中不纯的写法不安全的副作用 ,手段就是故意让组件渲染两次、让 Effect 挂载/卸载/再挂载

2. 双调用的具体机制(React 18+)

在 React 18 的 StrictMode 下,组件挂载时会经历:

复制代码
挂载 → 卸载(模拟)→ 重新挂载

对应的 Effect 生命周期是:

arduino 复制代码
setup 执行(第一次)→ cleanup 执行 → setup 再次执行(第二次)

所以 useEffect 里的请求发出两次、日志打印两次,是 React 故意模拟"组件被卸载再重挂"的场景,用来检查你的 cleanup 是否写得正确。

同理,以下内容在 StrictMode 下也会双调用:

  • 组件函数体本身(render 阶段)
  • useState / useReducer 的初始化函数(lazy initializer)
  • useMemo / useCallback 的计算函数
  • useInsertionEffect / useLayoutEffect

3. 为什么 React 要这么做?

它想逼你回答一个问题:如果组件被卸载了,你留下的"烂摊子"(定时器、订阅、全局监听)清理干净了吗?

如果你的 cleanup 写得正确,双调用不会产生任何 bug;如果 cleanup 漏写了,StrictMode 就会把这个隐患放大暴露出来------这正是它存在的意义。

解决方案

方案一:正确编写 cleanup(推荐,治本)

把"发起请求"和"取消请求"成对写好,让第二次 setup 能正确接管:

tsx 复制代码
function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    const controller = new AbortController();
    console.log('fetch user', userId);

    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then(res => res.json())
      .then(setUser)
      .catch(err => {
        // AbortError 是主动取消,不是真错误,直接忽略
        if (err.name !== 'AbortError') console.error(err);
      });

    // cleanup:卸载时取消请求,避免竞态和重复请求
    return () => controller.abort();
  }, [userId]);

  return <div>{user?.name ?? 'loading...'}</div>;
}

加了 AbortController 后,第一次 setup 的请求会被 cleanup 立刻 abort,实际只有第二次请求真正生效------网络面板里只会看到一个请求,日志也只剩一条。

同理处理定时器和事件监听:

tsx 复制代码
useEffect(() => {
  const timer = setInterval(() => setCount(c => c + 1), 1000);
  window.addEventListener('resize', onResize);
  return () => {
    clearInterval(timer);
    window.removeEventListener('resize', onResize);
  };
}, []);

方案二:用 ref 标记跳过首次重复(治标,慎用)

如果请求本身无法取消(比如第三方 SDK、埋点上报),可以用 ref 挡住第二次执行:

tsx 复制代码
const fetchedRef = useRef(false);

useEffect(() => {
  if (fetchedRef.current) return;
  fetchedRef.current = true;
  // 这里只会在 StrictMode 下执行一次
  trackEvent('page_view');
}, []);

⚠️ 注意:这只是"骗过" StrictMode 的检查,会掩盖 cleanup 缺失的问题,不建议无脑全局使用,只适合确实无法取消副作用的场景。

方案三:实在不想要 StrictMode,可以移除(下策)

tsx 复制代码
// main.tsx
createRoot(document.getElementById('root')!).render(
  // <StrictMode>  ← 注释掉
  <App />
);

强烈不建议。StrictMode 是免费的"代码体检",它帮你抓出的竞态、内存泄漏、不可预测的渲染问题,远比"请求多打一次"严重得多。生产环境它零开销,留着百利而无一害。

要点总结

  1. StrictMode 双调用只发生在开发环境,生产环境无任何影响,不是 Bug 而是刻意设计。
  2. 双调用 = 挂载 → 卸载 → 重挂载,用来检测 Effect 的 cleanup 是否完备。
  3. 正确姿势:给副作用写 cleanup(AbortController 取消请求、clearInterval、removeEventListener),这也是 React 官方推荐写法。
  4. 副作用无法取消时,可用 useRef 标记跳过第二次执行,但这是治标方案。
  5. 不要为了"看着干净"移除 StrictMode,它是发现竞态和内存泄漏的最佳工具。
  6. 排查"请求发两次"问题时,先看是否包了 StrictMode,再看是否用了 React.StrictMode 的旧版 Fast Refresh 叠加效应------新版 React DevTools 已能直观看到双调用标记。
相关推荐
做好一个小前端1 小时前
ECharts 折线图大数据量性能优化参考
前端·性能优化·echarts
渣波1 小时前
🧭 从 URL 到组件:深度剖析 React Router 原理与 SPA 路由架构艺术
前端
用户852495071841 小时前
一个 useRef,三种人生:从 DOM 引用到 Web Worker
前端
无人生还1 小时前
从 Vue3 到 React · 快速上手系列第 13 篇:工程化与综合实战(Vite + Todo 应用)
前端·vue.js·react.js
晴殇i1 小时前
用 TRAE Work 搞定客户项目介绍,开发再也不用头疼写汇报 PPT
前端·后端
Cache技术分享1 小时前
488. Java 反射 - 动态创建数组
前端·后端
Synmbrf1 小时前
ECharts 饼图鼠标悬浮后标签 title 消失的问题
前端·javascript
Canace1 小时前
用 AI 写了一天代码,为什么反而更累了?
前端·人工智能·ai编程
Vhen1 小时前
Taro 封装小程序toast提示组件
前端