问题背景
在接手某个微前端项目后,遇到了一个极其棘手的问题。
在持续操作页面一段时间后,Chrome 浏览器的内存占用会偶发性突破 4GB+,最终达到浏览器限制,导致页面卡死甚至直接闪退。
并且具有下面几个特征:
- 问题只出现在开发环境
- 问题无法稳定复现
- 生产环境未反馈类似问题
- 本项目是一个微前端项目
- 即使不做任何操作,一段时间后内存也会暴涨
问题分析
是否是内存泄漏?
内存泄漏的定义
当出现内存问题是,我们第一时间想到的是内存泄漏,但是并不是所有的内存问题都一定的内存泄漏导致的。
内存泄漏的定义是:已经不再需要使用的内存,却仍然被程序持有引用,导致无法被回收。
逻辑上已经“死亡”的对象,在 GC 视角下仍然“存活”。
内存泄漏 vs 内存爆炸
内存泄漏可能会导致内存爆炸,但是出现了内存爆炸,并不一定是因为内存泄漏。
例如长列表,如果没有实现虚拟滚动等优化手段的话,在滚动多次后,浏览器会创建大量的 dom 节点,这些 dom 节点会持续存在不会被释放,从而导致内存爆炸,但是这些 dom 节点上在业务上我们是需要的,因此并不能算内存泄漏。
因此,是内存泄漏还是其他什么原因导致的内存问题,还需要进一步排查。
是否是内存泄漏?
而排查是否是内存泄漏其实也简单,前面我们说过,内存泄漏是已经不再需要使用的内存,却仍然被程序持有引用,导致无法被回收而导致的。
因此我们可以按照下面这个步骤排查:
- 操作一下页面,让内存问题触发
- 在 chrome devtools 性能面板点击 GC,进行多次垃圾回收
- 等待一段时间,观察内存占用是否大幅下降至正常水平
如果是内存泄漏,那么由于对象引用,GC 将无法回收,导致内存占用无法下降至正常水平,否则基本可以排除内存泄漏。
实际操作中,发现内存并没有明显下降,说明确实存在内存泄漏问题。
微前端的特殊性
项目采用的是微前端架构,其底层使用的微前端框架基于 qiankun 实现。
在微前端场景下,对象的生命周期变得异常复杂,主要体现在:
- 子应用 mount / unmount / bootstrap 各种生命周期函数
- 沙箱隔离带来的内存问题(例如Snapshot 模式下会深拷贝 window 对象)
- 子应用卸载是否有残留
- 全局事件、缓存、状态可能跨应用残留
问题的触发可能在:
- 基座应用
- 某个子应用
- 微前端框架本身
最后事实证明,虽然上述并没有造成直接影响,但是对本次内存问题产生了间接影响。
问题排查
常规手段的失效:堆快照
定位内存问题最直接的方法是使用 Chrome Devtools 的堆快照功能,它能非常直观观察堆内存空间的对象以及引用链。
实现步骤是:
- 操作前拍一次快照
- 操作后拍一次快照
- 对比对象数量和保留数量
- 分析引用链
然而,堆快照在这次内存问题中会失效了,无法派上用场,因为问题是偶发的,当意识到内存问题触发时,内存通常已经暴涨到较高的水平,此时执行堆快照,浏览器直接就崩溃了,观察任务管理器可以发现,在进行堆快照时,浏览器内存直接继续暴涨,当突破到 6 GB 左右时就会崩溃。
整个问题不难理解,堆快照大概率是通过深拷贝堆内存空间中对象的信息来统计分析,因此它整个操作本身也会造成大量内存占用,当内存占用已经在一个比较高的水准时,使用堆快照会引发内存的进一步暴涨,从而导致崩溃。
因此,在大内存问题排查时,堆快照并不是一个好的选择。
轻量级内存工具
既然无法“看全局”,我决定换一个思路:我不需要观察整个堆内存的情况,我只需要追踪我操作页面这段时间内的内存变化情况即可。
最开始选择的是 Chrome Devtools 的性能监视器,但是它只能观察 JS 堆内存的变化情况,无法统计和分析内部对象信息。
最后,我选择了 Chrome DevTools 的另外两个工具作为突破口:
- Allocation instrumentation on timeline(按时间分配)
- Allocation sampling(按调用栈采样)
前者只采集某一段时间内分配的对象内存信息,后者是采集调用栈分配的内存信息,相较于堆快照,它们的优点是更加轻量,性能更好。
定位关键函数:deepcopyArray 和 deepcopyObject
通过调用栈采集分析,我们定位到有两个关键函数,在内存大幅上涨期间分配内存比重非常高。

