使用内存堆快照检测分离的 DOM 内存泄漏
https://fe.okki.com/post/62cbfea7136f570343d89416/
使用 Chrome Devtools 分析内存问题
网页的内存限制
对于 Chrome 浏览器,每个 Tab 页能使用的内存大小是有限制的。限制大小根据 Chrome 版本,Chrome位数(32/64),操作系统版本,会有所不同。可以通过 window.performance.memory 查看内存限制信息。
对于在现代操作系统运行的较新版本 chrome,单tab的内存限制在 1.8G左右, 一旦超出内存限制,浏览器就会崩溃。正常情况下我们编写的代码使用不了这么多的内存,不过如果代码中存在内存泄漏问题,则有可能在较长时间操作后超出内存限制。
何为内存泄漏
在计算机科学中,内存泄漏指由于疏忽或错误造成程序未能释放已经不再使用的内存。内存泄漏并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,导致在释放该段内存之前就失去了对该段内存的控制,从而造成了内存的浪费。
在浏览器中,常见的内存泄漏包含以下场景:
- 闭包使用不当引起内存泄漏
- 分离的 DOM 节点
- 全局变量和全局绑定的事件
- 遗忘的定时器
内存泄漏的问题一般不容易发现,好在我们可以使用 chrome 浏览器的开发者工具协助定位内存泄漏问题。
使用 Chrome Task Manager 观察实时 JS 内存使用
打开 Chrome 右上角设置-更多工具-任务管理器
在 header 上右键将 JavaScript 使用的内存打勾,这里解释下 **内存 和 JavaScript 使用的内存 的区别。
- 内存表示 native momory 占用,DOM 节点保存在 native momory。
- JavaScript 使用的内存 表示 JS 堆内存,有括号外和括号内两个值。我们关注括号内的实际值,这个值代表了当前页面可访问(reachable)的对象占用了多少内存,直白一点就是实际被使用,不会被 GC 的对象占用的内存大小。
使用 Performance 可视化检测内存泄漏
在 Chrome DevTools 的 Performance 面板勾选 Memory 并开启记录,之后在页面上执行一些操作。我们在记录数据的开始和结束时刻都手动进行一次 GC。
在结果图表中 HEAP 这一栏查看内存的使用情况,我们将一开始没有进行操作时的内存使用情况作为基准值,与最后停止操作并进行 GC 后的值进行比较,如果有较明显的差距,则说明产生了内存泄漏。
在检测到发生了内存泄漏后,可以转到 Memory 面板,进行更进一步的分析。
查看内存堆快照
在 Memory 面板选择 Heap snapshot 可以记录内存堆快照。内存堆快照中只包含可访问的对象,在开始记录内存堆快照前,Chrome 总是会进行一次 GC 操作。我们可以通过比较前后两次堆快照数据来分析内存泄漏问题。
使用内存堆快照检测分离的 DOM 内存泄漏
何为DOM内存泄漏
一个 DOM 节点只有在没有被引用时才会被 GC,当 DOM 节点不在 DOM 树中,但是却被 JS 代码保留着引用,这种情况下会造成内存泄漏。DOM 内存泄漏可能可能会比我们预想的严重,思考一下下面这个例子,tree 的内存在何时才能被 GC。
var select = document.querySelector;
var treeRef = select("#tree");
var leafRef = select("#leaf");
var body = select("body");
body.removeChild(treeRef);
//#tree can't be GC yet due to treeRef
treeRef = null;
//#tree can't be GC yet due to indirect
//reference from leafRef
leafRef = null;
//#NOW can be #tree GC
答案是只有在执行到 leafRef = null 时才可以,虽然 leftRef 保留的只是 tree 的子节点 leafRef 的引用,但是因为leafRef 包含着对它 parmentNode 的引用,这个引用可以一直向上追溯到 tree,导致 GC 无法回收 tree节点。
检测 DOM 内存泄漏
可以输入 Detached 在结果中搜索分离状态的 DOM 节点。
下面这行代码创建了一个分离的 DOM 节点,在快照结果中搜索 Detached 可以找到这个分离的 DOM 节点,下方的信息告诉我们是 detached 这个变量保留了这个 DOM 节点的引用。
<script>
const detached = document.createElement("div")
</script>
查看内存分配时间轴信息
在 Memory 面板选择 Allocation instrumentation on timeline 可以记录内存分配时间轴信息。Chrome 会周期性以一定的间隔记录内存堆快照,并在记录结束时进行最后一次快照,以此生成内存分配时间轴信息。
开始记录后,在页面上进行一些操作,一段时间后停止记录,结果如下所示。
最上方的时间轴中,条形说明在该时间段发生了内存分配。条形的高度对应了分配的内存大小,条形的蓝色部分说明依然存活的对象数量,灰色部分说明已经被 GC 的对象数量。如果较早时间分配的对象依然大量存活,则说明可能有内存泄漏的问题。我们可以点击时间轴上该条形,检查该时间段内的详细内存分配信息。
实战 - 分析 OMS 项目内存使用情况,定位内存泄漏问题
整体思路:
现在测试环境检测内存使用趋势是否有问题,发现问题后开启本地开发环境具体分析问题。
准备工作:
打开 Chrome 的隐身模式,并关闭所有非必要的 Chrome 扩展,避免缓存和 Chrome 插件内存占用造成的影响,打开测试环境的采购入库单页面。
接下来定义一组模拟用户操作
- 进入采购入库列表页
- 进入编辑页
- 打开产品选择器添加新产品
- 任意编辑产品的价格数据
- 点击保存跳转到详情页面
- 点击菜单栏返回列表页面
这里使用了 Performance monitior 实时查看内存占用,在执行完一组用户操作后手动执行 GC 操作,观察内存增长情况。
发现每次操作完成后,内存稳定增加 12 M左右,接着采用控制变量的方式,发现不执行打开产品选择器的操作,内存几乎没有增加,推测是产品选择器出现了内存泄漏。
接着就具体找到哪里发生了泄漏,此时开启本地开发环境。在产品选择器中新建一个测试对象,观察该对象是否被正常 GC。
在采购入库列表记录第一次快照,然后进入编辑页,打开产品选择器,再回到列表页,记录第二次快照,比较两次快照,在结果中搜索 TestObject。
发现果然没有被 GC 掉,同时路径中 popup-manager.js 这个文件很可疑,会不会是这里面缓存了组件实例呢?
尝试打印 popup-manager 的实例,发现 popups 依然保留着产品选择器的实例,看来是这个原因导致了内存泄漏。
知道了原因,接下来就是有的放矢,分析为啥产品选择器关闭时没有将其冲 popups 中移除,再修改代码,这里就比较简单了。定位到是 amumu 的 Popup 在销毁时,没有调用 PopupManager 的 unregister 方法将实例从 popups 中移除。修改后再测试,没有发现产品选择器内存泄漏的问题。
// amumu/src/utils/popup/popup-manager.js
const PopupManager = {
zIndex: (Vue.prototype.$amumu || {}).zIndex || 1000,
popups: {},
get nextZIndex() {
return this.zIndex++
},
register(instance) {
const popupId = (instance.popupId = uniqueId("popup-id-"))
if (popupId && instance) {
this.popups[popupId] = {
popupId,
instance,
}
}
},
unregister(instance) {
delete this.popups[instance.popupId]
},
...
}
// amumu/src/utils/popup/index.js
{
...
mounted() {
PopupManager.register(this)
if (this.localVisible) {
PopupManager.open(this)
}
},
// 原因就在这里 销毁时没有调用 unregister,popups 保留了vue实例的引用
beforeDestroy() {
PopupManager.close(this)
this.removeScrollEffect()
},
...
}
通过 git 的 commit 记录显示,这是一个四年前就存在的问题,在此之前一直没有被发现。而运用 Chrome 的开发者工具进行内存分析,我们轻易就发现并解决了这个问题。