JS 内存泄漏:从 “页面越用越卡” 到 “内存稳定”,我排查过的 5 个真实案例

内容分享2周前发布
0 0 0

上周接到一个紧急线上问题:运营同学反馈 “后台管理系统用 2 小时后,页面卡到动不了,必须刷新才能用”。我远程操控他的电脑打开 Chrome 任务管理器,一眼就看到了问题 —— 页面内存从初始的 200MB 飙升到了 1.2GB,明显是内存泄漏了。

顺着这个线索查代码,最终定位到一个定时器:运营页面有个 “实时刷新数据” 的功能,用
setInterval
每秒请求一次接口,但切换到其他页面时,这个定时器没被清理,一直在后台疯狂发请求、存数据,2 小时积累下来,内存自然爆了。

内存泄漏就像前端的 “隐形杀手”—— 它不会让页面立刻崩溃,却会随着时间推移慢慢 “拖垮” 性能:从轻微卡顿到操作无响应,最后甚至导致浏览器崩溃。更麻烦的是,它藏得深,很多开发者遇到了也不知道是内存泄漏,只会觉得 “代码写得没问题,怎么就卡了?”

今天就结合我在电商后台、数据看板等项目里排查内存泄漏的真实经历,把 5 个最容易踩的泄漏场景掰开揉碎了讲 —— 每个场景都带 “问题代码 + 排查过程 + 修复方案 + 监测方法”,教你从 “发现卡顿” 到 “彻底解决” 的完整流程,以后遇到类似问题,照着装就能排查。

一、先搞懂:什么是内存泄漏?为什么会卡顿?

简单说,内存泄漏就是 “JS 中不再需要的内存,没有被正确释放,一直占着空间”。

JS 有自动垃圾回收(GC)机制,理论上会自动回收无用内存,但如果代码写得有问题,导致 “垃圾回收器以为这些内存还在用”,就会造成泄漏。比如:你声明了一个全局变量存临时数据,用完后没清空,垃圾回收器会觉得 “这个变量还能访问,不能删”,内存就一直被占着。

内存泄漏的危害是 “累积性” 的:

初期:内存占用慢慢涨,页面轻微卡顿(比如滚动掉帧);中期:内存占用超过 1GB,操作响应延迟(点击按钮等半秒才有反应);后期:内存占用持续飙升,浏览器强制终止进程(页面白屏崩溃)。

最容易出问题的场景,往往是 “频繁创建对象却不释放”“长期运行的页面(如后台系统、数据看板)”—— 这些场景下,泄漏的内存会像滚雪球一样越积越大。

二、场景 1:未清理的定时器 / 计时器,最容易踩的 “定时炸弹”

开头提到的后台系统泄漏,就是定时器没清理导致的。这是我见过最多的内存泄漏场景,尤其是在 “实时刷新”“轮播图”“倒计时” 这类功能里。

问题代码(后台系统实时刷新):

javascript

运行



// 运营后台:实时刷新订单数据(每1秒请求一次)
function startRefreshOrderData() {
  // 问题:timer变量没被外部引用,无法在组件卸载时清理
  const timer = setInterval(async () => {
    const newData = await fetch('/api/order/latest');
    // 把新数据存到数组里(一直累加,从不清理)
    window.orderHistory.push(...newData); 
    renderOrderTable(window.orderHistory); // 渲染表格
  }, 1000);
}
 
// 组件挂载时启动刷新
startRefreshOrderData();
 
// 组件卸载时(比如切换到其他页面),没清理定时器!
// 这里漏了:clearInterval(timer)

为什么会泄漏?


setInterval
创建的定时器,只要不清理,就会一直执行,每次执行都会往
window.orderHistory
里加数据 —— 这个数组越来越大,占用的内存自然越来越多;更糟的是,定时器的回调函数引用了
window.orderHistory
,垃圾回收器会认为 “这个数组还在被使用”,永远不会回收它;即使页面切换了,这个定时器还在后台跑,持续占用内存和网络资源。

排查过程(用 Chrome Memory 面板):

打开页面,操作 30 分钟(让内存泄漏积累);打开 DevTools → Memory → 点击 “Take snapshot”(拍内存快照);在快照里搜索
orderHistory
,发现它的数组长度已经到了 1800+(30 分钟 ×60 秒 = 1800 次请求),占用内存 500MB+;顺着数组的引用找,发现定时器的回调函数还在引用它,说明定时器没被清理。

修复方案:组件卸载时清理定时器

javascript

运行



// 改进:用全局变量存timer,方便清理
let orderTimer = null;
 
