全面解答React严格模式下useEffect执行两次的原因、设计哲学与常见反模式,并提供事件监听、网络请求、第三方库初始化的标准应对方案与排查清单。
useEffect在StrictMode下执行两次是bug吗?怎么正确应对?
时间:2026-11-13
浏览:648
文章目录
一、直击本质:为什么 React 刻意调用两次?
在 React 18 之前,许多开发者习惯将 useEffect(..., []) 视为类似于类组件中 componentDidMount 的替代品,认为它代表“页面刚加载好时执行一次的代码”。这种心智模型在简单的单页面应用中往往能够勉强运行,但在并发渲染、路由快速切换以及带有缓存特性的现代前端架构中,经常暴露出隐蔽的内存泄漏与状态不同步问题。
从 React 18 开始,React 团队为未来的核心特性(如组件休眠保留状态并随时唤醒的并发机制)做准备。为了确保一个组件即使在屏幕上被反复挂载、销毁再重新挂载,依然能保持状态完好无损,React 在开发环境(Development Mode)下的 <React.StrictMode> 中引入了这一强制性校验行为:
- 步骤 1(首次挂载):React 实例化组件,并执行
useEffect的副作用创建逻辑(setup 函数)。 - 步骤 2(模拟卸载):React 立即调用上一步返回的清理函数(cleanup 函数),模拟组件瞬间被销毁。
- 步骤 3(重新挂载):React 重新执行副作用创建逻辑(再次调用 setup 函数),但保持组件既有的 state 与 DOM 状态。
需要特别强调的是:这一双重执行流程只发生在开发环境下。当项目通过 npm run build 打包为生产环境(Production)代码后,StrictMode 的模拟卸载与重挂载逻辑将自动被剥离,useEffect 会恢复为标准的单次执行。
如果你的代码在开发环境下因为执行了两次而出现接口重复提交、数据翻倍累加、弹窗连续弹出或页面报错,这恰恰证明当前的副作用函数缺少了必要的反向清理逻辑。如果直接带病部署到生产环境,一旦用户在页面间快速跳转或遭遇网络延迟,同样会引发严重的竞态 bug。
二、必须避免的常见错误应对方案(反模式)
当第一次在控制台看到 console.log 打出两遍、接口发了两次请求时,很多开发者会尝试“压制”第二次执行。以下三种做法非常普遍,但都违背了框架的设计初衷,属于需要坚决避免的反模式:
1. 使用 useRef 设置标志位强行拦截
常见写法是定义一个 const hasMounted = useRef(false),在 Effect 内部判断只有当 !hasMounted.current 时才执行逻辑,随后将其置为 true:
// ❌ 错误示范:使用 useRef 阻断第二次执行
function UserProfile({ userId }) {
const isFirstRender = useRef(true);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
fetchUserData(userId); // 第二次重挂载被强行跳过!
}
}, [userId]);
}
为什么不能这样写?当 React 模拟重挂载时,第一次挂载创建的资源已经在步骤 2 中被清理,而步骤 3 却因为 isFirstRender.current 为 false 被直接跳过,导致组件处于“未连接”的残缺状态。在快速热更新(Fast Refresh)或真正的组件复用场景中,该组件将无法正确响应后续变动。
2. 直接移除根组件的 <React.StrictMode>
在 main.jsx 或 index.js 中直接删除包裹的 <React.StrictMode> 标签,控制台的两次执行确实会立刻消失。但这属于典型的“掩耳盗铃”。严格模式不仅检测副作用清理,还会提醒过时的 API、废弃的生命周期以及并发渲染中的不安全副作用。关闭严格模式等于主动关闭了项目的安全防护网。
3. 滥用 useEffect 处理常规用户交互
部分开发者习惯把用户点击按钮后应触发的操作(如“点击购买”、“提交表单”),通过更新某个状态,然后在 useEffect 中监听该状态并触发网络请求。这会导致原本只应发生一次的用户操作与组件的生命周期绑在了一起,从而在严格模式重挂载时错误地重复触发。
三、四种典型场景下的标准应对方案
解决“执行两次”问题的唯一正确原则是:让副作用具备对称性与可逆性(Reversible)。即无论 setup 执行了多少次,只要配对的 cleanup 能够精确销毁 setup 引入的外部变更,多次执行就不会产生任何破坏性后果。
场景 1:原生事件监听与全局订阅
问题表现:监听器被注册两次,触发一次操作会执行两遍回调。
正确做法:在返回的 cleanup 函数中调用与 addEventListener 完全对称的 removeEventListener。
// ✔ 标准解法:对称添加与解绑监听器
useEffect(() => {
function handleResize() {
console.log("Window resized:", window.innerWidth);
}
// 1. Setup 逻辑:建立监听
window.addEventListener("resize", handleResize);
// 2. Cleanup 逻辑:严格对称移除
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
在严格模式下,流程为:添加监听 → 移除监听 → 再次添加监听。最终驻留在浏览器内存中的监听器恰好只有一份,完美复原。
场景 2:数据请求与网络 Fetch(消除竞态)
问题表现:组件挂载时发出两个相同的 HTTP 请求。如果网络存在波动,先发出的请求可能后返回,从而导致旧数据覆盖新数据的竞态问题(Race Condition)。
正确做法 A(现代浏览器标准):利用 AbortController 取消过期请求
// ✔ 标准解法 A:使用 AbortController
useEffect(() => {
const controller = new AbortController();
async function loadData() {
try {
const response = await fetch(`/api/user/${userId}`, {
signal: controller.signal // 将请求与信号源关联
});
const data = await response.json();
setUser(data);
} catch (error) {
if (error.name !== "AbortError") {
// 仅捕获真正的网络异常,忽略手动取消的异常
console.error("Fetch failed:", error);
}
}
}
loadData();
return () => {
// 模拟卸载时立即取消上一次尚未完成的请求
controller.abort();
};
}, [userId]);
正确做法 B(通用标志位):设置 ignore 标记跳过废弃状态设置
// ✔ 标准解法 B:使用局部 ignore 变量
useEffect(() => {
let ignore = false;
async function startFetching() {
const result = await api.queryDetails(itemId);
if (!ignore) {
// 如果组件已经经历卸载,则丢弃响应,不写入 state
setData(result);
}
}
startFetching();
return () => {
ignore = true; // 卸载或依赖变化时标记为失效
};
}, [itemId]);
场景 3:第三方插件与 DOM 节点初始化(图表、富文本、地图)
问题表现:图表库(如 ECharts)或富文本编辑器在同一个 DOM 节点内被初始化了两次,导致图表重复渲染、DOM 结构重叠或画布黑屏。
正确做法:在 cleanup 函数中调用第三方库提供的 destroy() 或 dispose() 实例方法,确保画布彻底销毁后再重新创建。
// ✔ 标准解法:妥善销毁第三方库实例
useEffect(() => {
if (!containerRef.current) return;
// 1. 初始化实例
const chartInstance = echarts.init(containerRef.current);
chartInstance.setOption(options);
// 2. 清理阶段彻底释放内存与 DOM 节点引用
return () => {
chartInstance.dispose();
};
}, [options]);
场景 4:不需要使用 useEffect 的纯计算逻辑
问题表现:根据已有 props 计算派生数据,或者在组件初次挂载时将某些状态做联动同步,放进 useEffect 后触发两次无谓的更新与重渲染。
正确做法:直接在组件主体渲染期间计算,或使用 useMemo 缓存高开销计算,彻底移除 useEffect。
// ❌ 错误做法:使用 Effect 同步派生状态
function Cart({ items }) {
const [total, setTotal] = useState(0);
useEffect(() => {
setTotal(items.reduce((sum, item) => sum + item.price, 0));
}, [items]);
// ...
}
// ✔ 正确做法:直接在渲染中计算
function Cart({ items }) {
// 渲染期间直接计算,无须触发额外重渲染,也不存在执行两次的问题
const total = items.reduce((sum, item) => sum + item.price, 0);
// ...
}
四、系统排查与改造流程(4 步检查清单)
在日常开发中,每当你编写或重构一个 useEffect 时,可以按照以下标准步骤进行自检,确保代码在 StrictMode 乃至生产并发环境下完全稳定:
- 第一步:审查触发源。 问自己:这段逻辑是由“组件展现于屏幕(外部系统同步)”引起的,还是由“用户的具体交互动作(如点击提交)”引起的?如果是后者,果断将代码移至事件处理函数(onClick、onSubmit 等)中。
-
第二步:检查清理对称性。
凡是在 setup 中产生了外部可观测变化的操作(如
setInterval、addEventListener、创建 WebSocket 握手),必须在 cleanup 中编写完全对等的销毁操作(如clearInterval、removeEventListener、socket.close())。 -
第三步:治理异步与网络状态。
涉及异步请求时,检查返回时是否考虑了组件已被卸载的情况。使用
AbortController取消请求,或者在闭包中使用let ignore = false阻止旧数据写入当前组件。 - 第四步:工程化验证。 在开发环境下刷新页面,观察控制台与网络面板。即使看到两次请求与清理,界面应无任何闪烁、数据错乱或报错。确认通过后,在生产构建包中再次验证行为的一致性。
五、心智模型总结:从生命周期到外部系统同步
React 官方在文档《你可能不需要 Effect》(You Might Not Need an Effect)中明确指出了现代 React 的设计核心:组件的渲染应该是纯粹的,而 Effect 的唯一正当用途是将 React 组件与外部系统保持同步。
外部系统包括:原生 DOM API、浏览器窗口事件、网络连接、定时器、第三方非 React 库等。一旦确立了“同步”的心智模型,你就会自然地意识到:同步从来不是“单向触发一次”,而是包含了“开始同步”和“断开同步”两个不可分割的方面。
| 对比维度 | 旧的理解(易出 bug) | 官方推荐心智(健壮可靠) |
|---|---|---|
| 核心定位 | 当做类组件的 componentDidMount 使用 | 用来与非 React 外部系统建立状态同步 |
| 执行预期 | 认为必须且只能在初始化时执行 1 次 | 随时能够安全地经历“建立连接 - 断开连接” |
| 面对两次执行 | 试图用 useRef 阻止或直接关掉严格模式 | 补齐 cleanup 逻辑,使副作用具备幂等性与可逆性 |
| 数据请求手段 | 纯手写裸 fetch,无取消与去重能力 | 结合 AbortController,或采用 TanStack Query / SWR 等成熟方案 |
综上所述,当你在严格模式下看到 useEffect 执行了两次时,不必感到焦虑,更不要试图“扼杀”第二次执行。把它当做 React 为你免费提供的自动化代码质量体检——耐心检查并补齐对应的 cleanup 函数,让组件的生命周期经得起任何断开与重连的考验。
实用工具推荐
绿化、压缩、噪点和虚拟水印,自由调节做旧程度。
选择画质和输出格式,预览压缩前后的大小。