ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

前端内存泄漏排查实战:从垃圾回收到闭包引用,手把手教你用Chrome DevTools定位

前端内存泄漏排查实战:从垃圾回收到闭包引用,手把手教你用Chrome DevTools定位 写这篇东西的起因是我接手过一个“越用越卡、最后直接白屏”的线上系统。用户一打开页面内存就跟坐火箭一样往上涨过个二十分钟整个标签页直接崩掉。查到最后罪魁祸首就是一个藏在闭包里的定时器外加几个没清理的事件监听。那阵子天天跟 Chrome DevTools 里的内存曲线较劲踩了不少坑也把“前端内存泄漏”这件事从理论到实践彻底摸了一遍。很多前端同学一听到“内存泄漏”四个字就头皮发麻觉得这是后端或者 C 工程师该操心的事。但实际上只要你的页面需要用久了不卡、切页面不顿、跑数据大屏不崩垃圾回收GC和内存管理就跟你脱不了干系。JS 这门语言虽然自带垃圾回收机制不需要我们手动 malloc 和 free但它该回收的时候认不出来、不该回收的时候瞎留着问题一样会出现。这篇文章我会从 JS 的内存生命周期讲起把 V8 的垃圾回收机制拆开揉碎再重点说说闭包跟内存到底什么关系、什么情况下闭包会变成“内存杀手”最后给出一套我自己用下来最顺手的排查流程和工具打法。整篇偏实战也会把一些原理层面的东西讲清楚毕竟排查内存泄漏不懂原理真的只能瞎猜。1. 先搞懂 JS 的内存生命周期和垃圾回收机制1.1 内存从分配到回收的三个阶段不管什么语言程序使用内存的过程都可以拆成三步分配内存、使用内存、释放内存。C 语言里你得手动 malloc 和 freeC 里你得操心 new 和 delete。JS 作为一门高级语言把第一步和第三步都自动化了——你声明一个变量引擎自动给你分配内存你不再需要这个变量引擎通过垃圾回收器自动把内存收回去。听起来很美好但坑也埋在这里。垃圾回收器判断“何时回收”是有一套自己的逻辑的它并不知道你的业务逻辑里哪个对象“以后还用得上”。它只知道一件事这个对象现在还能不能“够得着”。这个“够不着就回收”的规则引申出 JS 内存管理的核心概念——可达性。内存模型上JS 的数据存储主要分两块栈和堆。基础数据类型number、string、boolean、null、undefined、symbol、bigint通常存在栈里引用类型对象、数组、函数则存在堆里。栈上的数据通过值访问函数调用结束就出栈内存自动释放堆上的数据是引用类型真正待的地方也是垃圾回收器的主战场。函数、闭包、对象这些东西只要还有人通过变量或者属性引用着它们它们就活在堆里不会被回收。1.2 可达性判断对象生死的唯一标准垃圾回收器判断一个对象能不能回收靠的是一个“从根出发能不能到”的搜索过程。所谓的“根”root在浏览器环境里一般指这几类东西全局对象 windowNode.js 里是 global当前正在执行的所有函数调用栈上的局部变量和参数当前所有微任务和宏任务队列中涉及的变量被其他存活对象引用的对象垃圾回收器会从这些根出发沿着引用链把所有能访问到的对象都“标记”一遍剩下的没被标记到的对象就是不可达的会被判定为垃圾等待回收。这个思路你应该很熟悉了——没错这就是经典的“标记-清除”算法。我再打个比方。你把根看成一座发电站对象是城市里的各个屋子引用关系就是电线。只有通着电的屋子才有资格留下哪个屋子被剪断了电线跟发电站彻底失去联系那它里面的灯就永远不会再亮了留着它还占着城区的地皮不如拆了重建。这个“拆”的动作就是垃圾回收器干的活。1.3 V8 引擎的分代回收策略V8 是 Chrome 和 Node.js 底层的 JS 引擎它的垃圾回收实现得很精细。它把堆内存分成了两大区域新生代Young Generation和老生代Old Generation。新生代空间小、回收频繁里面放的都是“活的时间短”的对象。新建的对象基本都会先分到这里。新生代用的是一个叫 Scavenge 的算法核心思路是把空间分成两个半区From 和 To新对象先放进 From等 From 快满的时候把还活着的对象复制到 To然后一次性清空 From再交换角色。这个算法的好处是效率极高坏处是空间得留一份备用属于典型的用空间换时间。对象在新生代里熬过了几轮垃圾回收还没死就会被晋升到老生代。老生代里放的是长命百年的对象比如全局变量、模块缓存、被闭包长期引用的数据。老生代的回收策略就是上面说的标记-清除偶尔还会配合标记-整理把存活对象紧凑排列解决内存碎片问题。老生代空间大回收一次比较耗时所以 V8 还搞了增量标记、并发标记这些优化手段目的就是别让垃圾回收卡住主线程太久。明白了这一点你对内存泄漏的认知就得升级一层内存泄漏的本质就是“本该晋升/该被回收的对象因为一个多余的引用被垃圾回收器误判为存活对象一直留在老生代里不出去”。它占着内存不干活老生代越来越大GC 时间越来越长页面就越来越卡。2. 闭包与内存泄漏现实中到底是啥关系2.1 闭包的本质保持“过去的环境”不消失闭包是 JS 面试必考的东西但很多人只停留在“函数内部能访问外部变量”这个层面。闭包的本质是函数会记住自己创建时所在的作用域哪怕这个外部函数已经执行完了内部函数依然能访问到外部函数的变量。这背后的实现机制涉及作用域链。每个函数都有一个[[Environment]]属性指向创建它时的词法环境。当内部函数被当成返回值扔出来或者被存到某个外部变量里这个词法环境就不会跟着外部函数一起销毁了。外部函数的变量对象被内部函数引用着垃圾回收器从根出发顺着引用链一查发现这些变量还有“电线”连着于是判定它们存活——即使外部函数已经执行完毕即使这些变量未来可能根本不会被用到。这个机制本身不是 leak它是 JS 实现数据封装和函数式编程的基石。真正的问题在于词法环境这个“保鲜盒”会把外部函数里所有的变量都包进去而不是只包你用到的那几个。2.2 闭包变成内存泄漏的两种典型姿势我先抛一个结论闭包本身不收内存收内存的是“闭包被长期持有”这个事实。具体来说导致问题的姿势就两种。第一种闭包被全局变量长期引用而且闭包作用域链里挂了大体积数据。比如你写了一个全局的缓存对象里面不断往里塞闭包每个闭包都带上了当时的上下文上下文里又有大对象、大数组、DOM 节点那么这些数据会跟着闭包永远活在老生代里永远不回收。第二种闭包只在某个局部作用域里短暂使用但它被错误的生命周期给“放生”了。最常见的就是事件监听器、定时器、Promise 回调里引用了外层变量而这些监听器或定时器又没有在合适的时机清理掉。这种情况下闭包其实是无辜的真正的问题是你让它的生命周期变得和页面一样长了。2.3 经典闭包泄漏案例拆解我写一个非常经典的例子你可以拿去复现function createLeak() { const heavyData new Array(1000000).fill(leak); return function leakFn() { console.log(heavyData.length); }; } const leakFns []; function addLeak() { for (let i 0; i 100; i) { leakFns.push(createLeak()); } }这段代码里leakFns是全局数组里面存了 100 个leakFn闭包。每个leakFn都引用着createLeak调用时的heavyData。million 级别的数组 × 100 个闭包几百万个字符串对象全部被钉在内存里。只要你不清空leakFns这些数据就永远不会被回收。问题是leakFn内部只用到了heavyData.length其实根本不需要保留整个数组。这里如果改成记录一个数字内存占用就能瞬间降下来function createFixedLeak() { const length 1000000; // 只保留必要信息 return function leakFn() { console.log(length); }; }看起来是一个很小的改动但这就是理解闭包内存引用的关键闭包会保留整个外部作用域而不是只保留你用到的部分。这个特性在 V8 里虽然有优化但优化的前提是——引擎能静态分析出你到底用到了哪些变量。一旦你的闭包里有eval、动态访问、或者大量使用this绕来绕去引擎没法精确分析它就会保守地把整个作用域链都保存下来泄漏面积瞬间扩大。2.4 闭包不背锅的场景说句公道话很多内存泄漏真不是闭包惹的祸闭包只是“背锅侠”而已。真正的元凶是那些持有闭包的容器——全局数组、缓存 Map、事件订阅中心——它们的生命周期太长了。比如很多团队喜欢搞一个 EventBus模块间通信全靠on方法注册回调。如果页面切换的时候忘了调用off方法去注销那么这个事件总线就一直持有着你组件里的回调函数而回调函数可能又引用了组件的 DOM 节点和状态数据。一整个组件树就被这么“吊”在全局事件总线上永远不回收。你说这是闭包的锅吗闭包只是被注册进去的那只“手”事件总线才是那个“锁”。所以排查这类问题的时候我建议你脑子里先过一遍什么东西被什么东西引用了谁的命最长。页面的生命周期、全局对象的生命周期、事件总线的生命周期这些都是“长命根”组件、局部变量、临时数据这些都是“短命鬼”。长命根引用了短命鬼短命鬼就死不掉了内存泄漏就来了。3. 前端常见内存泄漏场景逐一拆解3.1 意外全局变量最隐蔽的泄漏把变量直接挂到 window 上这在严格模式下会报错但非严格模式下最容易出问题。很多同学写代码的时候手一抖忘记加let或者const一个变量就直接变成全局的了。比如function processData(data) { result data.map(item item * 2); // 忘了写 const return result; }这里的result被隐式创建成了全局变量。函数调用完了result还挂在 window 上只要页面不关它就在那里。这个还好更可怕的是循环里创建隐式全局变量或者比较长的链式处理过程中不断往全局塞临时结果内存里的残留对象会越来越多。排查这类问题的思路其实很简单。打开 DevTools 的 Sources 面板搜索window.关键字看看全局对象上挂了哪些不该挂的东西。经验之谈线上项目里 window 上一堆自定义属性绝大多数都是历史遗留问题能清理就清理能收敛就收敛。3.2 定时器与事件监听器永远不清理的“钉子户”定时器是前端内存泄漏的重灾区。比如你有这样一个操作function startUpdating() { const tooltip document.getElementById(tooltip); setInterval(() { const now new Date(); tooltip.textContent ${now.getHours()}:${now.getMinutes()}:${now.getSeconds()}; trackUsage(now); // 这个函数内部又创建了对象并闭包引用 }, 1000); }这段代码本身没问题问题是你什么时候调用clearInterval。很多组件在初始化的时候调用了startUpdating组件销毁的时候没有清理定时器。定时器还在跑定时器里的回调函数就还引用着tooltip这个 DOM 节点和trackUsage这个函数以及它引用的所有东西。组件虽然从页面上移除了但 DOM 节点“吊”在定时器的引用链上回收不掉。处理办法也很简单粗暴用setInterval的地方永远要在生命周期结束时配对clearInterval。我自己习惯在一个对象里把定时器 id 存下来组件卸载方法里统一清理。React 的 useEffect 里 return cleanupVue 的 onUnmounted 里 clear这些 hook 设计出来就是干这个的别偷懒。事件监听器同理。addEventListener了就要考虑什么时候removeEventListener。特别要注意的是第三方库——图表库、地图库、拖拽库它们会在全局挂监听器或者自己内部缓存节点引用。如果你用了这些库又不销毁实例内存泄漏大概率躲不掉。3.3 脱离 DOM 的引用你以为看不见它就“死了”这个场景最骗人。你以为把 DOM 从页面上移除它就会被回收。实际上如果 JS 代码里还留着它的变量引用这个 DOM 节点就永远不会被回收哪怕它早就不在文档流里了。来看这个例子const nodes []; function createAndRemoveNode() { const node document.createElement(div); node.innerHTML spanhello/span; nodes.push(node); document.body.appendChild(node); // 忘记写document.body.removeChild(node) }更隐蔽的版本是你把节点的引用存在了闭包、Map、或者 Vue/React 的内部缓存里。页面上的 DOM 没了但引用还在内存里的节点对象就成了“看不见的幽灵”每次操作还会触发布局和样式计算白占资源。排查这类问题有一个技巧Chrome DevTools 的 Elements 面板里某个节点被移除后如果你在 Console 里用document.querySelector还能找到它或者你之前在 Console 里存过它的引用那它在内存里就是活的。更精确的判断方式是切堆快照查 DOM 节点的 Retained Size。我后面讲排查流程的时候会细说。3.4 Map、Set 和缓存无声的内存黑洞Map 和 Set 是 ES6 之后非常好用的数据结构但用不好就是内存黑洞。你往 Map 里塞了个对象做 key或者塞了个大数组做 value然后业务代码里忘了把它删掉。这个 Map 只要还活着里面所有 key-value 就全活着。这里有一个很经典的坑用 Map 做缓存的时候只负责往里 set不负责按需 delete。时间一长Map 里堆满了过期数据内存占用飙升。比如实现一个简单的记忆化函数const cache new Map(); function expensiveCalculation(arg) { if (cache.has(arg)) return cache.get(arg); const result doSomethingHeavy(arg); cache.set(arg, result); return result; }如果arg是不可枚举的对象每次传进来的对象都不相同那么这个 cache 只会无限膨胀永远不会命中而doSomethingHeavy的结果又被永远保留着。正确做法是给缓存加容量上限超过就把最早进去的清掉或者用WeakMap。3.5 WeakMap 和 WeakSet解决“对象引用与回收”的银弹WeakMap 和 WeakSet 是为解决这类问题专门设计的数据结构。它们的特殊之处在于键是“弱引用”——也就是说如果一个对象当前只被 WeakMap 当作 key 引用着没有其他地方再引用它那么垃圾回收器会直接把它回收掉WeakMap 里的这条记录也会自动消失。这正是记忆化缓存和对象与附加数据关联场景的解法const cache new WeakMap(); function calculateWithCache(obj) { if (cache.has(obj)) return cache.get(obj); const result computeHeavy(obj); cache.set(obj, result); return result; }使用 WeakMap 时需要注意它的键必须是对象不能是基础类型。另外 WeakMap 没有 size 属性不能遍历键值因为“里面到底还有什么键”这件事对用户是不可见的——引擎内部才能决定到底回收了没有。如果你需要遍历缓存内容那还是得用 Map 手动管理生命周期。4. 排查 JS 内存泄漏的完整实操流程4.1 第一步用 Performance 面板确认“内存到底涨了没”排查内存泄漏第一步不是打开 Memory 面板而是先确认问题确实存在。很多“越用越卡”其实是渲染问题、网络问题、或者大量的重排重绘内存只是背锅的。我常用的方法是用 Chrome DevTools 的 Performance 面板录一段操作路径。录制前先勾上 Memory 复选框这样录制的结果里会带一条蓝色的内存曲线。然后你在页面上重复执行某个操作——比如反复打开关闭弹窗、滚动列表、切换 Tab——观察内存曲线。关键看三件事操作结束回到静止状态后内存曲线有没有回落到操作前的水平。如果每次操作后内存都往上涨一点点回不去那就是泄漏的信号。反复操作同一路径内存会不会一路“锯齿式上涨”。锯齿是正常的GC 会回收一部分但锯齿的底部必须保持水平或缓慢下降。如果锯齿底部稳定上升说明回收回来的永远小于新占用的泄漏实锤了。最后强制触发一次 GCPerformance 面板里的垃圾桶图标看内存能不能降下来。如果强制 GC 都降不下来说明泄漏对象是被强引用钉死的回收器也无能为力。这一步的核心目标是把“疑似内存问题”升级成“确定的内存持续上涨”。有了这个确定性后面的堆快照分析才不会大海捞针。4.2 第二步切堆快照并对比定位“多出来的是谁”确认泄漏之后就该请出 Memory 面板了。Memory 面板里有三个工具我实际排查中用得最多的是 Heap Snapshot堆快照和 Allocation Instrumentation on Timeline分配时间线。堆快照的思路是拍两组照片做对比操作前拍一张反复操作 N 次之后再拍一张。然后切到 Summary 视图按 Retained Size 排序Blink 引擎和 V8 引擎自己内部的对象类型都会在这里列得很清楚。对比的时候我主要看这几个东西的数量变化(string)类型的数量。字符串疯狂增加往往意味着你在循环拼接或者缓存了文本内容。(closure)类型的数量。闭包数量上涨通常跟定时器、事件回调、组件状态没清理有关。HTMLDivElement、Document这些 DOM 节点类型。数量上涨说明 DOM 节点被非文档引用挂住了。Object和Array类型的 Retained Size。这两个是最容易看出异常的大头。找到数量异常上涨的类型后点开它看底下展开的实例列表然后挨个查看引用路径。DevTools 的 Retainers 面板会告诉你“是谁引用着它”——这个引用链是排查内存泄漏最核心的信息。比如你看到一个 HTMLDivElement 的 Retainers 一路指向一个闭包闭包又指向一个全局的 Map那问题基本就定位到了。实际操作中我建议这样对比操作前快照 A操作 20 次之后快照 B再操作 20 次之后快照 C。先对比 A 和 B 找出新增加的对象类型再对比 B 和 C 验证这些对象是不是持续线性增长。如果 B 比 A 多、C 比 B 更多那真的是泄漏了如果 C 基本等于 B说明操作到一定阶段就稳定了那可能只是缓存不算泄漏。4.3 第三步用 Allocation Timeline 捕捉“泄漏的瞬间”堆快照擅长回答“谁占着内存”但回答不了“这些对象到底是什么时候被创建出来的”。Allocation Instrumentation on Timeline 就是用来补这个空白的。录制的时候它会记录每帧里哪些代码新分配了哪些对象。你录一段操作结束后时间线上会有密密麻麻的蓝色条高度代表分配的内存量。点开最高的那些条DevTools 会显示分配点的调用栈。看到调用栈的那一刻基本就水落石出了——学校函数、组件初始化、路由切换、某个第三方库的 tick一目了然。这个工具对定位“一次性分配大量内存但没释放”的场景特别好使。比如某个函数每次执行都创建一个巨大的数组或者字符串然后被闭包引用着扔进缓存里。分配时间线会直接把这个函数的调用栈怼到你脸上你连猜都不用猜。4.4 第四步用 performance.memory 做线上监控和自动预警本地排查搞定之后还得防线上。performance.memory只有 Chrome 支持可以拿到 JS 堆的大小信息。你可以每隔一段时间采一个点把这个数据上报到监控平台设置一个阈值告警。function sampleMemoryMetrics() { if (performance.memory) { const { usedJSHeapSize, totalJSHeapSize, jsHeapSizeLimit } performance.memory; reportTelemetry({ usedJSHeapSize, totalJSHeapSize, jsHeapSizeLimit, timestamp: Date.now() }); } } setInterval(sampleMemoryMetrics, 10000);这个方案的思路很简单usedJSHeapSize 如果持续上涨且强制 GC 也不降就是内存泄漏的第一信号。配合采样上报你能在用户被卡死之前提前发现异常页面和异常操作路径。4.5 实战案例地图组件销毁后内存持续上涨我挑一个真实案例讲完整条排查链路。那是一个数据可视化大屏项目里面有一个地图组件每次切换筛选条件的时候会重新渲染。用户反馈说切几次之后页面变得很卡风扇呼呼转。我先用 Performance 录制了一段连续切换筛选条件的操作内存曲线呈明显的阶梯式上涨每切一次条件涨一截强制 GC 也下不来。确认内存泄漏后我拍了两组堆快照对比发现Object和(closure)的数量翻倍增长。点开(closure)实例列表看到一个 Retainers 里挂着SVGElement和Function。顺着引用链往上一路查最后发现是地图渲染库在每次重绘的时候都往一个内部的Layer数组里 push 新的图层引用而图层对象里又绑定了一个闭包闭包里保存着整个数据集的引用。组件虽然调用了销毁 API但销毁逻辑只清了画布上的元素没清内部图层数组。这属于第三方库的 bug但站在我们的角度只要在组件卸载后手动把数据引用置空也能打破这个引用链。解决办法很简单销毁地图之前先手动layer null、data null把那根“最粗的引用线”剪断。改了之后重新跑 Performance内存曲线恢复了水平状态问题解决。这个案例的启示是有时候不是你的代码泄漏而是第三方库的缓存机制太坑。你要做的就是在自己的边界上把能断的引用都断了。5. 常见问题速查表与团队落地建议5.1 内存泄漏常见问题速查表我把日常排查中最常遇到的几种内存泄漏场景、表现和解决办法整理成一张表方便你遇到问题时直接对着查。泄漏场景典型表现定位方法解决办法隐式全局变量窗口对象属性不断增加Sources 搜索window.前缀严格模式写代码用完置 null定时器未清理关闭页面或组件后 CPU 仍然活跃Allocation Timeline 看回调调用栈生命周期尾部 clearInterval/clearTimeout事件监听器未移除DOM 节点无法回收堆快照看 Detached DOM 节点removeEventListener事件委托替代逐个绑定闭包长期持有大对象(closure) 数量上涨Retained Size 巨大堆快照看闭包引用链只保留必要变量引用置空Map/Set 无限增长Object 数量线性上涨难以回落堆快照对比 Map 内部 Slot 数量加容量上限用 WeakMapDOM 脱离文档但 JS 持有引用HTMLDivElement 数量异常堆快照看 Detached 节点移除节点时将引用置 null第三方库实例未销毁图表/地图库内部缓存无限增长堆快照查库函数闭包链调用销毁 API边界引用手动置空5.2 从机制上预防code review 清单与自检测试排查固然重要但最好的内存泄漏修复是“从一开始就让它别发生”。我在团队里定了一套代码审查清单每次看到下面这些情况就会重点留意有没有在组件卸载/页面销毁时清理定时器和事件监听器React 的 useEffect cleanup、Vue 的 onUnmounted必须成对出现。有没有往全局数组、Map、对象里塞了不该塞的东西临时数据用完要删缓存要有上限。有没有把大对象直接挂在闭包或者全局变量上这种引用会跟随整个页面生命周期能精简就精简。第三方库实例图表、地图、组件库有没有在销毁时调用对应的销毁 API有没有用 WeakMap/WeakSet 来代替不该牵扯对象生命周期的强引用缓存除了代码审查我们还会在页面生命周期关键节点主动做一次内存自检。比如大屏项目我会在组件挂载前采样一次内存基线卸载后再采样一次对比两次的 usedJSHeapSize。如果卸载后比挂载前高出 5MB 以上直接抛一个告警。这个方案简单粗暴但对发现“销毁不干净”的问题非常有效。export function reportDiffAfterUnmount(beforeSize, componentName) { setTimeout(() { const after performance.memory?.usedJSHeapSize || 0; const diff after - beforeSize; if (diff 5 * 1024 * 1024) { console.warn([memleak] ${componentName} unmount后内存${(diff / 1024 / 1024).toFixed(2)}MB); reportTelemetry({ componentName, diff }); } }, 3000); }这里 setTimeout 3 秒是为了等 GC 跑一段时间把该回收的回收掉这样对比出来的数值才接近真实残留。5.3 团队规范让内存护理成为开发的默认动作最后说点我在团队里落地的经验。纯粹靠个人自觉去避免内存泄漏效果很有限因为大家在开发的时候脑子里想的是功能和交互根本不会时刻记着一个定时器有没有清理。所以我把这些规则沉淀成了三个默认动作已经写进团队的开发规范里了。第一自定义 Hook 或者 Mixin 里的资源必须自清理。凡是涉及 setInterval、事件绑定、外部库实例的都在内部封装好销毁逻辑不允许把定时器 id 抛给业务组件自己去管。这叫“资源封装完整使用方零心智负担”。第二路由切换是内存泄漏的高发时机所有页面级组件都必须提供销毁逻辑。React 项目里我会要求在路由级组件 useEffect 里 return 一个清理函数不管有没有资源要清这个清理函数必须存在。这样后面有人新增资源时会下意识把它加进已有的清理函数里。第三做一次大版本重构或者公共组件升级之后必须跑一遍内存回归。不用很复杂Performance 录一段关键路径对比重构前后的内存曲线基线。这个动作能让你提前发现很多第三方依赖升级带来的隐蔽泄漏。排查 JS 内存泄漏这件事我做下来的最大感受是它特别像中医看病得望闻问切。望是看 Performance 的内存曲线闻是听用户“页面越用越卡”的描述问是问自己“最近改了哪个模块”切是把堆快照当脉象一根根引用链摸过去直到找到那条“该断没断”的线。技术本身不复杂复杂的是耐心和方法。希望这篇文章能帮你把 JS 内存管理这件事从“玄学”变成“工程学”下次再遇到内存问题能自己手起刀落干脆利落。
返回列表