Skip to content

React useEffect 在开发环境下执行两次的深入解析 ​

在 React 18 中,当开启严格模式(Strict Mode)时,useEffect 在开发环境下会执行两次这个现象困扰了许多开发者。让我们一起深入理解这个特性,并掌握应对之道。

🔍 问题复现 ​

来看一个典型的例子:

typescript
import React, { useEffect, useState } from 'react';

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

  useEffect(() => {
    console.log('🚀 Effect executed');
    
    fetch('https://api.example.com/data')
      .then(response => response.json())
      .then(data => console.log(data));

    return () => {
      console.log('🧹 Cleanup executed');
    };
  }, []);

  return <div>{count}</div>;
}

控制台输出会是这样的:

sh
🚀 Effect executed
🧹 Cleanup executed
🚀 Effect executed

💡 为什么会这样? ​

React 18 的刻意设计 ​

  • ✨ 帮助发现并修复副作用中的 bug
  • 🔄 为即将到来的新特性做铺垫
  • ⚡️ 验证清理逻辑的正确性

具体执行流程 ​

  1. 首次挂载组件
  2. 执行 useEffect
  3. 立即卸载(触发清理)
  4. 再次挂载
  5. 再次执行 useEffect

⚠️ 可能受影响的场景 ​

typescript
// 1. 🌐 API 请求
useEffect(() => {
  fetch('/api/data');
}, []);

// 2. 📡 事件订阅
useEffect(() => {
  const subscription = someService.subscribe();
  return () => subscription.unsubscribe();
}, []);

// 3. 🖱️ DOM 事件
useEffect(() => {
  const handler = () => console.log('scroll');
  window.addEventListener('scroll', handler);
  return () => window.removeEventListener('scroll', handler);
}, []);

🛠️ 优雅的解决方案 ​

1. 使用 ref 标记(推荐) ​

typescript
function ExampleComponent() {
  const [data, setData] = useState(null);
  const effectRan = useRef(false);

  useEffect(() => {
    // 跳过第二次执行
    if (effectRan.current) return;

    const fetchData = async () => {
      const response = await fetch('https://api.example.com/data');
      const result = await response.json();
      setData(result);
      effectRan.current = true;
    };

    fetchData();
  }, []);

  return <div>{/* 渲染数据 */}</div>;
}

2. 使用 AbortController(适用于请求场景) ​

typescript
function ExampleComponent() {
  useEffect(() => {
    const controller = new AbortController();

    fetch('https://api.example.com/data', {
      signal: controller.signal
    }).catch(error => {
      if (error.name === 'AbortError') {
        console.log('🚫 请求已取消');
      }
    });

    return () => controller.abort();
  }, []);
}

3. 自定义 Hook(通用解决方案) ​

typescript
function useEffectOnce(effect: () => void | (() => void)) {
  const destroyFunc = useRef<void | (() => void)>();
  const effectCalled = useRef(false);
  const renderAfterCalled = useRef(false);

  if (effectCalled.current) {
    renderAfterCalled.current = true;
  }

  useEffect(() => {
    if (!effectCalled.current) {
      destroyFunc.current = effect();
      effectCalled.current = true;
    }

    return () => {
      if (!renderAfterCalled.current) return;
      destroyFunc.current?.();
    };
  }, []);
}

📝 最佳实践 ​

1. 环境区分 ​

typescript
if (process.env.NODE_ENV === 'development') {
  console.log('🔧 开发环境');
} else {
  console.log('🚀 生产环境');
}

2. 事件处理优化 ​

typescript
import { debounce } from 'lodash';

function ScrollComponent() {
  useEffect(() => {
    const handleScroll = debounce(() => {
      console.log('📜 滚动事件');
    }, 200);

    window.addEventListener('scroll', handleScroll);
    
    return () => {
      window.removeEventListener('scroll', handleScroll);
      handleScroll.cancel();
    };
  }, []);
}

💪 总结要点 ​

  1. ✅ 双重执行是 React 18 严格模式的特性,仅存在于开发环境
  2. 🎯 目的是帮助发现问题,而不是制造麻烦
  3. 🛡️ 建议采用以下方案处理:
    • 使用 ref 标记
    • AbortController 处理请求
    • 自定义 Hook 封装
  4. 📌 始终记得实现清理函数
  5. 🚀 生产环境不受影响,无需担心

🎁 补充提示 ​

  • 开发时使用 React.StrictMode 是好习惯
  • 确保异步操作有适当的错误处理
  • 考虑使用 React Query 等数据管理库来处理复杂的数据获取场景

通过正确理解和应对这个特性,我们可以写出更健壮的 React 应用。记住,这是 React 团队为了帮助我们提前发现问题而设计的