Links
- https://learn.microsoft.com/zh-cn/microsoft-edge/devtools/landing/
- https://blog.csdn.net/weixin_63692030/article/details/130139493
- https://juejin.cn/post/7185128318235541563
- https://juejin.cn/post/7185501830040944698
内存优化工具
开发者工具的使用 https://learn.microsoft.com/zh-cn/microsoft-edge/devtools-guide-chromium/overview
主要通过浏览器的开发者工具审核内存情况,排查时需要注意一下细节
通过内存工具手动gc时,需要先清理控制台的内容(输出到控制台中的对象也属于引用了对象,会导致对象不会被gc)。 最好关掉开发者工具重开,然后再手动gc一下,这样会更干净一些。 要稍微等待几秒后再gc一次。网上看到有人说,一些游离的dom节点,浏览器不会立即回收,需要等几秒。 每次有更新代码,请刷新页面,重开开发者工具。避免热更内容影响排查。甚至打个包来排查,避免来自开发模式对内存的影响。
排查思路一:
重新操作或加载可能存在内存泄漏问题的功能,然后内存快照里面按保留大小从大到小排序一下,看一下是否存在大量相同的对象(比如我们重复打开100次页签),可能存在100个相同大小的对象。大概率是这个对象泄漏了没有被回收。
如果怀疑某个对象有内存泄漏,可以放大一下:给这个对象增加一个属性,比如
temp: new Array(10000).fill('一千个字符的字符串,或则一万个字符。new Array(10000000).fill('a') 这样大尺寸的数组似乎会导致内存快照卡很久或者卡死')
这样后面可以在内存快照的 Array 中轻易找到这个对象,以确定是否泄漏。
后面发现,内存快照可以筛选某2个快照之间创建的对象。
比如在打开页签之前,先拍摄快照1
打开页签后,再拍摄快照2
关闭页签后,再拍摄快照3
然后筛选快照1和快照2之间创建的对象
这时看到的对象基本都是泄漏的。
当一块内存不再被应用程序使用的时候,由于某种原因,这块内存没有返还给操作系统或者空闲内存池的现象。
所有的全局对象window的子节点都被递归地检查过,每块可以从根节点访问的内存都不会被视为垃圾。
在 Javascript 的环境中,不必要的引用是某些不再被使用的代码中的变量。这些变量指向了一块本来可以被释放的内存。一些人认为这是程序员的失误。
1 意外的全局变量
1.1 在局部作用域产生的没有必要的全局变量。 解决办法:使用“use strict”。 1.2 显式全局变量存放大量不再需要的数据。
2 遗漏的定时器和回调函数(闭包的一种)
在 JavaScript 中 setInterval 的使用十分常见。其他的库也经常会提供观察者和其他需要回调的功能。这些库中的绝大部分都会关注一点,就是当它们本身的实例被销毁之前销毁所有指向回调的引用。在 setInterval 这种情况下,一般情况下的代码是这样的:
var someResource = getData(); setInterval(function() { var node = document.getElementById('Node'); if(node) { // Do stuff with node and someResource. node.innerHTML = JSON.stringify(someResource)); } }, 1000);
这个例子说明了摇晃的定时器会发生什么:引用节点或者数据的定时器已经没用了。那些表示节点的对象在将来可能会被移除掉,所以将整个代码块放在周期处理函数中并不是必要的。然而,由于周期函数一直在运行,处理函数并不会被回收(只有周期函数停止运行之后才开始回收内存)。如果周期处理函数不能被回收,它的依赖程序也同样无法被回收。这意味着一些资源,也许是一些相当大的数据都也无法被回收。
下面举一个观察者的例子,当它们不再被需要的时候(或者关联对象将要失效的时候)显式地将他们移除是十分重要的。在以前,尤其是对于某些浏览器(IE6)是一个至关重要的步骤,因为它们不能很好地管理循环引用(下面的代码描述了更多的细节)。现在,当观察者对象失效的时候便会被回收,即便 listener 没有被明确地移除,绝大多数的浏览器可以或者将会支持这个特性。尽管如此,在对象被销毁之前移除观察者依然是一个好的实践。示例如下:
var element = document.getElementById('button');
function onClick(event) { element.innerHtml = 'text'; }
element.addEventListener('click', onClick); // Do stuff element.removeEventListener('click', onClick); element.parentNode.removeChild(element); // Now when element goes out of scope, // both element and onClick will be collected even in old browsers that don't // handle cycles well.
对象观察者和循环引用中一些需要注意的点
观察者和循环引用常常会让 JavaScript 开发者踩坑。以前在 IE 浏览器的垃圾回收器上会导致一个 bug(或者说是浏览器设计上的问题)。旧版本的 IE 浏览器不会发现 DOM 节点和 JavaScript 代码之间的循环引用。这是一种观察者的典型情况,观察者通常保留着一个被观察者的引用(正如上述例子中描述的那样)。换句话说,在 IE 浏览器中,每当一个观察者被添加到一个节点上时,就会发生一次内存泄漏。这也就是开发者在节点或者空的引用被添加到观察者中之前显式移除处理方法的原因。目前,现代的浏览器(包括 IE 和 Microsoft Edge)都使用了可以发现这些循环引用并正确的处理它们的现代化垃圾回收算法。换言之,严格地讲,在废弃一个节点之前调用 removeEventListener 不再是必要的操作。
像是 jQuery 这样的框架和库(当使用一些特定的 API 时候)都在废弃一个结点之前移除了 listener 。它们在内部就已经处理了这些事情,并且保证不会产生内存泄露,即便程序运行在那些问题很多的浏览器中,比如老版本的 IE。
现代浏览器在废弃一个节点之前调用 removeEventListener 不再是必要的操作。
3 DOM 之外的引用
有些情况下将 DOM 结点存储到数据结构中会十分有用。假设你想要快速地更新一个表格中的几行,如果你把每一行的引用都存储在一个字典或者数组里面会起到很大作用。如果你这么做了,程序中将会保留同一个结点的两个引用:一个引用存在于 DOM 树中,另一个被保留在字典中。如果在未来的某个时刻你决定要将这些行移除,则需要将所有的引用清除。
var elements = { button: document.getElementById('button'), image: document.getElementById('image'), text: document.getElementById('text') };
function doStuff() { image.src = 'http://some.url/image'; button.click(); console.log(text.innerHTML); // Much more logic }
function removeButton() { // The button is a direct child of body. document.body.removeChild(document.getElementById('button'));
// At this point, we still have a reference to #button in the global
// elements dictionary. In other words, the button element is still in
// memory and cannot be collected by the GC.
}
还需要考虑另一种情况,就是对 DOM 树子节点的引用。假设你在 JavaScript 代码中保留了一个表格中特定单元格(一个 <td> 标签)的引用。在将来你决定将这个表格从 DOM 中移除,但是仍旧保留这个单元格的引用。凭直觉,你可能会认为 GC 会回收除了这个单元格之外所有的东西,但是实际上这并不会发生:单元格是表格的一个子节点且所有子节点都保留着它们父节点的引用。换句话说,JavaScript 代码中对单元格的引用导致整个表格被保留在内存中。所以当你想要保留 DOM 元素的引用时,要仔细的考虑清除这一点。
4 闭包
JavaScript 开发中一个重要的内容就是闭包,它是可以获取父级作用域的匿名函数。Meteor 的开发者发现在一种特殊情况下有可能会以一种很微妙的方式产生内存泄漏,这取决于 JavaScript 运行时的实现细节。
var theThing = null; var replaceThing = function () { var originalThing = theThing; var unused = function () { if (originalThing) console.log("hi"); }; theThing = { longStr: new Array(1000000).join('*'), someMethod: function () { console.log(someMessage); } }; }; setInterval(replaceThing, 1000);
这段代码做了一件事:每次调用 replaceThing 时,theThing 都会得到新的包含一个大数组和新的闭包(someMethod)的对象。同时,没有用到的那个变量持有一个引用了 originalThing(replaceThing 调用之前的 theThing)闭包。哈,是不是已经有点晕了?关键的问题是每当在同一个父作用域下创建闭包作用域的时候,这个作用域是被共享的。在这种情况下,someMethod 的闭包作用域和 unused 的作用域是共享的。unused 持有一个 originalThing 的引用。尽管 unused 从来没有被使用过,someMethod 可以在 theThing 之外被访问。而且 someMethod 和 unused 共享了闭包作用域,即便 unused 从来都没有被使用过,它对 originalThing 的引用还是强制它保持活跃状态(阻止它被回收)。当这段代码重复运行时,将可以观察到内存消耗稳定地上涨,并且不会因为 GC 的存在而下降。本质上来讲,创建了一个闭包链表(根节点是 theThing 形式的变量),而且每个闭包作用域都持有一个对大数组的间接引用,这导致了一个巨大的内存泄露。
这是一种人为的实现方式。可以想到一个能够解决这个问题的不同的闭包实现,就像 Metero 的博客里面说的那样。 垃圾收集器的直观行为
尽管垃圾收集器是便利的,但是使用它们也需要有一些利弊权衡。其中之一就是不确定性。也就是说,GC 的行为是不可预测的。通常情况下都不能确定什么时候会发生垃圾回收。这意味着在一些情形下,程序会使用比实际需要更多的内存。有些的情况下,在很敏感的应用中可以观察到明显的卡顿。尽管不确定性意味着你无法确定什么时候垃圾回收会发生,不过绝大多数的 GC 实现都会在内存分配时遵从通用的垃圾回收过程模式。如果没有内存分配发生,大部分的 GC 都会保持静默。考虑以下的情形:
大量内存分配发生时。 大部分(或者全部)的元素都被标记为不可达(假设我们讲一个指向无用缓存的引用置 null 的时候)。 没有进一步的内存分配发生。 这个情形下,GC 将不会运行任何进一步的回收过程。也就是说,尽管有不可达的引用可以触发回收,但是收集器并不要求回收它们。严格的说这些不是内存泄露,但仍然导致高于正常情况的内存空间使用。
一、常见的内存泄漏
- 意外的全局变量:函数中意外的定义了全局变量,每次执行该函数都会生成该变量,且不会随着函数执行结束而释放
- 未清除的定时器:定时器没有清除,它内部引用的变量,不会被释放
- 脱离DOM的元素引用:一个dom容器删除之后,变量未置为null,则其内部的dom元素则不会释放
- 持续绑定的事件:函数中addEventListener绑定事件,函数多次执行,绑定便会产生多次,产生内存泄漏
- 绑在EventBus的事件没有解绑
- 闭包引起内存泄漏:比如事件处理回调,导致DOM对象和脚本中对象双向引用
- 使用第三方库创建,没有调用正确的销毁函数
- 单页应用时,页面路由切换后,内存未释放
- 未清理的log: console.log打印的对象不能被垃圾回收,可能会导致内存泄露
二、疑似存在内存泄漏
1、意外的全局变量或无意义的全局变量
3、脱离DOM的元素引用
在工作台打开 6 个页签,然后关闭这 6 个页签,存在 1632 个被删除但依旧占用内存的元素。 此时当前页面占用内存从最初的 60M 到现在惊人的240M😱
4、绑定事件监听未解绑
packages/renderer-main/src/components/third/InfiniteScroll.jsx
6、闭包使用不当
关闭页签后,Tab对象一直未被释放
建议: 执行close后,Tab赋值为null, 以释放资源使 GC回收该对象占用的内存
9、未处理的console.error
生产环境一直在执行的console.error
10、其它
占用 3M 内存的offlineImg
建议:大图片不应该使用base64, 改成图片文件引用
浪费性能的重复渲染
建议: 直接使用useMemo
const conversationInfo = useMemo(() => wfc.getConversationInfo(conversation), [conversation]);
- 2闭包导致的内存泄漏
- 3未清除的定时器
- 4DOM 引用
- 5事件监听未移除
- 6全局变量
- 7控制台日志
- 8缓存未清理
- 9Promise 链未处理异常
- 10使用后未销毁的大型对象
- 11循环引用
- 12订阅模式未取消
- 13React 中的事件监听
- 14无限递归
- 15使用 requestAnimationFrame 而不取消
- 16使用 eval 或 new Function 动态创建函数
- 17React 组件中未清理的定时器
- 18使用 MobX 的 autorun 而不清理
- 19React 中的事件监听器未移除
- 20MobX 中的 reaction 未正确处理
- 21React 中的闭包陷阱
- 22React 中的异步操作未取消
- 23React 中的 context 过度使用
- 24MobX 与 React 结合使用时的内存问题
- 25扩展阅读
闭包导致的内存泄漏
未清除的定时器
DOM 引用
事件监听未移除
全局变量
控制台日志
缓存未清理
Promise 链未处理异常
使用后未销毁的大型对象
循环引用
(WeakRef 避免使用, 只是举例)
订阅模式未取消
React 中的事件监听
无限递归
使用 requestAnimationFrame 而不取消
使用 eval 或 new Function 动态创建函数
React 组件中未清理的定时器
使用 MobX 的 autorun 而不清理
React 中的事件监听器未移除
MobX 中的 reaction 未正确处理
React 中的闭包陷阱
React 中的异步操作未取消
React 中的 context 过度使用
MobX 与 React 结合使用时的内存问题
扩展阅读
微前部分
vv-desktop-work: src/pages/work/components/selectTagFilter/index.jsx
antTabsNavWrap、scrollEle 定义为全局变量,DOM 卸载后引用未被删除
桌面部分
vv-desktop: packages/toolbox/utils/notify.ts
全局的 audio 元素未被释放
vv-desktop: packages/renderer-main/src/hooks/useOverflow.ts
ResizeObserver 未被释放
页签管理
微前应用:
目前发现,微前应用的页签关闭后,子应用的dom节点没有被释放。
在用 create-react-app 创建的 demo 项目中试验发现一样没有释放。
改成 官方 umd模式 (micro-zoe.github.io) 推荐(频繁创建和销毁子应用场景下)的 window.mount 和 window.unmount 方式后,发现dom节点能正常释放了。
上面创建了 10000 个div,关闭页签后没有游离的 10000 个 div 。
故需要写一个飞冰插件,给各个子项目接入。统一注入 mount 和 unmount 方法。以改善dom节点没有释放的问题。
但是经过尝试发现,使用ice2的情况下,使用umd模式,dom节点依然无法被释放。
使用ice3时,节点能正常被释放。
尝试将工作管理项目升级ice3后,,打开反馈列表进行测试。发现在根节点下创建的子节点能正常被释放,但是在孙子节点就无法释放。尚无法理解,需要进一步排查。
如果是新建一个 ice 项目,不论是什么节点都能正常释放。
内置应用(日历、会议、协同首页、工作台首页等):
排查:
每次关闭页签都会有内存泄漏,将 TabPanel 内用到的 Freeze 注释掉后,关闭页签时内存基本都能正常释放了。
至于 Freeze 为什么会导致内置应用无法正确被释放,需要进一步排查。
且 DynamicComponent 渲染的组件会重复创建2-3次。(也是因为 Freeze 的原因,注释掉 Freeze 就没有这个问题)
(上面 DynamicComponent 组件的时间戳不同,代表是不同的组件实例,说明多次创建了组件)
另外,刷新工作台首页时发现依然有较多内存泄漏,审查内存快照发现大多与 DnD、swiper、dropdown、tooltip(Trigger2)有关。
应该与相关事件没有正确解绑有关。
最终发现:
是 React 18 的 concurrent mode 下,Suspense 解决时,会触发一次 useLayoutEffect 最终导致的内存泄漏。https://github.com/CJY0208/react-activation/issues/225#issuecomment-1311136388
而 React 17 不会,故在入口处改用 ReactDOM.render 来进行渲染。
👆 改造后,在协同页多次(共约20次)打开日历和会议模块,dom节点没有发生泄漏,内存使用也几乎没有上升。
DynamicComponent 也能正常创建渲染(不会重复创建)
对比改造前的👆,打开约20次时,约泄漏1万+个dom节点,内存也存在泄漏。
其他
react-sortablejs
刷新工作台首页时发现,存在很多 dom 节点泄漏。审查快照发现,很多节点跟拖拽组件 react-sortablejs 有关系。
查看 react-sortablejs 源码发现没有组件卸载的相关方法。而 sortablejs 是由提供 destroy 方法的。
顾尝试通过打补丁的方式,为 react-sortablejs 组件卸载时执行 destroy,发现有效。能正常被释放了。
但是因为 react-sortablejs 是基于 class 组件实现的。在 Freeze 组件下,处于冻结状态时(实际是 Suspense 的挂起状态),会触发 class 组件的 componentWillUnmount 。
所以,需要大家一起讨论看看是要去掉 Freeze,还是不打这个补丁,还是有其他的想法。
最终发现,通过react 17 的方式渲染节点,不会出现上述问题。这种方式与解决 Freeze 导致部分组件的dom节点无法释放的问题刚好一致。
dom 变量没有释放
如图,dom 节点复制给全局变量后,组件卸载时没有做释放。
store(mobx)
useImg
如果在promise 阶段,组件状态重新渲染之前的 new Image() 不会被释放
ReactiveStore
排查下来,ReactiveStore 本身应该不会存在或导致内存泄漏。因为注释掉 ReactiveStore 与否,内存泄漏的情况基本一样。
但是需要注意 ReactiveStore 把状态同步到其他图层时,会引起其他图层的重新渲染。
虽然其他图层内存也没有泄漏,但多个图层同时重新渲染,引起CPU、内存占用同时升高,相比没有 ReactiveStore 的情况下卡顿明显。