在构建高性能、长周期运行的 WebGL/Canvas 应用(如 3D 编辑器、数据可视化平台)时,内存管理是一个至关重要且极具挑战性的课题。

开发者通常面临的内存泄漏问题,其根源远比简单的 JavaScript 对象未释放要复杂得多。一个现代 WebGL/Canvas 应用的内存版图实际上跨越了三个截然不同但又相互关联的内存区域:
- 图 V8 引擎管理的 JavaScript 堆(JS Heap),绝大部分情况最关注的是这一层的泄露
- 图形处理器(GPU)的显存(VRAM)
- Blink 渲染引擎自身用于管理 DOM 等对象的原生 C++ 堆(Native Heap)
这三个内存区域各自遵循不同的分配、管理和回收规则:
- V8 堆:采用先进的、自动化的垃圾回收(GC)机制,当引用为空的时候会自动释放
- GPU 显存:依赖于开发者通过 WebGL API 进行显式的手动管理
- Blink 的原生堆:由专用 C++ 垃圾回收器负责
大多数难以诊断和修复的内存泄漏问题,其本质都源于对这三个层面之间的边界、所有权规则以及通信协议缺乏深刻理解。
我们将分别对三个核心部分,系统性地分析每一层内存区域中常见的泄漏模式、底层成因,并介绍实用排查策略和解决方案。通过本篇分享,开发者将能够建立一个贯穿 GPU、JavaScript 引擎和浏览器渲染内核的整体内存心智模型,从而更有效地构建稳定、高效且无泄漏的 WebGL/Canvas 应用。

JavaScript 堆泄漏
堆简述
Javascript 的解释器 V8 引擎将浏览器内存分为两个主要部分:
- 栈(Stack):用于存储静态数据,包括原始类型(Primitive Types)的局部变量(如 number, boolean, null, undefined, string 等)以及指向堆中对象的指针(引用地址)。栈内存的特点是大小固定、自动分配和释放,随着函数调用的开始和结束(执行上下文的入栈和出栈)而进行管理。
- 堆(Heap):用于存储动态分配的内存,即大小不固定的、生命周期可能很长的数据。JavaScript 中的绝大多数“东西”都存在这里。譬如对象(object),数组(Arrays),函数(Functions),闭包(Clousures),字符串(String),ArrayBuffer / Uint8Array 等
我们重点关注堆上的资源,尽管有自动垃圾回收(GC)机制,JS 堆泄漏仍然是 WebGL 应用中一个常见且棘手的问题。
导致 JS 内存泄漏的常见架构模式:
在 JavaScript 这类具备自动垃圾回收机制的语言中,内存泄漏的本质并非“忘记释放内存”,而是存在“意外的引用”(unwanted reference)。一个在逻辑上已经废弃、应用不再需要的对象,若仍有一条引用链将其与存活的对象图相连,GC 就会判定它“可达”,从而无法回收 。
GC 标记 – 清除(Mark-and-Sweep)算法遵循明确的规则:从根对象开始,逐条追踪所有指针。只要从根到某个对象存在一条路径,该对象就定义为可达,即“存活”。但是 GC 无法理解开发者的语义意图 —— 它无法判断一个已经脱离文档的 DOM 节点是否永远不会被重新挂载,也无法知晓闭包中捕获的变量是否永远不会被访问。它只能机械性地遵循指针。
因此,修复这些内存泄漏并非寻找 V8 引擎的 bug,而是细致地管理对象图,确保当对象在逻辑上不再需要时,通过 myNode = null 或 removeEventListener 等方式显式地切断引用。
以下是一些常见的导致意外引用的模式:
1、 分离的 DOM 元素
分离的 DOM 元素 (Detached DOM Elements) 这是最经典的泄漏模式之一。当一个 DOM 节点通过 element.removeChild() 从文档树中移除后,它在页面上就不再可见。但是,如果此时 JavaScript 代码中仍有某个变量持有对该节点的引用,那么这个节点及其整个子树都无法被 GC 回收。在复杂的单页应用(SPA)中,视图组件被动态创建和销毁,如果销毁逻辑不完善,很容易留下对旧视图 DOM 节点的引用。
2、闭包引起的意外作用域捕获
闭包(Closure)是 JavaScript 的一个强大特性,它允许函数访问并操作其词法作用域(lexical scope)中的变量,即使该函数已在其作用域之外被调用。但是这份强大也暗藏风险,闭包往往是内存泄漏的“隐形源头”。
闭包会完整持有其创建时所在作用域的引用。若一个生命周期很长的内部函数(例如,一个事件回调或定时器回调)是在一个包含大型对象引用的外部函数中创建的,那么这个大型对象也会被闭包“捕获”。即使内部函数本身从未使用过它,大型对象也会始终处于可达状态,最终导致垃圾回收器无法对其回收,从而造成内存泄漏。
3、 悬空的定时器和事件监听器
传递给 setInterval、setTimeout 或 element.addEventListener 的回调函数,其生命周期会持续到定时器被清除或事件监听器被移除为止。在此期间,若回调函数内部引用了其他对象(比如某个组件的实例或数据),这些被引用的对象也会被“绑定”而保持存活状态。在组件化开发中,最常见的疏漏之一便是:在组件销毁时忘记清理这些定时器与事件监听器。这就会直接导致整个组件实例及其依赖对象始终处于可达状态,最终无法被回收,从而造成内存泄漏。
4、意外的全局变量
在非严格模式下,函数内给未声明的变量进行赋值,JavaScript 不会报错,反而会在全局对象(如浏览器的 window)上创建一个同名变量。全局变量作为 GC 根节点,它们在应用的整个生命周期内都无法被回收。这种“意外的全局变量”通常由拼写错误或忘记使用 let、const、var 关键字引起,是一种隐蔽但危害严重的内存泄漏源。
Chrome DevTools 堆分析实战指南
Chrome DevTools(开发者工具) 的 Memory(内存)面板是诊断 JS 堆泄漏的权威工具。它可以对当前堆进行快照,直观的展示当前的占用情况。

