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
- 🔄 为即将到来的新特性做铺垫
- ⚡️ 验证清理逻辑的正确性
具体执行流程
- 首次挂载组件
- 执行 useEffect
- 立即卸载(触发清理)
- 再次挂载
- 再次执行 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();
};
}, []);
}💪 总结要点
- ✅ 双重执行是 React 18 严格模式的特性,仅存在于开发环境
- 🎯 目的是帮助发现问题,而不是制造麻烦
- 🛡️ 建议采用以下方案处理:
- 使用 ref 标记
- AbortController 处理请求
- 自定义 Hook 封装
- 📌 始终记得实现清理函数
- 🚀 生产环境不受影响,无需担心
🎁 补充提示
- 开发时使用 React.StrictMode 是好习惯
- 确保异步操作有适当的错误处理
- 考虑使用 React Query 等数据管理库来处理复杂的数据获取场景
通过正确理解和应对这个特性,我们可以写出更健壮的 React 应用。记住,这是 React 团队为了帮助我们提前发现问题而设计的