likes
comments
collection
share

一次「内存泄漏」问题排查背景 electron多窗口功能开发时,某些场景出现了明显的内存泄漏问题。用户的每次操作都会使j

作者站长头像
站长
· 阅读数 44

背景

electron多窗口功能开发时,某些场景出现了明显的内存泄漏问题。用户的每次操作都会使js内存稳定上涨5M。

一次「内存泄漏」问题排查背景 electron多窗口功能开发时,某些场景出现了明显的内存泄漏问题。用户的每次操作都会使j

复现路径:用户每次点击「音量调节」会打开独立窗进行操作,关闭独立窗之后,内存并未回落。

问题排查

定位内存泄漏代码

这里简单讲一下投屏的打开新窗口的实现方案。 需求背景,在控制台窗口外部要展示一些外挂的组件。

技术方案:

  1. 使用window.open打开一个窗口,通过electron的能力,关联到父窗口(投屏控制台)上。
  2. window.open的返回值是新窗口的window实例,通过给这个返回值挂载变量的方式,实现数据共享及通信

基于背景中提到的现象,可以确认是窗口关闭时,部分变量未正确释放导致。

而浏览器窗口关闭整个进程的内存都会被释放掉,照理说不存在泄漏问题。

所以内存泄漏问题,只能存在于「变量共享」

export const openWindow = () => {
    const win = window.open('xxx', '_blank', 'datas...');
    connectData(win);
}
export const connectData = (win: Window) => {
  win[WindowKey.commonStore] = getShareStore(); // redux store
  win[WindowKey.lang] = i18n.language; // 语言
  win[WindowKey.socket] = getMeetingDataProvider(); // socket
  win[WindowKey.globalData] = globalData; // 全局缓存对象
  win[WindowKey.bus] = Bus; // 通信
};

从使用的方式排查

  • redux store -> 使用三方库,内部处理不可控,可能存在使用不当导致泄漏问题
  • language -> string 类型即使不释放,也不会导致5m的内存泄漏问题
  • socket -> 自己封装的socket实例,通过代码排查,新窗口的操作不存在写入新数据问题
  • globalData -> 全局缓存对象,共享窗口中「只读」
  • bus -> 通信,所有使用的地方都调用了off。

通过简单的排查,代码中仅剩下redux-store的使用,存在不可控的地方,其他共享的数据,经排查都不存在内存泄漏的问题。

redux内存泄漏排查

关于redux我们使用了reduxreact-redux两个仓库,首先我们注释掉了连接redux的方法,useSelectoruseDispatch。随后试验后,发现内存依旧存在泄漏问题

一次「内存泄漏」问题排查背景 electron多窗口功能开发时,某些场景出现了明显的内存泄漏问题。用户的每次操作都会使j


随后,我们把react-reduxProvider注释掉后,内存回归正常。

一次「内存泄漏」问题排查背景 electron多窗口功能开发时,某些场景出现了明显的内存泄漏问题。用户的每次操作都会使j


推测Provider实现存在问题,卸载时没有正确的取消监听

然而由于共享store的原因,意味着被注册进store的监听器,拽着他连带的react节点,一并被保存了下来。如下图

一次「内存泄漏」问题排查背景 electron多窗口功能开发时,某些场景出现了明显的内存泄漏问题。用户的每次操作都会使j

弃用react-redux,解决内存泄漏

浅层原因很明显,使用了react-reduxProvider注销时未触发unSubscribe导致订阅的函数被缓存。自己封装一个store的订阅监听就好了,自己在页面unmount的时候,调用unSubscribe就好了。


共享store的内容,二次封装,主要目的是缓存unSubscribe,其余功能和store一致

// 共享的store
 export const getShareStore = () => {
   let storePrev = store.getState();
   let returnStroe = { ...storePrev };
   let unSubScribe = new Map();
   const setStore = () => {
     storePrev = store.getState();
     returnStroe = { ...storePrev };
   };
   return {
     subscribe: (fn: any) => {
       unSubScribe.set(store.subscribe(fn), 1);
       setStore();
     },
     getState: () => {
       const storeNext = store.getState();
       if (storePrev === storeNext) {
         return returnStroe;
       }
       setStore();
       return returnStroe;
     },
     dispatch: (v: any) => {
       return store.dispatch(v);
     },
     unSubScribe: () => {
       unSubScribe.forEach((_, key) => key());
     },
   };
 }
 

连接store时,拿到win实例,在beforeunload时,调用unSubscribe注销监听

// 连接store
export const connectData = (win: any) => {
  win[WindowKey.commonStore] = getShareStore();
  // ...
  const load = () => {
    win.removeEventListener('load', load);
    const beforeunload = () => {
      win[WindowKey.commonStore].unSubScribe();
      win.removeEventListener('beforeunload', beforeunload);
    };
    win.addEventListener('beforeunload', beforeunload);
  };
  win.addEventListener('load', load);
};
};

hooks

// hooks封装
 const storeIns = getStore();
 export const useMyAppSelector: TypedUseSelectorHook<RootState> = (fn) => {
   // const [value, setValue] = useState<ReturnType<typeof storeIns.getState>>();
   // useEffect(() => {
   //   const unSubScribe = storeIns.subscribe(() => {
   //     setValue(storeIns.getState());
   //   });
   //   setValue(storeIns.getState());
   //   return () => unSubScribe?.();
   // }, []);
   const value = useSyncExternalStore(storeIns.subscribe, storeIns.getState);
   return useMemo(() => fn(value), [fn, value]);
 };
 export const useMyAppDispatch = () => {
   return getStore().dispatch;
 };