具体操作位置在 Chrome DevTools -> Momery 标签页(图中 ①)。
1.在内存分析中,Heap snapshot(堆快照)是最常用的排查手段,在生成快照前,需先选择这一类型(图中 ②)。
2.在生成快照前,需要先点击上图中的 ③ 号按钮(强制垃圾回收),待完成一次 GC 后,再点 ④ 号按钮生成快照。这样做的原因是,我们的核心目标是排查内存泄漏问题,强制 GC 能释放原本应该被回收的资源,这会让快照结果更加直观地显示出问题。
3.快照生成后,在 ⑤ 位置会显示快照信息,展开后如下:
(⑥ 位置会展示堆内存的大小,能快速且直观地了解到整个页面的堆内存占用情况。)

在快照信息中,需要重点关注每个对象的两项核心数据:
- Shallow Size 浅层大小(图中 ①):一般用来指对象自身占用的大小,不包含它引用的其他对象的大小。
- Retained Size 保留大小(图中 ②):表示该对象在被 GC 后,所能释放的总内存大小。通常等于自身的 Shallow Size 加上被它引用的其他对象的 Shallow Size 之和。
在实际分析中,建议优先关注 Retained Size,因其能更全面地反映对象堆内存占用的实际影响。
快照的摘要视图

在上图所示的摘要中,每一项都支持展开,展开后可以看到对象的完整引用链。摘要面板适合的运用场景:当单次 Profile 已显示出大量的内存占用时,可先按 Retained Size 对列表进行排序,快速定位到占据了过高的内存的项,展开其中的可疑目标并一路追溯,直到找到根源 —— 通常是挂载到全局 windows 对象上的变量,或被闭包捕获的变量。
三快照法(推荐的排查步骤)
在多数情况下,泄露是缓慢发生的,单个堆快照包含了数百万个对象,杂乱无章,不方便直接找到泄漏源。因此,我们更推荐使用“三快照法”来找到泄露的源头。具体操作步骤:
- 快照 1 (基线状态):加载页面,在应用进入稳定状态后,点快照中的扫把按钮,做一次强制 GC 后,拍摄第一次堆快照(Heap snapshot),建立内存的基线
- 执行可疑操作:执行一系列你怀疑可能导致内存泄漏的用户操作。这里的关键在于:这个操作序列应具备是可逆性。例如“打开一个复杂的 UI 面板,随后再将其关闭”。这个“操作-逆操作”循环是你的受控实验,假设是“该循环应是内存中性的,即操作后不应遗留任何内存垃圾”。此外,也可测试应用长时间静置(如半小时以上)的情况。
- 快照 2:做完上述的操作之后,继续强制 GC 一次,再拍摄第二次快照。
- 放大泄漏:重复执行步骤 2 中的“操作-逆操作”循环数次(例如 1-N 次)。这会放大内存泄漏,使其在快照对比中更加明显。
- 快照 3:完成所有循环后,再次强制 GC,并拍摄第三次快照。
1、使用对比视图
在完成以上的操作步骤后,选择第三个快照,并在顶部的视图选择器中(下图 ②),将视图模式从 Summary 切换为 Comparison,比较对象选择为快照 2(下图 ③)。现在视图只会显示快照 2 和快照 3 之间发生变化的对象。操作后需要关注以下内容:
- Delta 列:这是该视图的核心,它显示了对象实例数量的净变化。需重点关注 Delta 值为正数的项,尤其是那些与重复操作次数成正比的构造函数。这些就是在操作循环中被创建但未能被成功回收的对象。
- Retained Size Delta 列:此列显示了该类对象及其引用的所有对象所占内存的净增量。按此列降序排序,可以快速定位到对内存影响最大的泄漏源。

