症状
控制台用三行讲完故事:subscribe、cleanup、subscribe——这正是 StrictMode 在履行职责:挂载、卸载、再挂载,让缺失的清理当场现形。React 18 开发环境下这是刻意行为,生产不受影响;“开发重复、生产正常”的不对称正是它的指纹。对每个被重复执行的 Effect,诊断问题只有一个:我的清理是否完整撤销了设置?缺 AbortController、没清的 interval、无补偿逻辑的统计上报,都会在这里最先暴露。
诊断思路
先在 Effect 与 cleanup 里各打一行日志确认“设置-清理-设置”序列;再审计所有 useEffect 的清理对称性——有副作用的 Effect 必须有完全撤销它的 return,没有 return 的就是重复候选;最后按幂等性分类副作用:统计事件、支付意图这类非幂等操作要移到事件处理器按逻辑动作触发,而不是随 Effect 执行。
根因
React 18 StrictMode 在开发环境对每个组件做一次重挂载,以验证 Effect 能自我清理。这是有意设计:健壮的应用应能在挂载-卸载-挂载循环中幸存,双重执行暴露的正是清理没有完全逆转设置的 Effect。
修复与验证
把对称当铁律:设置做什么,清理就撤销什么——订阅配退订、定时器配清除、fetch 配 abort(并单独捕获 AbortError 不让它污染错误监控)。非幂等动作归属用户意图:checkout 点击时 track 一次,而不是挂载时。验证时保持 StrictMode 开启:监听器计数稳定无重复订阅、网络面板显示一条 in-flight 被中止后再一条完成、统计按逻辑动作恰好一次。关掉 StrictMode 只是让症状静音——路由切换与 Suspense 重挂载在生产会触发同样的 bug。
代码示例
subscribe <- effect body 1
cleanup <- StrictMode unmount
subscribe <- effect body 2
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then(r => r.json())
.then(setData)
.catch(e => { if (e.name !== 'AbortError') setError(e); });
return () => controller.abort(); // fully undoes the setup
}, [url]);
useEffect(() => {
const handler = (e) => setPos(e);
window.addEventListener('pointermove', handler);
return () => window.removeEventListener('pointermove', handler);
}, []);
function onCheckout() {
track('checkout_started', { cart }); // once per click, not per mount
createPaymentIntent(cart);
}