当前位置:首页 > 技术分析 > 正文内容

深入JavaScript教你内存泄漏如何防范

ruisui883个月前 (02-20)技术分析9



作者:大道至简

转发链接:
https://mp.weixin.qq.com/s/0w6aWwpR3MAJnmyLwDnAzA

前言

一般情况下,忽视内存管理不会对传统的网页产生显著的后果。这是因为,用户刷新页面后,内存数据都被清理了。

但是随着SPA(单页应用)的普及,我们不得不更加关注页面的内存管理。用户在 SPA 上往往很少刷新页面,随着页面停留时间的增长,内存可能越占越多,轻则影响页面性能,严重的可能导致标签页崩溃。

在这篇文章中,我们将探讨导致 JavaScript 中内存泄露的常见原因,以及如何改善内存管理。

具体如何内存管理,请见这篇:「前端进阶」JS中的内存管理

什么是内存泄漏以及如何发现

浏览器将对象保留在堆内存中,通过引用链可从根对象到达这些对象。垃圾回收器(GC)是 JavaScript 引擎中的一个后台进程,它可以识别无法到达的对象,将其删除,并回收相应的内存。

引用链 - GC - 对象关系图

当内存中本应在垃圾回收循环中被清理的对象,通过另一个对象意外的引用从而维持可访问状态,就会发生内存泄漏。将多余的对象保持在内存中,会导致应用程序内部的内存使用量过大,进而影响性能。

内存泄露

如何判断代码是否存在内存泄漏呢?内存泄漏通常比较隐蔽,难以发现和定位。造成内存泄漏的 JavaScript 代码看上去挺正常,浏览器在运行的时候也不会抛出错误。如果发现页面性能越来越差,通常是内存泄漏的征兆,可以通过浏览器内置的工具判断是否存在内存泄漏,并分析出原因。

最快的方法是查看浏览器的任务管理器(注意,不是操作系统的任务管理器)。它提供了浏览器运行中的所有 tab 页和进程的资源使用情况,比如内存占用、CPU 占用和进程 ID 等。Chrome 的任务管理器可通过 Shift+Esc 快捷键打开,Firefox 可在地址栏输入about:performance打开。

如果页面都没有任何交互,内存占用却越来越多,很可能存在泄漏。

Chrome 任务管理器

浏览器 DevTools 则提供了更丰富的内存管理功能。可以在 Chrome 的性能面板录制页面运行情况,查看可视化的性能分析数据。

Chrome 性能面板

除此之外,Chrome 和 Firefox 的 DevTools 还有专门的内存工具用于分析内存使用情况。通过比较连续的内存快照,可以看出内存分配情况。

通过前面的分析,内存泄露的根本原因就是代码在无意之中引用了本该被 GC 回收的对象。那么,哪些情况容易造成内存泄露呢?

1意外的全局变量

全局变量一直处于可访问状态,不会被 GC 回收。在非严格模式下,有时会不小心让局部变量变成全局变量。

  • 给未声明的变量赋值
  • 使用指向全局对象的 this
function?createGlobalVariables()?{
????leaking1?=?'变成全局变量了';?//?给未声明的变量赋值
????this.leaking2?=?'这也是全局变量';?//?'this'?指向全局对象
};
createGlobalVariables();
window.leaking1;?//?'变成全局变量了'
window.leaking2;?//?'这也是全局变量'

如何避免: 严格模式 ("use strict") 会避免意外的全局变量,以上代码在严格模式下会报错。

2 闭包

函数作用域变量在函数执行完后会被清理,前提是在函数外部没有引用它。闭包会让变量一直处于被引用状态,即使它的执行上下文和作用域已经不存在了。

function?outer()?{
????const?Array?=?[];
????return?function?inner()?{
????????bigArray.push('Hello');?
????????console.log('Hello');
????};
};
const?sayHello?=?outer();?//?包含了对?inner?的引用

