你写的页面刚上线时很流畅,用户点了 20 分钟之后,开始卡了。再点 10 分钟,浏览器标签页变成了"无响应"。
你打开控制台,没有任何报错。刷新一下页面,又好了。
用户反馈:"这个页面用久了会卡。"
你复现了半小时,终于发现问题——内存占用一直在涨,从刚打开时的 80MB,涨到了 800MB,直到浏览器把标签页杀掉了。
这不是 bug,至少不是传统意义上的 bug。这是内存泄漏。JavaScript 有垃圾回收(Garbage Collection,简称 GC),但 GC 不是万能的。有些垃圾,它根本扫不走。
这篇文章,我们就来聊聊 JS 的内存管理——不是算法论文,而是前端开发者每天都在打交道的内存泄漏问题。
GC 不是魔法:它只是自动扫地
很多人学 JavaScript 时,都听过这样一句话:
"JS 会自动管理内存,不需要你手动释放。"
这句话没错,但它容易让人产生一个误解:GC 是智能管家,会自动判断"这个东西你以后还用不用",然后帮你清理。
真相是:GC 只是一个扫地机器人,按固定规则扫,不懂业务逻辑。
主流的 GC 算法是标记-清除(Mark-Sweep),核心逻辑就两步:
-
标记:从"根对象"(全局变量、当前执行栈)出发,能访问到的对象,标记为"存活"
-
清除:没被标记的对象,视为"垃圾",回收内存
说白了,GC 的判断标准只有一条:
"还有没有人能访问到这个对象?"
它不会关心"你以后还用不用",它只关心"现在能不能访问到"。
来看一个最简单的例子:
function createUser() {const user = { name: "张三", age: 25 };console.log(user.name);}createUser();// 函数执行完,user 还能被访问到吗?// 不能。局部变量出了作用域,没人引用它了。// GC 下次扫过来,会把 { name: "张三", age: 25 } 这个对象清理掉。
很简单,对吧?但现实世界里的内存问题,远比这个复杂。
V8 的 GC 长什么样?分代回收
你不需要背 V8 的 GC 算法,但你需要知道它的基本分工。因为不同的"代",回收策略不一样,泄漏的表现也不一样。
V8 把内存里的对象分成两拨人,管理方式完全不同。
新生代:刚来的,活不长
新创建的小对象,先放到新生代。这里空间很小(默认 1-8MB),但 GC 频率极高。
V8 用了一种叫 Scavenge 的算法。它的核心思路很巧妙:
把新生代的内存分成两半——From 空间和To 空间。新对象先往 From 空间里放。GC 触发时,把 From 空间里还活着的对象,复制到 To 空间,然后把 From 空间整体清空。最后,From 和 To 交换角色,下一轮继续。
用个类比,Scavenge 就像快递分拣的两个筐:
-
新来的快递(新对象)先放进 A 筐(From)
-
每天下班时,把 A 筐里还有用的快递挑出来,搬到 B 筐(To)
-
然后把 A 筐整个倒掉,垃圾全清理
-
第二天,A 筐变成空的接收筐,B 筐里的快递继续用
-
第三天,角色再换回来
// 示意:Scavenge 的工作流程(非真实代码,仅帮助理解)// 初始状态:From 空间有 4 个对象,To 空间是空的// From: [objA(存活), objB(垃圾), objC(存活), objD(垃圾)]// To: []// GC 触发:// 1. 遍历 From 空间,标记存活对象// 2. 把 objA 和 objC 复制到 To 空间// 3. 清空整个 From 空间(objB 和 objD 直接丢掉)// 4. 交换角色:To 变成新的 From,From 变成空的 To// 结果:// From: [objA, objC] ← 存活对象在这里,继续被程序使用// To: [] ← 空的,等下一轮 GC 当接收筐
为什么用"复制"而不是"标记清除"?
因为新生代对象的特点是:大部分活不长。Scavenge 只需要复制少量存活对象,然后整块内存清空,效率极高。但它有个代价:内存只能用一半(From 和 To 各占一半)。所以对大对象不适合,只适合小对象。
老生代:熬过来的,不好动
如果对象在新生代里经过几次 GC 还活着,V8 会说:"这对象挺能活,搬到老生代吧。" 这里空间大,GC 频率低,但回收过程更复杂。
老生代用的是 Mark-Sweep + Mark-Compact。别被名字吓住,拆开来就是两步:
第一步:Mark(标记)
从"根"(全局变量、执行栈)出发,沿着引用链走,能访问到的对象,全部打个标记——"这个人还活着"。
// 示意:标记阶段的工作流程// 假设内存里有这些对象:// window.user → { name: "张三", profile: profileObj }// profileObj → { avatar: "url", settings: settingsObj }// settingsObj → { theme: "dark" }// window.cache → { data: hugeArray } // 被全局变量引用,可达// orphanObj → { a: 1 } // 没有任何人引用,不可达// 标记阶段:// 1. 从 window(根)出发,找到 window.user → 标记 user// 2. 从 user 找到 profileObj → 标记 profileObj// 3. 从 profileObj 找到 settingsObj → 标记 settingsObj// 4. 从 window 找到 window.cache → 标记 cache// 5. orphanObj 没被任何人引用,不标记// 标记完成后:// 存活对象:user, profileObj, settingsObj, cache// 垃圾对象:orphanObj
第二步:Sweep(清除)
遍历整个老生代内存,把没标记的对象占用的内存回收掉。orphanObj 这种没人引用的对象,就在这里被清理。
// 示意:清除阶段// 遍历内存:// - user(已标记)→ 保留// - profileObj(已标记)→ 保留// - settingsObj(已标记)→ 保留// - cache(已标记)→ 保留// - orphanObj(未标记)→ 回收内存
第三步:Compact(压缩)—— 可选
清除之后,内存里会出现碎片。就像你搬家时,把房间里的垃圾清走了,但剩下的东西东一个西一个,中间空出很多小缝隙。如果来了一件大家具(大对象),可能没有一块连续的空地放得下。
所以 Mark-Compact 会再做一步:把存活对象挤到内存的一端,空出另一端整块连续的空间。
// 示意:压缩阶段// 清除后的内存布局(假设):// [user][空][profileObj][空][空][settingsObj][空][cache][空][空][空]// ↑ 碎片 ↑ 碎片 ↑ 碎片// 压缩后:// [user][profileObj][settingsObj][cache][空][空][空][空][空][空][空]// 存活对象全部挤到左边,右边是一整块连续的空闲内存
Scavenge、Mark-Sweep、Mark-Compact 的区别,用一句话总结:
-
Scavenge:快递分拣,两个筐倒来倒去,适合小件(新生代)
-
Mark-Sweep:先查房标记,再清理空房间,适合大件(老生代)
-
Mark-Compact:清理完再把东西推到墙角,腾出大块空地
关键认知:GC 是有代价的
GC 不是免费的。每次 GC 触发时,JS 主线程会暂停(Stop-The-World),等 GC 扫完地再继续执行你的代码。
-
新生代 Scavenge:只复制少量对象,暂停 几毫秒,你感知不到
-
老生代 Mark-Sweep:要遍历整个老生代,暂停 几十到几百毫秒,你的页面就会"卡一下"
-
老生代 Mark-Compact:还要搬动对象,更慢,暂停可能超过 100 毫秒
现代 V8 有优化(并发标记、增量标记),但完全消除暂停是不可能的。所以,减少内存泄漏不仅是"省内存",更是减少 GC 触发频率和暂停时间。
一句话:你的内存泄漏,最终都会变成用户的卡顿。
但总有扫不干净的地方:四种最常见的内存泄漏
GC 按规则扫地,但规则之外,有四种"漏网之鱼",是前端开发者最高频踩的坑。
1. 意外的全局变量
这是最隐蔽、最低级、但最常见的泄漏。
function processData() {// 糟糕!忘了写 let / const / vardata = new Array(10_000_000).fill("x"); // 1000 万项,约 80MBconsole.log("处理完成");}processData();// 函数执行完,data 会被释放吗?// 不会。因为 data 没有声明,它变成了 window.data(全局变量)// 全局变量是 GC 的"根",永远不会被释放。
为什么泄漏:在浏览器里,未声明的变量默认挂载到 window 对象上。而 window 是 GC 的"根",从根出发一定能访问到 data,所以 GC 永远不会清理它。
正确做法:
function processData() {const data = new Array(10_000_000).fill("x"); // 显式声明console.log("处理完成");// 函数执行完,data 是局部变量,没人引用,GC 会正常清理}processData();
严格模式下("use strict"),未声明变量会直接报错,根本不会给你泄漏的机会。所以:严格模式是防全局泄漏的第一道防线。
2. 闭包里的"夹带私货"
闭包是 JS 的精髓,但也是内存泄漏的重灾区。
情况一:闭包直接引用了大对象
function createReporter() {// 一个巨大的数据对象const hugeData = new Array(10_000_000).fill("report-data"); // 约 80MBreturn function reporter() {// 闭包直接访问了 hugeDatareturn hugeData.length; // 返回数据条数};}const report = createReporter();// hugeData 会被释放吗?// 不会。reporter 闭包实际访问了 hugeData,所以 hugeData 会被保留
为什么泄漏:从 JS 语义上说,闭包可以访问外部词法环境中的变量。虽然现代引擎(如 V8)通常会做优化,只保留闭包实际引用到的变量,但在这个例子里,reporter 确实用到了 hugeData,所以 hugeData 会被闭包保留,无法释放。
情况二:更隐蔽的间接引用
还有种更隐蔽的情况,也是面试常考的陷阱:
function createReporter() {const hugeData = new Array(10_000_000).fill("report-data"); // 约 80MB// 把 hugeData 挂到一个对象上const config = {data: hugeData, // config 引用了 hugeDatatitle: "报告已生成",};return function reporter() {// 闭包只访问了 config.titlereturn config.title;};}const report = createReporter();// hugeData 会被释放吗?// 不会。虽然 reporter 只读了 config.title,但闭包引用了整个 config 对象// config 对象里又挂着 data: hugeData,所以 hugeData 间接被保留了
为什么泄漏:reporter 闭包引用了 config 对象。config 对象又引用了 hugeData。GC 的判定规则是"可达性"——只要从根(闭包)出发能访问到 hugeData,它就不会被释放。即使你的代码里只写了 config.title,引擎也必须保留整个 config 对象。
正确做法:只暴露真正需要的变量,不要把整个大对象带进去。如果有大对象,处理完数据后,让闭包只引用最小化的结果。
function createReporter() {const hugeData = new Array(10_000_000).fill("report-data");// 先处理完数据,把结果提取出来const result = processHugeData(hugeData); // 假设处理后只有 1KB// 返回的闭包里只引用 result,不引用 hugeData 和任何中间对象return function reporter() {return result; // 只用到处理后的结果};}const report = createReporter();// 函数执行完,hugeData 只在 createReporter 内部被使用// 闭包没有引用它,GC 可以正常释放// 只有 1KB 的 result 被闭包保留
如果你不确定闭包引用了什么,可以在 Chrome DevTools 的 Memory 面板里拍 Heap Snapshot,搜索 hugeData,看看是否被 reporter 函数闭包 retaining。
3. 事件监听和 DOM 引用
这是前端最经典的泄漏场景,尤其是在单页应用里。
class Dialog {constructor() {this.element = document.createElement("div");this.element.innerHTML = "<button>确认</button>";// 给按钮绑定点击事件this.button = this.element.querySelector("button");this.button.addEventListener("click", this.handleClick.bind(this));}handleClick() {console.log("确认了");}destroy() {// 只是把 DOM 从页面移除this.element.remove();// 但没有移除事件监听器!}}// 使用const dialog = new Dialog();document.body.appendChild(dialog.element);// 用户关闭弹窗dialog.destroy();// dialog.element 从页面移除了,但事件监听器还在!// this.button 引用着 DOM 节点,DOM 节点通过 bind(this) 引用着 dialog 实例// 整个 dialog 实例,包括里面的所有数据,都无法释放
为什么泄漏:DOM 节点被事件监听器引用,事件监听器又通过 bind(this) 引用着 dialog 实例。即使 DOM 不在页面上了,这条引用链还在内存里,GC 扫不到它。
正确做法:移除 DOM 时,务必清理事件监听器。
class Dialog {constructor() {this.element = document.createElement("div");this.element.innerHTML = "<button>确认</button>";this.button = this.element.querySelector("button");// 绑定事件,但先存一份引用,方便后续移除this.boundClick = this.handleClick.bind(this);this.button.addEventListener("click", this.boundClick);}handleClick() {console.log("确认了");}destroy() {// 第一步:移除事件监听器(关键!)this.button.removeEventListener("click", this.boundClick);// 第二步:手动断开引用链this.button = null; // 断开对 DOM 节点的引用// 第三步:从页面移除 DOMthis.element.remove();this.element = null; // 断开对 DOM 的引用}}
现代框架(React/Vue)内部已经处理了大部分事件清理,但如果你直接操作原生 DOM,或者写自定义组件库,这个坑必须自己填。
4. 被遗忘的定时器和回调
这是最容易被忽视的泄漏场景,因为代码"看起来没问题"。
class DataPoller {constructor() {this.data = [];// 每秒拉一次数据this.timer = setInterval(() => {fetch("/api/data").then((res) => res.json()).then((data) => {this.data.push(...data); // 数据越积越多console.log("当前数据条数:", this.data.length);});}, 1000);}stop() {// 用户点击了"停止"// 糟糕!忘了 clearIntervalconsole.log("轮询已停止");}}const poller = new DataPoller();// 用户切换了页面,poller 实例理论上应该销毁// 但定时器还在跑,this.data 还在增长// poller 实例被定时器回调引用,永远无法释放
为什么泄漏:setInterval 的回调形成了一个闭包,持有对 this(即 poller 实例)的引用。即使 poller 从业务逻辑上"应该被销毁"了,定时器还在跑,GC 看到"还有人引用 poller",就不会释放它。
而且这段代码有两个问题:
-
定时器没清理:
stop()里没有clearInterval -
数据只进不出:
this.data无限累积
正确做法:
class DataPoller {constructor() {this.data = [];this.timer = null; // 先初始化,方便后续判断}start() {// 如果已经有定时器,先清掉(防止重复启动)if (this.timer) clearInterval(this.timer);this.timer = setInterval(() => {fetch("/api/data").then((res) => res.json()).then((data) => {// 只保留最近 100 条,避免无限增长this.data = [...this.data, ...data].slice(-100);});}, 1000);}stop() {if (this.timer) {clearInterval(this.timer); // 关键:清理定时器this.timer = null; // 断开引用}this.data = []; // 清理数据,释放内存}destroy() {this.stop(); // 确保定时器被清理this.data = null; // 彻底断开}}
在 React/Vue 组件里,这类问题尤其常见。组件卸载时,如果没有在 useEffect 的 cleanup 或 onUnmounted 里清理定时器、请求、事件监听器,泄漏就发生了。
怎么发现内存泄漏?Chrome DevTools 实战
知道了四种泄漏场景,但如果用户只说"页面卡了",你怎么定位问题?
下面是一套可落地的排查流程。
第一步:拍 Heap Snapshot,对比对象数量
打开 Chrome DevTools → Memory 面板 → 选择 Heap Snapshot → 点击拍摄按钮(📷)。
然后操作你的页面:反复打开关闭弹窗、切换路由、触发定时器……再拍第二张快照。
切换到 Comparison 视图,你会看到类似这样的数据:
| 构造函数 | 新增对象数 | 删除对象数 | 净增量 |
|---|---|---|---|
| (array) | 1200 | 50 | +1150 |
| HTMLDivElement | 50 | 0 | +50 |
| Detached HTMLDivElement | 50 | 0 | +50 |
关键信号:
-
Detached HTMLDivElement:DOM 节点不在页面上了,但还在内存里。大概率是事件监听没清理。 -
Array/Object持续净增:数据在累积,没有释放。检查定时器、缓存、全局变量。 -
Closure数量暴增:闭包持有外部引用,检查是否有大对象被闭包"夹带"。
第二步:Performance 面板看内存曲线
打开 Performance 面板,勾选 Memory,点击录制,然后操作页面 1-2 分钟。
你会看到一条内存曲线:
-
正常情况:内存有升有降(锯齿状),但整体在一个范围内波动。GC 会定期清理,内存回落。
-
泄漏情况:内存持续上升,GC 只能清理一小部分,整体趋势向上。像一条"爬楼梯"的曲线。
如果你看到一条"只上不下"的曲线,几乎可以确定存在内存泄漏。
第三步:找到 Retainer(谁在引用它)
在 Heap Snapshot 里,选中一个可疑对象(比如一个很大的 Array 或 Detached DOM 节点),看右侧的 Retainers 面板。
它会显示一条引用链:
Array (1000000 items)→ property "data" of Object @12345→ property "poller" of Window→ global property
这条链告诉你:
-
这个 100 万项的数组是
Object @12345的data属性 -
这个对象被
Window的poller属性引用 -
Window是全局对象,GC 的根
结论:window.poller 没有释放,导致它里面的 data 数组也无法释放。检查 poller 的 destroy 或 stop 方法是否正确执行了。
优化策略:不是"少用内存",而是"及时放手"
排查完了,怎么预防?三个实用的编码策略。
1. 用 WeakMap / WeakSet 做缓存
如果你需要一个"对象 → 元数据"的映射,但不想让元数据阻止对象被 GC,用 WeakMap。
// 错误做法:Map 会阻止 key 被 GCconst cache = new Map();function processUser(user) {if (!cache.has(user)) {cache.set(user, computeExpensiveData(user)); // user 被 Map 引用,永远不会释放}return cache.get(user);}// 正确做法:WeakMap 不会阻止 key 被 GCconst weakCache = new WeakMap();function processUser(user) {if (!weakCache.has(user)) {weakCache.set(user, computeExpensiveData(user));}return weakCache.get(user);}// 当 user 对象不再被业务代码引用时,GC 会自动清理它// 同时 WeakMap 中对应的条目也会消失(不需要手动删除)
WeakMap 的局限:key 必须是对象,不能是字符串或数字。而且你无法遍历 WeakMap(因为随时可能有条目被 GC 清理)。
它适合的场景:DOM 节点元数据、对象私有数据、计算缓存。
2. 事件监听:用 AbortController 一键清理
现代浏览器提供了 AbortController,可以批量移除事件监听器。
class Component {constructor() {this.controller = new AbortController(); // 创建一个信号控制器const signal = this.controller.signal;// 绑定事件时传入 signalthis.button.addEventListener("click", this.handleClick, { signal });this.input.addEventListener("input", this.handleInput, { signal });window.addEventListener("resize", this.handleResize, { signal });}destroy() {// 一键移除所有绑定了这个 signal 的事件监听器this.controller.abort();}}
好处:不需要逐个 removeEventListener,一行 abort() 全部清理。而且即使某个事件绑定时忘了存引用,也能通过 signal 统一断开。
3. 组件卸载时清理一切
如果你用 React,养成在 useEffect 里写 cleanup 的习惯:
useEffect(() => {const poller = new DataPoller();poller.start();// 这是 cleanup 函数,组件卸载时执行return () => {poller.stop(); // 清理定时器poller.destroy(); // 清理数据和引用};}, []);
Vue 的 onUnmounted 同理:
import { onUnmounted } from "vue";const timer = setInterval(() => { /* ... */ }, 1000);onUnmounted(() => {clearInterval(timer); // 组件卸载时清理});
核心原则:任何"启动了一个长期运行的东西"(定时器、事件监听、WebSocket、请求),都必须有对应的"停止并清理"逻辑。而且清理逻辑要和启动逻辑写在一起,别分开——分开就容易忘。
写在最后
JS 的垃圾回收器,是"自动扫地",不是"智能管家"。它按规则扫,不懂你的业务逻辑。
那些"忘了摘的监听器"、"没清理的定时器"、"闭包里夹带的大对象"——对 GC 来说,它们都是"还有人在引用",所以不扫。
内存泄漏最可怕的地方,不是技术复杂,而是"沉默"。它不会抛错,不会崩溃(一开始),只是让你的页面越来越慢,直到用户刷新。
理解 GC 不是为了让你的代码"更底层",而是为了让你的应用不卡。一个懂内存管理的前端开发者,和一个只懂语法的前端开发者,写出来的代码在用户体验上,差距是巨大的。
下次再有人问你"为什么页面越用越卡",你可以说:
"不是 JS 不行,是我们忘了告诉 GC:这东西可以扔了。"