2、使用摘要视图
还有一种很重要的排查方式:
- 选择第三个快照,顶部的视图选择器,切换为 Summary
- 右侧下拉框中选择筛选快照一和快照二中间创建的对象
该视图的意图是:查找出快照 2 较快照 1 新增的内存对象,若这些新增对象在快照 3 中依然存在,那么它们极有可能是泄露的源头。

3、使用 Retainers 树追溯泄漏源
在对比视图中定位到一个可疑的泄漏对象(即对应的构造函数)后,展开该构造函数,并选中其中一个实例。此时,下方的 Retainers(保留者)面板会自动加载内容。这个面板是定位内存泄漏根源的核心工具,面板展示了一条或多条引用链,并清晰地解释了被选中对象无法被 GC 回收的原因。
具体分析步骤如下:
- 追溯引用链:Retainers 树以被选中的对象为起点,逐层向上追溯,直到指向某个 GC 根节点(例如 (Global handles) 下的 window 对象)。开发者需要仔细检查这条链路上的每个节点。
- 识别意外引用:寻找那些本应在操作结束后被切断的引用。例如,一个已关闭面板的 DOM 节点,仍被一个全局缓存对象 myApp.cache 引用,那么 myApp.cache 就是那个“意外的引用”。
- 关注高亮节点:分析分离的 DOM 树时,DevTools 会用颜色高亮节点。
- 黄色节点: 表示被 JavaScript 代码直接引用的节点。
- 红色节点:表示无直接引用,但因属于某个黄色节点的父子节点,而被间接保留在内存中的节点。在排查时,应优先关注黄色节点。