function startRefreshOrderData() {
  // 先清掉旧的定时器(防止重复创建)
  if (orderTimer) {
    clearInterval(orderTimer);
  }
  
  orderTimer = setInterval(async () => {
    const newData = await fetch('/api/order/latest');
    // 优化:只存最近100条数据,避免数组无限增大
    window.orderHistory = [...window.orderHistory.slice(-99), ...newData];
    renderOrderTable(window.orderHistory);
  }, 1000);
}
 
// 组件挂载时启动
startRefreshOrderData();
 
// 组件卸载时(必须清理!)
function stopRefreshOrderData() {
  if (orderTimer) {
    clearInterval(orderTimer);
    orderTimer = null; // 手动置空,帮助GC回收
    // 可选:清空临时数据
    window.orderHistory = [];
  }
}
 
// 在路由切换、页面关闭时调用清理函数
router.beforeEach((to, from, next) => {
  if (from.path === '/order-manage') {
    stopRefreshOrderData(); // 离开订单管理页时清理
  }
  next();
});

我的踩坑经历:

2 年前做一个电商数据看板,用
setTimeout
模拟
setInterval
(因为需要动态调整间隔时间),结果忘了清理
setTimeout
创建的定时器链 —— 每个定时器执行完会创建下一个,最后形成了 “定时器套娃”,内存 10 分钟就涨到 800MB。后来才明白:
setTimeout

setInterval
都需要清理,尤其是手动递归创建的定时器链,一定要在合适的时机打断。

三、场景 2:事件监听 “只绑不解”,页面越用越重


window

document
这类全局对象绑事件,如果不手动解绑,即使组件卸载了,事件监听依然存在,会导致:1. 回调函数无法被回收;2. 回调里引用的 DOM / 数据也无法被回收。

问题代码(滚动加载更多):

javascript

运行



// 商品列表页:滚动到页脚时加载更多商品
function initScrollLoad() {
  // 给window绑滚动事件(全局对象)
  window.addEventListener('scroll', handleScroll);
}
 
function handleScroll() {
  // 滚动到页脚附近
  if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 200) {
    loadMoreGoods(); // 加载更多商品
  }
}
 
// 组件挂载时初始化
initScrollLoad();
 
// 组件卸载时(比如切换到详情页),没解绑scroll事件!

为什么会泄漏?


window
是全局对象,只要页面没关,它就一直存在;给
window
绑的
scroll
事件监听,如果不解绑,
handleScroll
函数会一直被引用,无法被回收;
handleScroll
里调用了
loadMoreGoods
,而
loadMoreGoods
可能引用了列表数据、DOM 元素等,这些都会被 “拖下水”,无法回收。

排查过程:

打开多个页面(比如从列表页→详情页→列表页,重复几次);拍内存快照,搜索
handleScroll
,发现有多个实例(每次进入列表页都绑了新的,却没解旧的);触发滚动,发现控制台打印了多次 “加载更多”(说明多个监听同时生效)。

修复方案:组件卸载时解绑事件

javascript

运行



function initScrollLoad() {
  // 绑定事件时,用具名函数(不要用匿名函数,否则解绑不了)
  window.addEventListener('scroll', handleScroll);
}
 
// 新增:解绑事件的函数
function removeScrollLoad() {
  window.removeEventListener('scroll', handleScroll); // 必须传同一个函数
}
 
// 组件挂载时初始化
initScrollLoad();
 
// 组件卸载时调用解绑
function onUnmount() {
  removeScrollLoad();
}
 
// 路由切换时调用(以Vue为例)
beforeRouteLeave(to, from, next) {
  removeScrollLoad();
  next();
}

避坑技巧:

给全局对象(
window

document
)绑事件,一定要在组件卸载时解绑;用匿名函数绑事件(
window.addEventListener('scroll', () => {})
)无法解绑,必须用具名函数;频繁切换的组件(如标签页),尤其要注意事件监听的解绑,否则会累积多个监听。

四、场景 3:DOM 元素 “删了还引着”,内存里的 “幽灵 DOM”

有时候你用
removeChild

innerHTML
删除了 DOM 元素,但 JS 里还存着它的引用,这会导致:DOM 虽然不在页面上了,但内存里还占着空间(因为 JS 还在引用它),变成 “幽灵 DOM”。

问题代码(动态删除列表项):

javascript

运行



// 待办事项列表:删除一个待办项
function deleteTodoItem(id) {
  const list = document.getElementById('todo-list');
  const item = document.querySelector(`[data-id="${id}"]`);
  
  // 从DOM中删除元素
  list.removeChild(item);
  
  // 问题:全局数组里还存着这个item的引用!
  // window.todoItems 是存所有待办项DOM的数组
}
 
// 初始化时保存所有待办项DOM到全局数组
window.todoItems = Array.from(document.querySelectorAll('.todo-item'));

