React 18挂载时Effect执行两次,页面进出逻辑实现遇阻
解决React异步Effect的竞态问题
原问题代码
JavaScript
const { useEffect } = React; const items = []; const addItem = (item) => { setTimeout(() => { console.log('Adding item', item); if (items.includes(item)) { console.log('Item is already added'); return; } items.push(item); console.log('Items changed', items); }, 0); }; const removeItem = (item) => { setTimeout(() => { console.log('Removing item', item); const index = items.indexOf(item); if (index === -1) { console.log('Nothing to remove'); return; } items.splice(index, 1); console.log('Items changed', items); }, 500); }; const App = () => { useEffect(() => { console.log('Run effect!'); addItem(1); return () => { console.log('Run cleanup'); removeItem(1); }; }, []); return <div>see console</div>; } ReactDOM.createRoot(document.getElementById('root')).render( <React.StrictMode> <App /> </React.StrictMode> );
HTML
<script src="https://unpkg.com/react@18/umd/react.development.js"></script> <script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script> <div id="root"></div>
问题分析
这段代码要实现「进入页面添加item,离开页面移除item」的逻辑,但异步操作竞态+全局状态共享导致两个核心问题:
- React 18开发模式重复执行:Strict Mode会强制Effect执行两次(挂载→卸载→重新挂载),由于
removeItem的异步延迟更长,第一次cleanup的移除操作会在第二次挂载的添加操作完成后执行,最终把刚添加的item删掉,得到空数组。 - HMR热更新冲突:修改代码触发热更新时,组件先卸载执行cleanup,再重新挂载执行Effect。同样因为移除操作延迟更长,会把新添加的item误删,最终数组为空。
修复方案
方案1:用标志位绑定异步操作与生命周期
通过在Effect内部维护取消标志,确保只有当前挂载状态下的异步操作才会生效:
const { useEffect } = React; const items = []; const addItem = (item) => { return new Promise((resolve) => { setTimeout(() => { console.log('Adding item', item); if (items.includes(item)) { console.log('Item is already added'); resolve(false); return; } items.push(item); console.log('Items changed', items); resolve(true); }, 0); }); }; const removeItem = (item) => { return new Promise((resolve) => { setTimeout(() => { console.log('Removing item', item); const index = items.indexOf(item); if (index === -1) { console.log('Nothing to remove'); resolve(false); return; } items.splice(index, 1); console.log('Items changed', items); resolve(true); }, 500); }); }; const App = () => { useEffect(() => { let isCurrentEffect = true; console.log('Run effect!'); const handleAdd = async () => { if (!isCurrentEffect) return; await addItem(1); }; handleAdd(); return () => { console.log('Run cleanup'); isCurrentEffect = false; const handleRemove = async () => { await removeItem(1); }; handleRemove(); }; }, []); return <div>see console</div>; } ReactDOM.createRoot(document.getElementById('root')).render( <React.StrictMode> <App /> </React.StrictMode> );
方案2:避免全局共享状态(真实场景推荐)
全局数组items是冲突根源,真实场景应将状态放在组件内部或用状态管理工具,确保每个组件实例状态独立:
const { useEffect, useState } = React; const addItemApi = (item) => { return new Promise((resolve) => { setTimeout(() => { console.log('Adding item via API', item); resolve(item); }, 0); }); }; const removeItemApi = (item) => { return new Promise((resolve) => { setTimeout(() => { console.log('Removing item via API', item); resolve(true); }, 500); }); }; const App = () => { const [items, setItems] = useState([]); useEffect(() => { let isMounted = true; const handleAdd = async () => { const item = await addItemApi(1); if (isMounted) { setItems(prev => { if (prev.includes(item)) return prev; return [...prev, item]; }); } }; handleAdd(); return () => { isMounted = false; const handleRemove = async () => { await removeItemApi(1); setItems(prev => prev.filter(i => i !== 1)); }; handleRemove(); }; }, []); return <div>Items: {JSON.stringify(items)}</div>; } ReactDOM.createRoot(document.getElementById('root')).render( <React.StrictMode> <App /> </React.StrictMode> );
方案3:用AbortController取消异步请求(真实API场景)
如果使用fetch或支持AbortController的API,直接取消未完成的请求是最可靠的方式:
const { useEffect, useState } = React; const App = () => { const [items, setItems] = useState([]); useEffect(() => { const controller = new AbortController(); const signal = controller.signal; const addItem = async () => { try { const res = await fetch('/api/add-item/1', { signal }); const data = await res.json(); setItems(prev => [...prev, data.item]); } catch (err) { if (err.name !== 'AbortError') { console.error('Add item failed:', err); } } }; addItem(); return () => { controller.abort(); const removeItem = async () => { try { await fetch('/api/remove-item/1'); setItems(prev => prev.filter(i => i !== 1)); } catch (err) { console.error('Remove item failed:', err); } }; removeItem(); }; }, []); return <div>Items: {JSON.stringify(items)}</div>; }
关键总结
- 避免全局共享状态:全局状态容易引发跨组件实例冲突,优先使用组件内部状态或状态管理工具。
- 绑定异步操作与生命周期:用标志位或AbortController确保异步操作仅在组件挂载状态下生效,避免已卸载操作修改数据。
- 适配Strict Mode:开发模式的重复执行是为了检测副作用泄漏,修复时要确保cleanup能正确清理,同时不影响正常挂载逻辑。
内容的提问来源于stack exchange,提问作者Maxim Anisimov
相关产品推荐
相关产品推荐