GPU 显存与 WebGL 上下文管理
本部分内容将聚焦于 GPU 中的关键资源,此类资源必须通过 WebGL API 进行显式的、手动的生命周期管理。这背后的核心逻辑在于:在 GPU 层面不存在自动内存管理机制。从资源的创建、绑定到最终销毁,开发者须全程主导,主动承担释放内存的全部责任。
WebGL 上下文句柄
WebGL 上下文句柄是一种有限且关键的资源。现代浏览器对单个页面或同源(origin)下可创建的活动 WebGL 上下文(Context)数量施加了严格的限制。例如,在 Chrome 浏览器中,这个上限通常是 16 个。Firefox 也有类似的限制,尽管具体数值和配置策略可能略有不同。
这个限制是浏览器厂商为保护整个系统稳定性而采取的一项关键防御措施。GPU 是一种系统级的共享资源,如果单个网页能够无限制地创建 WebGL 上下文,它将可能耗尽 GPU 驱动程序的资源,导致驱动崩溃或整个操作系统的性能下降,从而影响到其他应用程序和系统界面的正常运行。
我们会经常看到,作为系统级资源管理者的浏览器,其抉择始终是:优先保障宿主操作系统的稳定性,而非满足单个网页的无节制资源需求。
当 WebGL Context 超出限制,浏览器会采取强制措施:丢弃“最近最少使用”的那个 WebGL 上下文,并在控制台输出一条警告,如:“WARNING: Too many active WebGL contexts. Oldest context will be lost.”(警告:活动 WebGL 上下文过多。最旧的上下文将被丢弃。)。对于那些未预料到此行为的应用而言,这可能导致灾难性的渲染失败,且问题难以追踪。

对于确实需要大量独立 3D 视图的应用(例如建筑设计软件、多视图监控面板),必须采用更高级的架构模式来规避此限制。常见的解决方案推荐复用 gl context,切换场景的时候,做 clear + dispose 操作清空,并使用同一个 g3d 进行反序列化。
贴图,buffer 等 GPU 资源对象
在 WebGL 环境中,代表 GPU 资源的 JavaScript 对象(例如 WebGLTexture 对象),其生命周期与该资源在 GPU 显存中实际占用的内存的生命周期是完全分离的。简单地将 JavaScript 对象的引用设置为 null,或让其离开作用域而被垃圾回收,也不会触发 GPU 显存的释放。
WebGL API 划定了一条清晰的界线:JavaScript 的 WebGLTexture 对象仅仅是一个轻量级的句柄(handle),本质上是一个整数 ID。JS GC 可以安全地回收这个句柄对象,且不会对 GPU 产生任何影响。而真正占用显存(VRAM)的重量级 GPU 资源,唯有开发者——这个唯一掌握渲染逻辑上下文的角色——显式调用对应的删除函数时,才会被彻底释放。因此,一旦某个 GPU 资源不再需要,就必须立即调用对应的删除函数,例如:
- gl.deleteTexture()
- gl.deleteBuffer()
- gl.deleteRenderbuffer()
- gl.deleteFramebuffer()
- gl.deleteProgram()
- gl.deleteShader()
一个标准的 WebGL 资源生命周期应遵循“创建-绑定-使用-解绑-删除”的模式。GPU 显存泄漏并非浏览器的“缺陷”,而是开发者未能遵守这一显式契约的结果。
HT 中的 graph3dView 提供了专门的 dispose 方法,当 3D 场景确定要释放的时候,主动调用 g3d.dispose() 将会彻底把当前的所有跟 WebGL 相关的 GPU 资源彻底释放。
查看这类资源占用,通常需要观察系统显卡的显存使用情况。以 Windows 系统为例,可以通过:「任务管理器 → 性能 → GPU → 专用 GPU 内存」这一路径,直观地看到显存占用的变化趋势。以一个 6G 显存的 GPU 为例,尽量将显存占用控制在合理范围(譬如 5G 以内,避免超过 5.5G),否则一旦超标,系统可能会强制回收显存资源。