为什么会泄漏?


list.removeChild(item)
只是把 DOM 从页面上移除了,但
window.todoItems
数组里还存着
item
的引用;垃圾回收器看到
window.todoItems
还在引用这个 DOM 元素,会认为 “它还有用”,不会回收它的内存;如果频繁删除和添加待办项,
window.todoItems
会积累大量 “已删除但被引用” 的 DOM 元素,内存越来越大。

排查过程:

多次添加和删除待办项,然后拍内存快照;在快照里搜索
todo-item
(类名),发现很多
div.todo-item
元素的
parentNode

null
(说明已从 DOM 树移除),但依然存在于内存中;查看这些元素的引用者,发现被
window.todoItems
数组引用着。

修复方案:删除 DOM 后,同时清除 JS 引用

javascript

运行



function deleteTodoItem(id) {
  const list = document.getElementById('todo-list');
  const item = document.querySelector(`[data-id="${id}"]`);
  
  // 1. 从DOM中删除元素
  list.removeChild(item);
  
  // 2. 从数组中删除对应的引用
  window.todoItems = window.todoItems.filter(el => el !== item);
  
  // 3. 手动置空DOM引用(帮助GC回收)
  item = null;
}
 
// 优化:尽量避免全局数组存DOM引用,用的时候再查
// 比如需要操作时,直接通过document.querySelector获取,不用提前存

我的经验:

做后台系统的 “动态表单” 功能时,我曾用一个全局对象缓存所有表单字段的 DOM 元素,方便快速操作。但删除字段时忘了从全局对象里删引用,导致内存泄漏 —— 用户用 1 小时后,表单页面内存涨到 900MB。后来改成 “用时再查 DOM”,内存占用直接降到 200MB 以内。结论:能不缓存 DOM 就不缓存,尤其是会被频繁删除的 DOM

五、场景 4:闭包 “关住” 大对象,内存无法释放

闭包能让函数访问外部变量,但如果闭包里引用了 “大对象”(比如大量数据的数组、复杂 DOM),且闭包本身长期存在(比如被定时器、事件监听引用),就会导致大对象无法被回收。

问题代码(闭包引用大数组):

javascript

运行



// 统计模块:计算销售额,用闭包缓存历史数据
function createSalesCounter() {
  // 大数组:存每天的销售额(假设10万条数据)
  const salesHistory = get100kSalesData(); 
  
  return function calculateTotal(month) {
    // 闭包:访问外部的salesHistory
    return salesHistory
      .filter(item => item.month === month)
      .reduce((sum, item) => sum + item.amount, 0);
  };
}
 
// 创建计数器(闭包被保存到全局变量)
window.salesCounter = createSalesCounter();
 
// 组件卸载后,这个全局变量还在引用闭包,闭包又引用salesHistory

为什么会泄漏?


createSalesCounter
返回的
calculateTotal
函数是闭包,它引用了
salesHistory
(10 万条数据的大数组);
window.salesCounter
存着这个闭包,只要
window.salesCounter
不被清空,闭包就一直存在;闭包一直引用
salesHistory
,导致这个大数组永远不会被回收,占用几百 MB 内存。

排查过程:

拍内存快照,发现有一个巨大的数组(长度 10 万 +);查看数组的 “Retainers”(引用者),发现被一个匿名函数(闭包)引用;顺着闭包的引用找,发现被
window.salesCounter
引用着。

修复方案:及时清理闭包引用

javascript

运行



function createSalesCounter() {
  let salesHistory = get100kSalesData(); 
  
  return {
    calculateTotal(month) {
      return salesHistory
        .filter(item => item.month === month)
        .reduce((sum, item) => sum + item.amount, 0);
    },
    // 新增:清理数据的方法
    destroy() {
      salesHistory = null; // 手动清空大数组
    }
  };
}
 
// 创建计数器(存为对象,方便调用destroy)
window.salesCounter = createSalesCounter();
 
// 组件卸载时,调用destroy清理
function onUnmount() {
  if (window.salesCounter) {
    window.salesCounter.destroy();
    window.salesCounter = null; // 清空闭包的引用
  }
}

避坑提醒:

闭包本身不会导致内存泄漏,但闭包 + 长期存在的引用 + 大对象的组合才会。使用闭包时,尽量:

避免在闭包里引用大对象;给闭包提供 “销毁方法”,在不需要时手动清空引用;不要把闭包存到全局变量里,尽量限制在局部作用域。

六、场景 5:全局变量 “泛滥成灾”,不知不觉占内存

全局变量是内存泄漏的 “重灾区”—— 因为全局变量的生命周期和页面一致(页面不关闭,它就一直存在),如果往全局变量里存大量临时数据,又不清理,内存肯定爆。