内存泄漏根因排查

上述封装只是为了快速解决问题,不影响敏捷下的快速迭代。

然而问题的根本原因还是需要探究的。

项目中使用的react-redux版本为v8.1.3

由于推测react-redux存在内存泄漏问题,所以这里浅读了下react-reduxreact的源码。

源码的阅读后续会单独发一篇文章~


先简单介绍下react-redux的工作流

一次「内存泄漏」问题排查背景 electron多窗口功能开发时,某些场景出现了明显的内存泄漏问题。用户的每次操作都会使j

react-redux 全局只会调用一次store.subscribe

在初始化时执行两个操作

  1. store.subscribe 订阅store
  2. createListenerCollection 创建自己的订阅监听实例

推测1 -> Provider组件中注销事件存在异常

从代码的角度上检测Provider组件是否存在卸载不完全问题。


Provider组件,初始化时,调用useEffect/useLayoutEffect,并且在组件注销时调用了unSubscribe

一次「内存泄漏」问题排查背景 electron多窗口功能开发时,某些场景出现了明显的内存泄漏问题。用户的每次操作都会使j


useSelector会触发注册事件,给store注入的注册是去重的,仅会触发一次。实际上每次useSelector的注册事件,都会注册到私有listeners的队列中。

一次「内存泄漏」问题排查背景 electron多窗口功能开发时,某些场景出现了明显的内存泄漏问题。用户的每次操作都会使j


所以通过对源码的分析后,provider的代码也遵循着,init时注册,destroy时取消。从代码上并未见内存泄漏的问题,但内存泄漏确实因为增加这个组件而产生。

推测2 -> useSyncExternalStore存在未卸载问题

react-redux中的useSelector使用了useSyncExternalStorehook。

export function createSelectorHook(context = ReactReduxContext): UseSelector {
  ...
  return function useSelector(
    selector,
    equalityFnOrOptions
  ) {
    ...

    const selectedState = useSyncExternalStoreWithSelector(
      subscription.addNestedSub,
      store.getState,
      getServerState || store.getState,
      wrappedSelector,
      equalityFn
    )

    return selectedState
  }
}
export const useSelector = /*#__PURE__*/ createSelectorHook()

根据react官方的描述useSyncExternalStore猜测这个hooks只能在顶层使用,且不会被注销。

# useSyncExternalStore - react文档

useSyncExternalStore的源码中,初始化时会调用mountEffect,更新时会调用updateEffect,实际实现中是有注销逻辑的。

function mountSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) {
  var fiber = currentlyRenderingFiber$1;
  var hook = mountWorkInProgressHook();
  var nextSnapshot = getSnapshot();
  ...
  mountEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [subscribe]); // Schedule an effect to update the mutable instance fields. We will update
  pushEffect(HasEffect | Passive$1, updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot), undefined, null);
  return nextSnapshot;
}


function updateSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) {
  var fiber = currentlyRenderingFiber$1;
  var hook = updateWorkInProgressHook(); // Read the current snapshot from the store on every render. This breaks the
  var nextSnapshot = getSnapshot();

  var inst = hook.queue;
  updateEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [subscribe]); // Whenever getSnapshot or subscribe changes, we need to check in the
 
  if (inst.getSnapshot !== getSnapshot || snapshotChanged || // Check if the susbcribe function changed. We can save some memory by
  // checking whether we scheduled a subscription effect above.
  workInProgressHook !== null && workInProgressHook.memoizedState.tag & HasEffect) {
    fiber.flags |= Passive;
    pushEffect(HasEffect | Passive$1, updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot), undefined, null); // Unless we're rendering a blocking lane, schedule a consistency check.
  }

  return nextSnapshot;
}
function subscribeToStore(fiber, inst, subscribe) {
    var handleStoreChange = function () {
        // The store changed. Check if the snapshot changed since the last time we
        // read from the store.
        if (checkIfSnapshotChanged(inst)) {
          // Force a re-render.
          forceStoreRerender(fiber);
        }
      }; // Subscribe to the store and return a clean-up function.
    return subscribe(handleStoreChange)
}

所以实际上useSyncExternalStorehook实现的时候,有调用useEffect的实现,也就意味着hook本身也不存在内存泄漏问题。

问题根因

依赖的三方库都不存在“组件注销时未卸载”的问题,而卸载这件事又实实在在的没发生。

所以在modal浏览器窗口关闭时,并未触发react的unmount。

浏览器关闭 != unmount

结果查了下代码,新窗口关闭时确实没调用unmount。

一次「内存泄漏」问题排查背景 electron多窗口功能开发时,某些场景出现了明显的内存泄漏问题。用户的每次操作都会使j

加上代码,测试,泄漏问题解决!

结语

有些问题真的很蠢很简单,然而排查起来异常费劲,有时候就差那么一点。这个问题排查了一周,最终检查出来是这3句话就能解决问题的时候,我是真滴崩溃=。=

祝大家以后不会遇到内存泄漏的问题~

转载自:https://juejin.cn/post/7412505356762660898
评论
请登录