原生堆:理解 Blink 的 Oilpan GC
分析完应用层的内存问题,我们的视线将最终聚焦于浏览器的 C++ 底层核心 ——Blink 渲染引擎。
Blink Oilpan GC 是 Chromium 浏览器引擎 Blink 中用于管理 C++ 对象内存的垃圾回收 (Garbage Collection, GC) 系统。Oilpan 采用的是一种先进的并发标记与增-量清除 (Concurrent Marking and Incremental Sweeping) 垃圾回收机制。这种机制的核心思想是尽可能地将垃圾回收的工作与主线程 (main thread) 的任务(例如 JavaScript 执行、页面布局和渲染)并行处理,从而最大限度地减少因 GC 而导致的页面卡顿 (jank)。
通常这块内存由 Blink 底层管理,Web 应用层是无法干预的,这里我们通过一个实际案例来展开说明:JS 堆快照显示其 24 小时动画运行后内存增长微乎其微,但 Windows 资源监视器却显示 Chrome 进程占用了 4GB 内存。这种悬殊的差距,让人不禁好奇。
- 在 Chrome 地址栏输入 chrome://tracing 并访问
- 点击页面中的 “Record” 按钮,进入录制配置界面
- 选择 “Manually select settings” 选项
- 点击 “Edit categories” 按钮,打开配置列表
- 在弹出的类别列表中,务必勾选 memory-infra。
- 点击 “OK” 确认配置后,再次点击 “Record” 开始录制。等待一段时间后点击结束
- 录制结束后,点击键盘的 M 键查看具体的内存快照


从上图可见,blink_gc 占用 4GB 内存,这可能并非内存泄漏,而是 Blink 的 Oilpan GC 策略导致的正常现象。其核心机制是内存池化 (Memory Pooling):Blink 会预先向操作系统申请大块的连续内存区域。页面中几乎所有的 Blink C++ 对象(包括大量临时的字符串、数组等)都在这个大内存池中进行分配,以提升效率。
当这些短生命周期的对象不再被引用时,它们在逻辑上被视为“垃圾”,但它们所占用的物理内存并不会立即归还给操作系统。GC 回收器会根据当前的内存压力 (Memory Pressure) 来决定何时执行彻底的清理。
在本案例中,机器总内存高达 64GB,资源充裕,Chrome 判断无需迫切回收。为避免不必要的性能开销(一次完整的 GC 会消耗 CPU 资源),GC 选择推迟回收操作。因此,我们看到的 4GB 占用,实际上是 Oilpan GC 持有的一个较大的内存池,其中包含了活动对象和大量待回收的“垃圾”对象。只要这个内存池的大小趋于稳定,没有出现持续、无节制的增长,通常就不构成内存泄漏问题。
HT 与内存泄露
综合上述的三部分内容,我们捋清了内存泄漏问题的主要原因,并掌握了对应的排查方法。而在 HT 框架中,内存泄漏的问题在 3D 场景中最为常见,由于 HT 的 3D 是基于 WebGL 实现的,此类泄漏往往会表现得尤为明显。
为了清晰呈现 HT 3D 中的内存泄漏问题,我们设计了一个简单的对照实现来进行演示。
对照实验
在展开实验前,我们先简要了解下 HT 框架的核心架构。HT 采用 MV 架构模式,在 HT 的框架设计中, Data 模型和 View 视图是分离的,二者之间通过 Event 事件监听和派发机制来建立起数据绑定。

在实验操作前,我们可以打开 Chrome DevTools -> Performance (性能) 面板,并且点击面板中的录制按钮,记录整个实验过程,这能帮助我们在操作结束后,回溯并分析全程性能和内存的变化情况。

实验环境:
浏览器:Chrome 138.0.7204.101(64位)
显卡:NVIDIA GeForce GTX 1660 Ti
处理器:Intel(R) Core(TM) i5-10400F CPU @ 2.90GHz (2.90 GHz)
第一次实验
我们通过按钮不断创建新的视图,当页面中超过一定数量Graph3dView 时,可以看到第一个场景“崩溃”,但是当我们删除最后一个 Graph3dView 后,第一个场景又恢复了。

我们可以从 Performance 面板中观察到整个过程:
- 当首个 WebGL 上下文被销毁后,JS 堆内存出现明显下降。
- 删除最新的视图后,首个 WebGL 恢复,且删除后事件监听器占用的内存下降,由于 HT 是 MV 框架,虽然浏览器销毁了 WebGL 上下文,但是视图的数据模型仍然保留,这也就是首个视图“复活”的原因。