问题代码(随意使用全局变量):

javascript

运行



// 搜索功能:用户输入关键词,缓存搜索结果
function handleSearch(keyword) {
  // 问题1:直接往window上挂属性
  window.lastSearchKeyword = keyword;
  
  // 问题2:全局数组存所有搜索结果,从不清理
  fetch(`/api/search?keyword=${keyword}`)
    .then(res => res.json())
    .then(data => {
      window.allSearchResults = window.allSearchResults || [];
      window.allSearchResults.push(...data.results); // 一直累加
      renderSearchResult(data.results);
    });
}
 
// 页面上的搜索框事件:<input oninput="handleSearch(this.value)">

为什么会泄漏?


window.lastSearchKeyword

window.allSearchResults
都是全局变量,只要页面不刷新,就一直存在;用户每输入一次关键词,
allSearchResults
就会累加新结果,假设每次返回 100 条数据,搜索 100 次就是 10000 条,占用大量内存;这些全局变量往往被遗忘,没人会去清理,内存自然越来越大。

排查过程:

在 Console 里输入
window
,查看全局属性,发现
allSearchResults
数组长度异常大;拍内存快照,筛选 “Global”(全局对象),能看到这些全局变量引用的大数组。

修复方案:用局部变量 + 合理清理

javascript

运行



// 改进1:用模块作用域的变量,代替全局变量
const searchCache = {
  lastKeyword: '',
  results: []
};
 
function handleSearch(keyword) {
  // 改进2:只存最近3次的搜索结果,避免无限累加
  if (searchCache.results.length > 3) {
    searchCache.results.shift(); // 删掉最早的一次
  }
  
  fetch(`/api/search?keyword=${keyword}`)
    .then(res => res.json())
    .then(data => {
      searchCache.lastKeyword = keyword;
      searchCache.results.push(data.results);
      renderSearchResult(data.results);
    });
}
 
// 改进3:页面离开搜索页时,清理缓存
function onSearchPageLeave() {
  searchCache.results = [];
  searchCache.lastKeyword = '';
}

我的习惯:

永远不要直接往
window
上挂属性,用 IIFE 或模块创建局部作用域;全局变量只存 “整个应用生命周期都需要的数据”(如用户信息),临时数据用局部变量;给全局缓存设置 “上限”(比如最多存 10 条),超过上限就清理旧数据。

七、怎么检测内存泄漏?3 个实用工具

讲了这么多场景,最重要的还是 “如何发现内存泄漏”。分享 3 个我常用的工具和方法,从简单到复杂:

1. Chrome 任务管理器:快速判断是否泄漏

打开 Chrome → 更多工具 → 任务管理器;找到你的页面,观察 “内存” 和 “JavaScript 内存” 列:
如果页面不动时,内存还在持续增长 → 大概率有泄漏;切换页面后,内存没有明显下降 → 可能有泄漏(正常切换页面内存会降)。

2. Memory 面板:定位泄漏对象

打开 DevTools → Memory;选 “Allocation instrumentation on timeline”(时间线分配记录);点击 “开始”,操作页面 30 秒,点击 “停止”;看时间线:如果某类对象(如数组、DOM 元素)的数量一直在增加,且没有下降趋势 → 这些对象可能泄漏了。

3. Performance 面板:结合性能看泄漏

打开 Performance → 勾选 “Memory”;录制 30 秒操作,看内存曲线:
正常曲线:操作时上升,停止操作后下降;泄漏曲线:操作时上升,停止后不下降,甚至继续缓慢上升。

八、总结:内存泄漏的 “预防口诀”

最后给你一个预防内存泄漏的口诀,写代码时多默念几遍,能避免 80% 的泄漏问题:

“定时要清,事件要解,DOM 要删引用,闭包要销毁,全局要少用”

定时要清:
setTimeout
/
setInterval
用完就
clear
;事件要解:给全局对象绑的事件,不用时
remove
;DOM 要删引用:删除 DOM 后,同步清除 JS 里的引用;闭包要销毁:闭包引用大对象时,提供销毁方法;全局要少用:临时数据用局部变量,全局只存必要数据。

内存泄漏的排查就像 “破案”—— 先通过工具找到 “可疑对象”,再顺着引用链找到 “泄漏源头”,最后修复代码。你之前遇到过内存泄漏吗?比如页面用久了卡顿、崩溃,或者某个功能越用越慢?欢迎在评论区分享你的经历,我们一起分析排查~

如果觉得这些排查方法有用,建议收藏一下,下次遇到页面卡顿,按这个流程走一遍,大概率能找到问题所在。

© 版权声明

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
none
暂无评论...