一次「内存泄漏」问题排查背景 electron多窗口功能开发时,某些场景出现了明显的内存泄漏问题。用户的每次操作都会使j
背景
electron多窗口功能开发时,某些场景出现了明显的内存泄漏问题。用户的每次操作都会使js内存稳定上涨5M。
复现路径:用户每次点击「音量调节」会打开独立窗进行操作,关闭独立窗之后,内存并未回落。
问题排查
定位内存泄漏代码
这里简单讲一下投屏的打开新窗口的实现方案。 需求背景,在控制台窗口外部要展示一些外挂的组件。
技术方案:
- 使用window.open打开一个窗口,通过electron的能力,关联到父窗口(投屏控制台)上。
- 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
我们使用了redux
和react-redux
两个仓库,首先我们注释掉了连接redux
的方法,useSelector
和useDispatch
。随后试验后,发现内存依旧存在泄漏问题
随后,我们把react-redux
的Provider
注释掉后,内存回归正常。
推测Provider实现存在问题,卸载时没有正确的取消监听
然而由于共享store的原因,意味着被注册进store的监听器,拽着他连带的react节点,一并被保存了下来。如下图
弃用react-redux,解决内存泄漏
浅层原因很明显,使用了react-redux
的Provider
注销时未触发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-redux
和react
的源码。
源码的阅读后续会单独发一篇文章~
先简单介绍下react-redux
的工作流
react-redux
全局只会调用一次store.subscribe
。
在初始化时执行两个操作
store.subscribe
订阅storecreateListenerCollection
创建自己的订阅监听实例
推测1 -> Provider组件中注销事件存在异常
从代码的角度上检测Provider组件是否存在卸载不完全问题。
Provider组件,初始化时,调用useEffect/useLayoutEffect
,并且在组件注销时调用了unSubscribe
useSelector会触发注册事件,给store注入的注册是去重的,仅会触发一次。实际上每次useSelector的注册事件,都会注册到私有listeners的队列中。
所以通过对源码的分析后,provider的代码也遵循着,init时注册,destroy时取消。从代码上并未见内存泄漏的问题,但内存泄漏确实因为增加这个组件而产生。
推测2 -> useSyncExternalStore
存在未卸载问题
react-redux
中的useSelector
使用了useSyncExternalStore
hook。
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)
}
所以实际上useSyncExternalStore
hook实现的时候,有调用useEffect
的实现,也就意味着hook本身也不存在内存泄漏问题。
问题根因
依赖的三方库都不存在“组件注销时未卸载”的问题,而卸载这件事又实实在在的没发生。
所以在modal浏览器窗口关闭时,并未触发react的unmount。
浏览器关闭 != unmount
结果查了下代码,新窗口关闭时确实没调用unmount。
加上代码,测试,泄漏问题解决!
结语
有些问题真的很蠢很简单,然而排查起来异常费劲,有时候就差那么一点。这个问题排查了一周,最终检查出来是这3句话就能解决问题的时候,我是真滴崩溃=。=
祝大家以后不会遇到内存泄漏的问题~
转载自:https://juejin.cn/post/7412505356762660898