第二次实验
我们将所有的 Graph3dView 都绑定到一个 window.dataModel 上。具体可以参考下图:

同实验一,我们也通过按钮创建多个视图,在告警后删除最后一个视图。可以发现,当删除了最后一个 Graph3dView ,第一个场景也并没有恢复。

我们从 Performance 面板中观察到整个过程:
- 当首个 WebGL 上下文被销毁后,JS 堆内存出现明显下降
- 删除最新的视图后,首个 WebGL 没有恢复,且删除后事件监听器占用的内存也没有出现下降的情况
照第一组实验的结论来说,只要数据模型还在,视图应当“复活”,但是视图并没有“复活”。

为什么会出现上述两种情况?这是因为第二次,并没有正确地将 Graph3dView 清除。可以看一下两次实验系统的内存对象引用关系。

第一次

第二次
第一次实验,页面上有 19 个 Graph3dView,在内存中看到有 19 个 Graph3dView 对象,而第二次页面上仅有 7 个 Graph3dView,但是内存中有 17 个 Graph3dView 对象。这就说明了第二次的 Graph3dView 并没有被正确垃圾回收,这也就导致了即使移除了一个 Graph3dView,第一个场景也并不会恢复。打开其中一个 Graph3dView 可以看到,Graph3dView 与 window.dataModel 存在引用关系导致的。

解决方案
从上述的对照试验中,可以看出使用全局变量存储视图实例是导致内存泄漏的主要原因。当多个 Graph3dView 共享同一个全局 dataModel 时,即使删除视图,由于全局引用依然存在,这些视图无法被垃圾回收。
针对于内存泄漏可以通过以下几个方案解决:
1、避免全局变量引用
该方案从业务架构层面上解决内存泄漏问题,可采用以下实现方式:
- 使用模块化设计代替全局变量存储
- 采用弱引用等机制管理视图对象
- 建立专门的视图管理器统一管理实例
2、视图复用机制
从实验上可以看出,频繁创建和销毁视图会带来显著的性能损耗。在实际的业务场景中,可以通过复用视图来提升性能。在切换视图时,仅需要通过 dataModel.clear() 清空数据模型,重新对视图进行反序列化即可。
3、资源释放
对于必须要频繁创建/销毁视图的特殊场景,可在销毁前执行以下操作:
const dm = new ht.DataModel();
view.setDataModel(dm);
view.dispose();
关键要点:
- 创建新的 dataModel 实例替换原有引用
- 有效解除视图与业务数据的关联关系
- 3D 视图上存在 dispose 方法,用于主动释放 gl 的资源
需要注意:在具体的项目中,优先考虑上两个方案,此方案适用于必须销毁视图的特殊情况。

4、事件管理优化
在处理模块通信上,可以考虑使用 HT 的事件派发器进行。项目全局上创建一个事件派发器,模块间消息传递使用派发器进行:
const notifier = new ht.Notifier();
const func = function(e) {}
notifier.add(func); // 添加监听函数
notifier.remove(func); // 删除监听函数
notifier.fire(func); // 派发事件
关键要点:
- 统一使用事件派发器进行跨模块通信,避免不同模块间的直接调用、依赖,减少内存泄漏风险
- 若模块需要销毁,在销毁前需移除相关事件监听
- 必须使用具名函数而非匿名函数作为事件处理器
总结
在前端开发过程中,开发者应持续关注内存变化。内存泄漏问题并非都是 “爆发式显现”,更多是 “渐进式累积” —— 初期往往难以察觉,但随着时间推移,过高的内存占用会直接拖慢运行性能;对于基于 WebGL 的应用,甚至可能引发上下文丢失、页面白屏等严重问题。因此,对待内存泄漏,我们必须保持常态化关注的心态。
您可以至图扑软件官网查看更多案例及效果: