You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.05 08:37:03