从函数名称和源代码定位中可以发现,这两个函数位于 Vue devtools 源代码中,并且它们的作用是对对象和数组进行深拷贝。我们知道,深拷贝会完整深度复制对象的所有属性,对内存的影响非常大,因此这很可能是造成这次内存泄漏的关键原因之一。
虽然我们找到关键问题函数,但是整个链路我们缺少最后也是最重要的一环,因为深拷贝确实会导致内存上涨的问题,但是正常情况下,在其被使用完毕后,GC 会将其回收,不会导致内存大幅度上涨且无法被释放。
现在像这样直接内存暴涨最终导致浏览器崩溃,说明垃圾回收机制并未将这些对象回收,因此,我们将下一步的重点放在垃圾回收机制上。
真正的根因:Vue Devtools 的隐藏引用链
V8 垃圾回收与可达性
要想彻底解决这个内存问题,我们必须先对浏览器的 GC 有一定的了解。现代浏览器基本都采用标记清除法来实现垃圾回收,通常来说有三个核心步骤:
- 标记”活”的对象
- 清除未被标记的对象(死对象)
- 内存整理(可选)
从这里可以看出,关键在于如何定义什么是活的对象。在现代垃圾回收机制中,GC 会从一组根(DOM 根节点、当前执行上下文和全局对象等)出发,递归遍历每一个“可达”的对象,这些对象会被标记成“活对象”,而未被比较的对象则说明无法被访问,是“死对象”,需要被垃圾回收。
事实上,现代浏览器为 GC 做了大量的优化工作,实际比上述更加复杂,例如基于代际假说将对象分为新生代和老生带,使用主副垃圾回收器进行垃圾回收,使用并行、并发、增量等方式优化。 如果你有兴趣,可以参考笔者下面的文章。 深入浏览器引擎 IV:V8 垃圾回收机制 用 JavaScript 实现 V8 的垃圾回收:从MinorGC到MajorGC
也就是说,前面通过 deepcopyArray 和 deepcopyObject 创建的对象,必然会被某些对象引用着,并且这些对象形成的引用链最终指向根(DOM 根节点、当前执行上下文或全局对象等)。我们需要找到这个引用链。
最后一块拼图:引用链
确定好目标后,实现起来也很简单,使用前面提到过的按时间分配的内存工具,我们可以找到在某段时间内对象的内存分配情况,其中就包括引用链。

Vue devtools 做了什么
最终问题定位如图所示:

从图片中可以看出,Vue devtools 在 window 全局对象下挂载了一个对象,并且这个对象中的某个属性保存了前面通过 deepcopyArray 和 deepcopyObject 深拷贝的对象数据,正是由于这些数据被挂载在 window 全局对象上,导致对于 GC 来说,这些对象都是“可达的”,是“活对象”,因此不会将它们回收,从而导致了内存泄漏。
并且通过对 deepcopyArray 和 deepcopyObject 两个函数进行断点回溯,可以发现,它们会监听 vuex 的 commit 操作,结合前面的引用链可以推测:
Vue Devtools 会在时间轴组件中,记录 Vuex commit 的操作,其中会深拷贝 commit 传递的参数,并且 vue devtools 出于设计考虑,将这部分数据直接存储在 window 全局对象中,并且没有手动将其释放,从而导致了内存泄漏。
这也解释了为什么问题只发生在开发环境且是偶发的,因为我在开发环境会习惯性打开浏览器 devtools,从而导致 Vue Devtools 被激活,并且只有我切换时间轴组件时,它才会自动开始记录深拷贝 Vuex commit 的参数。
微前端扮演的角色
虽然整个链路都排查完毕,但是还有一个问题困扰我,虽然深拷贝+全局对象引用确实会导致内存泄漏,但是只要拷贝的对象不是特别大,那么也不会在短时间内导致内存极速大幅上涨。
也就是说:项目代码中很可能存在大数据的 VueX commit 事件。
顺着这个思路,很快就定位到代码。
从代码中可以看到,在这个微前端是通过事件总线来进行状态管理和消息通讯的,而在当前这个子应用中,老代码为了方便直接监听微前端的全局更新事件,并将包含基座应用在内的整个状态对象都通过 VueX commit 到自身的状态管理库中,这是导致本次内存泄漏问题的间接原因之一。
整个链路梳理
Vue Devtools 在时间轴中会监听 Vuex commit 函数事件,并且这个过程是自动触发的,它会深拷贝 commit 的参数,并且将其存储在 window 全局对象的属性上,导致这些对象是否是“可达的”,从而导致 GC 无法将其回收。
再加上子应用中直接将微前端整个全局状态对象都 commit 到自己的 Vuex 中,这就会导致哪怕在页面上进行微小的操作,只要触发了微前端的这个事件,就会导致一个大数据对象被深拷贝,从而使得内存占用大幅度上涨。
解决方案
微前端优化
从代码上来看,将整个微前端的全局状态直接简单地同步到 vuex 中肯定是不合理的,并且如果由于用户操作导致全局状态更新事件被频繁触发,那么将会短时间内频繁 commit 更新一个很大的对象对性能的影响也是非常大的。
因此,即使不考虑 vue devtools,这种方式也应该优化。
提案
此外,Vue Devtools 自身的设计缺陷是导致本次内存泄漏问题的直接原因:
- 深拷贝是合理的,因为要保存快照
- 自动记录是不合理的,应该像 Chrome Devtools 性能面板那样手动启动,并且提供清空回收的功能。
基于这次排查,笔者向 Vue Devtools 官方提交了一个 Proposal。