Mkdir700's Note

Mkdir700's Note

React严格模式下事件监听器失效

15
2025-03-18

问题描述

在基于 Tauri 和 React 的 UniClipboard 应用中,我遇到了一个与组件中事件监听器相关的问题。具体表现为:当应用启动后,控制台输出以下日志:

[Log] 开始加载剪贴板记录... (ClipboardContent.tsx, line 85)
[Log] 启动后端剪贴板新内容监听... (ClipboardContent.tsx, line 68)
[Log] 开始监听剪贴板新内容事件... (ClipboardContent.tsx, line 40)
[Log] 开始加载剪贴板记录... (ClipboardContent.tsx, line 85)
[Log] 启动后端剪贴板新内容监听... (ClipboardContent.tsx, line 68)
[Log] 开始监听剪贴板新内容事件... (ClipboardContent.tsx, line 40)
[Log] 后端剪贴板新内容监听已启动 (ClipboardContent.tsx, line 70, x2)
[Log] 取消监听剪贴板新内容事件 (ClipboardContent.tsx, line 49)
[Log] 停止后端剪贴板新内容监听... (ClipboardContent.tsx, line 77)
[Log] 后端剪贴板新内容监听已停止 (ClipboardContent.tsx, line 79)

从日志中可以看出,监听器在设置后很快就被取消了,这与我预期的行为不符。预期中,监听器应该在用户切换到其他页面之前一直保持活跃状态。

问题分析

经过代码审查,我发现问题的根源在于 [[React 的严格模式(StrictMode)]]。

main.tsx 文件中,应用被包裹在 <React.StrictMode> 标签内:

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
);

React 严格模式的特性

严格模式是 React 提供的一种开发工具,它能帮助开发者发现应用中的潜在问题。在开发环境中,严格模式会故意执行以下操作:

  1. 组件的双重渲染
    • 挂载组件
    • 卸载组件
    • 再次挂载组件
  2. 双重调用以下函数:
    • 类组件的 constructorrendershouldComponentUpdate 方法
    • 函数组件体
    • useStateuseMemouseReducer 的状态初始化函数
    • useEffect 钩子函数(这是我遇到问题的关键)

这种双重调用的目的是帮助开发者发现和消除依赖外部状态的副作用,确保组件在不同环境下的行为一致性。

我的代码问题

ClipboardContent.tsx 组件中,我的监听器设置逻辑放在了 useEffect 钩子中:

useEffect(() => {
  // 先立即加载一次数据
  loadClipboardRecords();

  // 启动后端的新剪贴板内容监听
  startListenClipboardNewContent();

  // 监听剪贴板新内容事件
  const listenNewContent = async () => {
    // 设置监听的代码...
    return () => {
      // 清理函数,取消监听
      console.log("取消监听剪贴板新内容事件");
      unlisten();
      stopListenClipboardNewContent();
    };
  };

  const cleanupPromise = listenNewContent();

  return () => {
    cleanupPromise
      .then((cleanupFn) => cleanupFn())
      .catch((err) => {
        console.error("执行清理函数失败:", err);
      });
  };
}, []);

在严格模式下,这个 useEffect 会被执行两次:

  1. 第一次挂载时,设置监听器
  2. 模拟卸载时,调用清理函数,取消监听器
  3. 第二次挂载时,重新设置监听器

这就导致了我在日志中看到的监听器被设置和取消的现象。

解决方案尝试

尝试 1:使用 useRef 跟踪监听器状态

我的第一个尝试是使用 useRef 钩子来跟踪监听器是否已经设置:

const listenerSetupRef = useRef(false);

useEffect(() => {
  loadClipboardRecords();

  if (!listenerSetupRef.current) {
    listenerSetupRef.current = true;
    // 设置监听器...
  
    return () => {
      // 清理函数
      unlisten();
      stopListenClipboardNewContent();
      listenerSetupRef.current = false;
    };
  } else {
    console.log("监听器已经设置,跳过重复设置");
    return () => {};
  }
}, []);

然而,这个方案失败了,因为严格模式在模拟卸载时会调用清理函数,重置 listenerSetupRef.current,导致第二次挂载时仍然设置了新的监听器,而第一次设置的监听器已被取消。

最终解决方案:全局监听器状态管理

最终,我采用了一个更彻底的方案——使用组件外部的全局变量来管理监听器状态:

// 全局监听器状态管理
interface ListenerState {
  isActive: boolean;
  unlisten?: () => void;
  cleanupPromise?: Promise<() => void>;
}

const globalListenerState: ListenerState = {
  isActive: false
};

const ClipboardContent: React.FC = () => {
  // ...

  useEffect(() => {
    loadClipboardRecords();

    const setupListener = async () => {
      if (!globalListenerState.isActive) {
        console.log("设置全局监听器...");
        globalListenerState.isActive = true;
      
        try {
          // 设置监听器...
          globalListenerState.unlisten = unlisten;
        } catch (err) {
          globalListenerState.isActive = false;
        }
      }
    };
  
    if (!globalListenerState.isActive) {
      setupListener();
    }
  
    return () => {
      // 关键点:不在组件卸载时取消监听
      console.log("组件卸载,但保持全局监听器活跃");
    };
  }, []);

  // ...
}

这个方案的关键点:

  1. 全局状态:使用组件外部的变量存储监听器状态,使其不受组件生命周期影响
  2. 保持监听器活跃:不在组件卸载时取消监听,而是让监听器持续存在
  3. 避免重复设置:第二次挂载时检测到监听器已存在,跳过设置过程

解决方案效果

实施此方案后,即使在 React 严格模式下,事件监听器也能保持持续活跃状态。日志显示:

[Log] 开始加载剪贴板记录...
[Log] 设置全局监听器...
[Log] 启动后端剪贴板新内容监听...
[Log] 开始监听剪贴板新内容事件...
[Log] 组件卸载,但保持全局监听器活跃
[Log] 开始加载剪贴板记录...
[Log] 全局监听器已存在,无需再次设置
[Log] 后端剪贴板新内容监听已启动

可以看到,不再有"取消监听"和"停止监听"的日志,监听器成功保持了活跃状态。

总结

  1. 了解 React 严格模式:严格模式下组件会经历双重挂载和卸载,这可能会影响含有副作用的代码。
  2. 状态管理的重要性:在处理跨渲染周期的资源时,应当谨慎选择状态存储位置。
  3. 全局状态的使用:对于需要持续存在的资源(如事件监听器),可以考虑使用组件外部的全局状态进行管理。
  4. 避免在清理函数中关闭永久资源:如果某个资源需要在整个应用生命周期内存在,不应将其清理逻辑放在组件的卸载函数中。

通过这个问题的解决过程,了解了 React 组件的生命周期、严格模式的工作原理以及事件监听器的正确管理方式,这些知识对于构建稳健的 React 应用至关重要。