function?repeat(fn,?num)?{
????for?(let?i?=?0;?i?

上面例子中的数组 bigArray 没有从任何函数中直接返回,因此无法直接访问,但是它却不停地膨胀,取决于我们调用了多少次 function inner()。

如何避免: 闭包是 JavaScript 语言的特性之一,如果无法避开,那就请注意两点:

  • 清楚闭包是何时创建的,以及哪些对象会被保留在内存中;
  • 清楚闭包的生命周期和用途(尤其是当做回调函数的时候)

3 定时器

在 setTimeout 或 setInterval 的回调函数中引用某些对象,是防止被 GC 回收的常见做法。如果在代码里设置循环定时器(setTimeout也能像setInterval一样定时重复执行,只要设置成递归调用),只要定时器还在运行,回调函数中的对象就会一直保持在内存中。

下面的例子中,data 对象会在清除定时器后被 GC 回收。但我们没有获取 setInterval的返回值,也就没办法用代码清除这个定时器,因此尽管完全没有用到,data.hugeString 也会一直保留在内存中,直到进程结束。

function?setCallback()?{
????const?data?=?{
????????counter:?0,
????????hugeString:?new?Array(100000).join('x')
????};
????return?function?cb()?{
????????data.counter++;?//?data?对象现在已经属于回调函数的作用域了
????????console.log(data.counter);
????}
}
setInterval(setCallback(),?1000);?//?没法停止定时器了

如何避免: 对于生命周期不确定的回调函数,我们应该:

  • 注意被定时器回调函数引用的对象
  • 使用定时器返回的句柄,在必要时清除它

也可以通过分离变量的方式,避免对大对象的引用:

function?setCallback()?{
????//?分开定义变量
????let?counter?=?0;
????const?hugeString?=?new?Array(100000).join('x');?//?setCallback执行完即可被回收
????return?function?cb()?{
????????counter++;?//?只剩?counter?位于回调函数作用域
????????console.log(counter);
????}
}

const?timerId?=?setInterval(setCallback(),?1000);?//?保存定时器?ID

//?执行某些操作?...

clearInterval(timerId);?//?停止定时器

4 事件监听器

活动的事件监听器会阻止作用域内的变量被 GC 回收。事件监听器一直处于活动状态,直到用 removeEventListener() 显式移除,或者关联的 DOM 元素被移除。

对于有些事件来说,监听器需要一直保留,直到页面被销毁。比如按钮点击事件,我们可能需要重复使用。但是,有时候我们希望某个事件只执行特定次数。

const?hugeString?=?new?Array(100000).join('x');
document.addEventListener('keyup',?function()?{?//?匿名监听器无法移除
????doSomething(hugeString);?//?hugeString?会一直处于回调函数的作用域内
});

上面例子中的事件监听器用了匿名函数,这样就没法用removeEventListener()移除了。同时,document元素也无法删除,因此事件回调函数内的变量会一直保留,哪怕我们只想触发一次事件。

如何避免: 事件监听器不再需要时,要记得解除绑定。使用具名函数方式获取引用,通过removeEventListener()解除绑定。

function?listener()?{
????doSomething(hugeString);
}
document.addEventListener('keyup',?listener);?
document.removeEventListener('keyup',?listener);?

如果事件监听器只需要执行一次, addEventListener()可以接受第三个参数,是一个配置对象。指定{once: true},监听器函数会在事件触发一次执行后自动移除(匿名函数也可以)。

document.addEventListener('keyup',?function?listener(){
????doSomething(hugeString);
},?{once:?true});?//?执行一次后自动移除事件监听器

5 缓存

如果持续不断地往缓存里增加数据,没有定时清除无用的对象,也没有限制缓存大小,那么缓存就会像滚雪球一样越来越大。

let?user_1?=?{?name:?"Kayson",?id:?12345?};
let?user_2?=?{?name:?"Jerry",?id:?54321?};
const?mapCache?=?new?Map();

function?cache(obj){
????if?(!mapCache.has(obj)){
????????const?value?=?`${obj.name}?has?an?id?of?${obj.id}`;
????????mapCache.set(obj,?value);

????????return?[value,?'computed'];
????}

????return?[mapCache.get(obj),?'cached'];
}

cache(user_1);?//?['Kayson?has?an?id?of?12345',?'computed']
cache(user_1);?//?['Kayson?has?an?id?of?12345',?'cached']
cache(user_2);?//?['Jerry?has?an?id?of?54321',?'computed']

console.log(mapCache);?//?((…)?=>?"Kayson?has?an?id?of?12345",?(…)?=>?"Jerry?has?an?id?of?54321")
user_1?=?null;?

//Garbage?Collector
console.log(mapCache);?//?((…)?=>?"Kayson?has?an?id?of?12345",?(…)?=>?"Jerry?has?an?id?of?54321")?//?依然在缓存里

上面的例子中,缓存依然保留了user_1 的数据。因此我们需要把不再使用的数据从缓存中删除。可能的解决方案: 为了解决这个问题,可以使用 WeakMap。 WeakMap 是一种数据结构,它只用对象作为键,并保持对象键的弱引用,如果这个对象被置空了,相关的键值对会被 GC 自动回收。

let?user_1?=?{?name:?"Kayson",?id:?12345?};
let?user_2?=?{?name:?"Jerry",?id:?54321?};
const?weakMapCache?=?new?WeakMap();

function?cache(obj){
????//?代码跟前一个例子相同,只不过用的是?weakMapCache

????return?[weakMapCache.get(obj),?'cached'];
}

cache(user_1);?//?['Kayson?has?an?id?of?12345',?'computed']
cache(user_2);?//?['Jerry?has?an?id?of?54321',?'computed']
console.log(weakMapCache);?//?((…)?=>?"Kayson?has?an?id?of?12345",?(…)?=>?"Jerry?has?an?id?of?54321"}
user_1?=?null;?

//?Garbage?Collector

console.log(weakMapCache);?//?((…)?=>?"Jerry?has?an?id?of?54321")?-?第一条记录已被?GC?删除

6 分离的 DOM 元素

如果 DOM 节点被 JavaScript 代码直接引用,即使从 DOM 树分离,也不会被 GC 回收。

下面的例子中,removeChild() 达不到预期效果,堆快照会显示HTMLDivElement处于分离状态,因为有个变量指向了这个div。

function?createElement()?{
????const?div?=?document.createElement('div');
????div.id?=?'detached';
????return?div;
}

//?即使调用了deleteElement()?,依然保存着?DOM?元素的引用
const?detachedDiv?=?createElement();
document.body.appendChild(detachedDiv);
function?deleteElement()?{
document.body.removeChild(document.getElementById('detached'));
}

deleteElement();?//?堆快照显示:?detached?div#detached

如何避免: 一种方法是把DOM 引用限制为局部作用域。

function?createElement()?{...}?//?
//?DOM?引用位于函数作用域内

function?appendElement()?{
????const?detachedDiv?=?createElement();
????document.body.appendChild(detachedDiv);
}

appendElement();

function?deleteElement()?{
?????document.body.removeChild(document.getElementById('detached'));
}

deleteElement();

总结

对于重要的前端应用,定位和解决 JavaScript 内存问题是一项颇具挑战性的任务。因此,理解典型的内存泄露原因,从而在源头上避免,是做好内存管理的必要工作。希望本文总结的造成内存泄漏的六大来源对你有所启发,在写代码的时候有所防范。

推荐JavaScript经典实例学习资料文章

手把手教你7个有趣的JavaScript 项目-上「附源码」

手把手教你7个有趣的JavaScript 项目-下「附源码」

JavaScript 使用 mediaDevices API 访问摄像头自拍

手把手教你前端代码如何做错误上报「JS篇」

一文让你彻底搞懂移动前端和Web 前端区别在哪里

63个JavaScript 正则大礼包「值得收藏」

提高你的 JavaScript 技能10 个问答题

JavaScript图表库的5个首选

一文彻底搞懂JavaScript 中Object.freeze与Object.seal的用法

可视化的 JS:动态图演示 - 事件循环 Event Loop的过程

教你如何用动态规划和贪心算法实现前端瀑布流布局「实践」

可视化的 js:动态图演示 Promises & Async/Await 的过程

原生JS封装拖动验证滑块你会吗?「实践」

如何实现高性能的在线 PDF 预览

细说使用字体库加密数据-仿58同城

Node.js要完了吗?

Pug 3.0.0正式发布,不再支持 Node.js 6/8

纯JS手写轮播图(代码逻辑清晰,通俗易懂)

JavaScript 20 年 中文版之创立标准

值得收藏的前端常用60余种工具方法「JS篇」

箭头函数和常规函数之间的 5 个区别

通过发布/订阅的设计模式搞懂 Node.js 核心模块 Events

「前端篇」不再为正则烦恼

「速围」Node.js V14.3.0 发布支持顶级 Await 和 REPL 增强功能

深入细品浏览器原理「流程图」

JavaScript 已进入第三个时代,未来将何去何从?

前端上传前预览文件 image、text、json、video、audio「实践」

深入细品 EventLoop 和浏览器渲染、帧动画、空闲回调的关系

推荐13个有用的JavaScript数组技巧「值得收藏」

前端必备基础知识:window.location 详解

不要再依赖CommonJS了

犀牛书作者:最该忘记的JavaScript特性

36个工作中常用的JavaScript函数片段「值得收藏」

Node + H5 实现大文件分片上传、断点续传

一文了解文件上传全过程(1.8w字深度解析)「前端进阶必备」

【实践总结】关于小程序挣脱枷锁实现批量上传

手把手教你前端的各种文件上传攻略和大文件断点续传

字节跳动面试官:请你实现一个大文件上传和断点续传

谈谈前端关于文件上传下载那些事【实践】

手把手教你如何编写一个前端图片压缩、方向纠正、预览、上传插件

最全的 JavaScript 模块化方案和工具

「前端进阶」JS中的内存管理

JavaScript正则深入以及10个非常有意思的正则实战

前端面试者经常忽视的一道JavaScript 面试题

一行JS代码实现一个简单的模板字符串替换「实践」

JS代码是如何被压缩的「前端高级进阶」

前端开发规范:命名规范、html规范、css规范、js规范

【规范篇】前端团队代码规范最佳实践

100个原生JavaScript代码片段知识点详细汇总【实践】

关于前端174道 JavaScript知识点汇总(一)

关于前端174道 JavaScript知识点汇总(二)

关于前端174道 JavaScript知识点汇总(三)

几个非常有意思的javascript知识点总结【实践】

都2020年了,你还不会JavaScript 装饰器?

JavaScript实现图片合成下载

70个JavaScript知识点详细总结(上)【实践】

70个JavaScript知识点详细总结(下)【实践】

开源了一个 JavaScript 版敏感词过滤库

送你 43 道 JavaScript 面试题

3个很棒的小众JavaScript库,你值得拥有

手把手教你深入巩固JavaScript知识体系【思维导图】

推荐7个很棒的JavaScript产品步骤引导库

Echa哥教你彻底弄懂 JavaScript 执行机制

一个合格的中级前端工程师需要掌握的 28 个 JavaScript 技巧

深入解析高频项目中运用到的知识点汇总【JS篇】

JavaScript 工具函数大全【新】

从JavaScript中看设计模式(总结)

身份证号码的正则表达式及验证详解(JavaScript,Regex)

浏览器中实现JavaScript计时器的4种创新方式

Three.js 动效方案

手把手教你常用的59个JS类方法

127个常用的JS代码片段,每段代码花30秒就能看懂-【上】

深入浅出讲解 js 深拷贝 vs 浅拷贝

手把手教你JS开发H5游戏【消灭星星】

深入浅出讲解JS中this/apply/call/bind巧妙用法【实践】

手把手教你全方位解读JS中this真正含义【实践】

书到用时方恨少,一大波JS开发工具函数来了

干货满满!如何优雅简洁地实现时钟翻牌器(支持JS/Vue/React)

手把手教你JS 异步编程六种方案【实践】

让你减少加班的15条高效JS技巧知识点汇总【实践】

手把手教你JS开发H5游戏【黄金矿工】

手把手教你JS实现监控浏览器上下左右滚动

JS 经典实例知识点整理汇总【实践】

2.6万字JS干货分享,带你领略前端魅力【基础篇】

2.6万字JS干货分享,带你领略前端魅力【实践篇】

简单几步让你的 JS 写得更漂亮

恭喜你获得治疗JS this的详细药方

谈谈前端关于文件上传下载那些事【实践】

面试中教你绕过关于 JavaScript 作用域的 5 个坑

Jquery插件(常用的插件库)

【JS】如何防止重复发送ajax请求

JavaScript+Canvas实现自定义画板

Continuation 在 JS 中的应用「前端篇」


作者:大道至简

转发链接:
https://mp.weixin.qq.com/s/0w6aWwpR3MAJnmyLwDnAzA

扫描二维码推送至手机访问。

版权声明:本文由ruisui88发布,如需转载请注明出处。

本文链接:http://www.ruisui88.com/post/2107.html

标签: 清除定时器
分享给朋友:

“深入JavaScript教你内存泄漏如何防范” 的相关文章

vue3中父子传值、defineProps用法、defineEmits用法

Vue3中新增了一个 script setup 语法糖模式,可以在单文件组件中更简洁地编写组件逻辑。使用 script setup 语法后,props、data、computed、methods 等选项不再需要独立定义,而是可以直接在 setup 函数中声明,代码结构更加清晰,并且可以更方便地使用响...

代码分支规范

一.gitflow工作流说明:主分支:master,稳定版本代码分支,对外可以随时编译发布的分支,不允许直接Push代码,只能请求合并(pull request),且只接受hotfix、release分支的代码合并。gitlab上做限制。热修复分支:hotfix,针对现场紧急问题、bug修复的代码分...

Gitlab之间进行同步备份

目前,我们公司有两个研发团队,分别在北京和武汉,考虑到访问速度的问题,原有武汉的研发环境在近端部署。也就是北京和武汉分别有两套独立的研发管理环境,虽然这解决了近端访问速度的问题,但是管理上较为分散,比如研发环境备份和恢复就是最重要的问题之一。最近,处于对安全性和合规性的考虑,希望将北京和武汉的源代码...

7 招教你轻松搭建以图搜图系统

作者 | 小龙责编 | 胡巍巍当您听到“以图搜图”时,是否首先想到了百度、Google 等搜索引擎的以图搜图功能呢?事实上,您完全可以搭建一个属于自己的以图搜图系统:自己建立图片库;自己选择一张图片到库中进行搜索,并得到与其相似的若干图片。Milvus 作为一款针对海量特征向量的相似性检索引擎,旨在...

el-table内容\n换行解决办法

问题请求到的数据带有换行符 '\n'但页面展示时不换行statusRemark: "\"1、按期完成计划且准确率100%,得100分;\n2、各项目每延误1天,扣1分;每失误1次或者员工投诉1次,扣3分,失误层面达到公司级影响较大的,该项绩效分数为0\"\n&...

USB电池充电基础:应急指南

USB为便携设备供电与其串行通信功能一样,已经成为一种标准应用。如今,USB 供电已经扩展到电池充电、交流适配器及其它供电形式的应用。应用的普及带来的一个显著效果是便携设备的充电和供电可以互换插头和适配器。因此,相对于过去每种装置都采用专用适配器的架构相比,目前的解决方案允许采用多种电源进行充电。毋...