一个Vue页面的内存泄露分析详解


Posted in Javascript onJune 25, 2018

什么是内存泄露?内存泄露是指new了一块内存,但无法被释放或者被垃圾回收。new了一个对象之后,它申请占用了一块堆内存,当把这个对象指针置为null时或者离开作用域导致被销毁,那么这块内存没有人引用它了在JS里面就会被自动垃圾回收。但是如果这个对象指针没有被置为null,且代码里面没办法再获取到这个对象指针了,就会导致无法释放掉它指向的内存,也就是说发生了内存泄露。为什么代码里面会拿不到这个对象指针了呢,举一个例子:

// module date.js
let date = null;
export default {
 init () {
  date = new Date();
 }
}
 
// main.js
import date from 'date.js';
date.init();

在main.js初始化了date之后,date这个变量就一会直存在了,直到你把页面关了,因为date的引用是在另一个module里面,可以理解为模块就是一个闭包对外是不可见的。所以如果你是希望这个date对象一直存在、需要一直使用的话,那么没有问题,但是如果想用一次就不用了那就会有问题,这个对象一直在内存里面没有被释放就发生了内存泄露。

另一种比较隐蔽并且很常见的内存泄露是事件绑定,形成了一个闭包,导致一些变量一直存在。如下例子所示:

// 一个图片懒惰加载引擎示例
class ImageLazyLoader {
 constructor ($photoList) {
  $(window).on('scroll', () => {
   this.showImage($photoList);
  });
 }
 showImage ($photoList) {
  $photoList.each(img => {
   // 通过位置判断图片滑出来了就加载
   img.src = $(img).attr('data-src');
  });
 }
}
 
// 点击分页的时候就初始化一个图片懒惰加载的
$('.page').on('click', function () {
 new ImageLazyLoader($('img.photo'));
});

这是一个图片懒惰加载的模型,每次点分页的时候就会清掉上一页的数据更新为当前页的DOM,并重新初始化一个懒惰加载的引擎。它里面监听了scroll事件,对传进来的图片列表的DOM进行处理。每点一次分页就会重新new一个,这里就发生了内存泄露,主要是以下3行代码导致的:

$(window).on('scroll', () => {
 this.showImage($photoList);
});

因为这里的事件绑定形成了一个闭包,this/$photoList这两个变量一直没有被释放,this是指向ImageLazyLoader的实例,而$photoList是指向DOM结点,当清除掉上一页的数据的时候,相关DOM结点已经从DOM树分离出来了,但是仍然还有一个$photoList指向它们,导致这些DOM结点无法被垃圾回收一直在内存里面,就发生了内存泄露。由于this变量也被闭包困住了没有被释放,所以还有一个ImageLazyLoader的实例发生内存泄露。

这个的解决方法比较简单,就是销毁实例的时候把绑定的事件off掉,如下代码所示:

class ImageLazyLoader {
 constructor ($photoList) {
  this.scrollShow = () => {
   this.showImage($photoList);
  };
  $(window).on('scroll', this.scrollShow);
 }
 // 新增一个事件解绑       
 clear () {      
  $(window).off('scroll', this.scrollShow);
 }
 showImage ($photoList) {
  $photoList.each(img => {
   // 通过位置判断图片滑出来了就加载
   img.src = $(img).attr('data-src');
  });
  // 判断如果图片已全部显示,就把事件解绑了
  if (this.allShown) {
   this.clear();
  }
 }
}
 
// 点击分页的时候就初始化一个图片懒惰加载的
let lazyLoader = null;
$('.page').on('click', function () {
 lazyLoader && (lazyLoader.clear());
 lazyLoader = new ImageLazyLoader($('img.photo'));
});

在每次实例化一个ImageLazyLoader之前把先把上一个实例clear掉,clear里面进行解绑,由于JS有构造函数但是没有解构函数,所以需要自己写一个clear,在外面手动调一下clear。同时在事件的执行过程的合适时机自动把事件给解绑了,上面是判断如果所有的图片都展示出来了那么就没必要监听scroll事件了直接解绑了。这样就能解决内存泄露的问题了,能够触发自动垃圾回收。

为什么把事件解绑了,就不会有闭包引用了呢?因为JS引擎检测到那个闭包没用了,就把那个闭包销毁了,那么闭包引用的外部变量也自然会被置空。

好了,基础知识就讲解到这里,现在用Chrome devtools的内存检测工具来实际操作一遍,方便发现页面的一些内存泄露行为。为了避免装给浏览器装的一些插件造成影响,使用Chome的隐身模式页面,它会把所有的插件都给禁掉。

然后打开devtools,切到Memory的tab,选中Heap snapshot,如下所示:

一个Vue页面的内存泄露分析详解

什么叫heap snapshot呢?翻译一下就是堆快照,给当前内存堆拍一张照片。因为动态申请的内存都是在堆里面的,而局部变量是在内存栈里面,是由操作系统分配管理的是不会内存泄露了。所以关心堆的情况就好了。

然后做一些增删改DOM的操作,如:

(1)弹一个框,然后把弹框给关了

(2)单页面的点击跳转到另一个路由,然后再点后退返回

(3)点击分页触发动态改DOM

就是先增加DOM,然后把这些DOM给删了,看一下这些被删除的DOM是否还有对象引用它们。

这里我是第2种方式的场景,检测单页面应用的某个路由页面是否存在内存泄露。先打开首页,点到另一个页面,再点后退,接着点一下垃圾回收的按钮:

一个Vue页面的内存泄露分析详解

触发垃圾回收,避免一些不必要的干扰。

然后再点一下拍照按钮:

一个Vue页面的内存泄露分析详解

它就会把当前页面的内存堆扫描一遍显示出来,如下图所示:

一个Vue页面的内存泄露分析详解

然后在上面中间的Class Filter的搜索框里搜一下detached:

一个Vue页面的内存泄露分析详解

它就会显示所有已经分离了DOM树的DOM结点,重点关注distance值不为空的,这个distance表示距离DOM根结点的距离。上图展示的这些div具体是啥呢?我们把鼠标放上去不动等个2s,它就会显示这个div的DOM信息:

一个Vue页面的内存泄露分析详解

通过className等信息可以知道它就是那个要检查的页面的DOM节点,在下面的Object的窗口里面依次展开它的父结点,可以看到它最外面的父结点是一个VueComponent实例:

一个Vue页面的内存泄露分析详解

下面黄色字体native_bind表示有个事件指向了它,黄色表示引用仍然生效,把鼠标放到native_bind上面停留2秒:

一个Vue页面的内存泄露分析详解

它会提示你是在homework-web.vue这个文件有一个getScale函数绑定在了window上面,查看一下这个文件确实是有一个绑定:

mounted () {
 window.addEventListener('resize', this.getScale);
}

所以虽然Vue组件把DOM删除了,但是还有个引用存在,导致组件实例没有被释放,组件里面又有一个$el指向DOM,所以DOM也没有被释放。

但是看代码的话是在beforeDestroyed里面解绑的:

beforeDestroyed () {
 window.removeEventListener('resize', this.getScale);
}

所以应该没有问题啊?

定睛一看,傻眼了,原来函数名写错了,应该是:

beforeDestroy () {
 window.removeEventListener('resize', this.getScale);
},

发现了一个隐藏多日的bug,因为这个比较隐蔽,就算写错了也不会有明显的感知了。

把这个地方改一下,重复操作一遍,再拍一张内存快照。我们发现游离的div节点仍然是74个且disance不为空,没有改进如下图所示:

一个Vue页面的内存泄露分析详解

难道刚刚改得不对?继续查看刚刚第2个节点:

一个Vue页面的内存泄露分析详解

可以发现,这次是有一个 事件总线EventBus的事件绑定指向了它 ,说明除了刚刚那个resize事件绑定之外,还有一个EventBus的事件没有释放,事件名称是gToNextHomworkTask。我们搜一下这个事件是在哪里绑的,可以找到它是在路由组件的一个子组件里面绑的:

mounted () {
 EventBus.$on('goToNextHomeworkTask', this.go2NextQuestion);
}

果不其然,这个组件只有$on,没有$off,所以导致组件卸载的时候仍然有一个事件的引用。所以需要在这个组件的destroyed里面给$off掉:

mounted () {
 EventBus.$off('goToNextHomeworkTask', this.go2NextQuestion);
}

改完后刷新页面操作第3次,再拍一张内存快照,比较尴尬的是情况还是一样:

一个Vue页面的内存泄露分析详解

说明还有人引用它,继续查看是谁引用了没有释放:

一个Vue页面的内存泄露分析详解

可以发现是一个 Vuex的$store的watch监听没有释放 ,借助Watcher的cb属性可以知道具体是哪个监听函数。利用简单的文本搜索发现是在一个子组件里面进行了watch:

mounted () {
 this.$store.watch(state => state.currentIndex, (newIndex, oldIndex) => {
  if (this.$refs.animation && newIndex === this.task.index - 1) {
   this.$refs.animation.beginElement();
  } 
 }); 
}

watch里面有一个this指针指向了组件的DOM元素,由于子组件没有被释放,那么包含它的父组件自然不会被释放,所以一层层往上,导致最外面那个路由组件也不会被释放。

这个需要在destroyed的时候unwatch一下:

mounted () {
 this.unwatchStore = this.$store.watch(state => state.currentIndex, (newIndex, oldIndex) => {
  // 代码略
 }); 
},
destroyed () {
 this.unwatchStore();
}

处理完之后再拍一张内存快照,如下图所示:

一个Vue页面的内存泄露分析详解

虽然还是74个但是distance已经为空了,可对比前3步distance都不为空,并且下面Object展开没有找到标黄的部分了,也就是说这个路由组件内存泄露的问题已经得到解决。

我们继续查看其它distance不为空的div节点,如下图所示,可以按照distance排下序:

一个Vue页面的内存泄露分析详解

其中有一个是.animate-container:

一个Vue页面的内存泄露分析详解

它是一个用来放lottie动画的DOM容器,lottie对象里面仍有引用它:

一个Vue页面的内存泄露分析详解

这个是一个用lottie做的loading动画,当loading结束的时候,我会手动调一下它的stop api停止动画,并且把.animte-container给remove掉,但是为什么lottie还不肯放过它呢?我的代码是这么写的:

let loadingAnimate = null;
let bodymovinAnimate = {
 // 显示loading动画
 showLoading () {
  loadingAnimate = bodymovinAnimate._showAnimate();
  return loadingAnimate;
 },
 // 停止loading动画
 stopLoading () {
  loadingAnimate && bodymovinAnimate._stopAnimate(loadingAnimate);
 },
 // 开始lottie动画
 _showAnimate () {
  const animate = lottie.loadAnimation({
   // 参数省略
  }); 
  return animate;
 }
 // 结束lottie动画
 _stopAnimate (animate) {
  animate.stop();
  let $container = $(animate.wrapper).closest('.bodymovin-container');
  $container.remove();
 },
};
export default bodymovinAnimate;

我猜想是调了stop之后lottie仍然没有释放对DOM的引用,因为stop之后还能够够支持重新start的,所以它得咬着DOM不放,因此如果要彻底结束动画,应该不是调stop,查了一下它还有一个destroy的方法,把stop换成destroy:

// 结束lottie动画
 _stopAnimate (animate) {
  animate.destroy();
  let $container = $(animate.wrapper).closest('.bodymovin-container');
  $container.remove();
 },

这样改了之后,lottie的引用就会把它给释放了,问题解决了,然后再重新拍一张照片:

一个Vue页面的内存泄露分析详解

仍然有一个exports.default指向它,它是webpack的模块,我猜想是因为本文开篇提到的例子的原因,就是模块形成了闭包,它的变量没有被释放造成内存泄露,所以在stopLoading里面把它置成null:

// 停止loading动画
 stopLoading () {
  loadingAnimate && bodymovinAnimate._stopAnimate(loadingAnimate);
  loadingAnimate = null;
 },

这样试了之后,.animate-container这个DOM对象就没有人引用它了。

最后div还剩下3个有distance:

一个Vue页面的内存泄露分析详解

其中两个是jq的$.support.boxSizingReliable,是jq用来检测boxszing是否可用创建的div:

一个Vue页面的内存泄露分析详解

还有一个是Vue的:

一个Vue页面的内存泄露分析详解

这些都是使用的库造成的内存泄露,暂时先不管。

再去分析其它的标签也有类似的情况。

所以综合上面的分析,造成内存泄露的可能会有以下几种情况:

(1)监听在window/body等事件没有解绑

(2)绑在EventBus的事件没有解绑

(3)Vuex的$store watch了之后没有unwatch

(4)模块形成的闭包内部变量使用完后没有置成null

(5)使用第三方库创建,没有调用正确的销毁函数

并且可以借助Chrome的内存分析工具进行快速排查,本文主要是用到了内存堆快照的基本功能,读者可以尝试分析自己的页面是否存在内存泄漏,方法是做一些操作如弹个框然后关了,拍一张堆快照,搜索detached,按distance排序,把非空的节点展开父级,找到标黄的字样说明,那些就是存在没有释放的引用。也就是说这个方法主要是分析仍然存在引用的游离DOM节点。因为页面的内存泄露通常是和DOM相关的,普通的JS变量由于有垃圾回收所以一般不会有问题,除非使用闭包把变量困住了用完了又没有置空。

DOM相关的内存泄露通常也是因为闭包和事件绑定引起的。绑了(全局)事件之后,在不需要的时候需要把它解绑。当然直接绑在div上面的可以直接把div删了,绑在它上面的事件就自然解绑了。

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持三水点靠木。

Javascript 相关文章推荐
你的编程语言可以这样做吗?
Sep 07 Javascript
收集json解析的四种方法分享
Jan 17 Javascript
jQuery中:header选择器用法实例
Dec 29 Javascript
jQuery实现点击图片翻页展示效果的方法
Feb 16 Javascript
js滚动条平滑移动示例代码
Mar 29 Javascript
web 屏蔽BackSpace键实例代码
Dec 24 Javascript
使用JavaScript进行表单校验功能
Aug 01 Javascript
jquery实现楼层滚动效果
Jan 01 jQuery
axios使用拦截器统一处理所有的http请求的方法
Nov 02 Javascript
Vue.js + Nuxt.js 项目中使用 Vee-validate 表单校验
Apr 22 Javascript
解决VUE项目localhost端口服务器拒绝连接,只能用127.0.0.1的问题
Aug 14 Javascript
Vue操作Storage本地化存储
Apr 29 Vue.js
Vue.js项目中管理每个页面的头部标签的两种方法
Jun 25 #Javascript
angularjs结合html5实现拖拽功能
Jun 25 #Javascript
vue中vee validate表单校验的几种基本使用
Jun 25 #Javascript
超出JavaScript安全整数限制的数字计算BigInt详解
Jun 24 #Javascript
JS的函数调用栈stack size的计算方法
Jun 24 #Javascript
JavaScript中var、let、const区别浅析
Jun 24 #Javascript
使用JavaScript中的lodash编写双色球效果
Jun 24 #Javascript
You might like
PHP的一个完整SMTP类(解决邮件服务器需要验证时的问题)
2006/10/09 PHP
PHP中的正则表达式函数介绍
2012/02/27 PHP
PHP中实现生成静态文件的方法缓解服务器压力
2014/01/07 PHP
php+ajax实现无刷新数据分页的办法
2015/11/02 PHP
Yii框架页面渲染操作实例详解
2019/07/19 PHP
修改Laravel自带的认证系统的User类的命名空间的步骤
2019/10/15 PHP
js 多种变量定义(对象直接量,数组直接量和函数直接量)
2010/05/24 Javascript
ExtJS下书写动态生成的xml(兼容火狐)
2013/04/02 Javascript
左侧是表头的JS表格控件(自写,网上没有的)
2013/06/04 Javascript
jQuery中使用Ajax获取JSON格式数据示例代码
2013/11/26 Javascript
Javascript前端UI框架Kit使用指南之Kitjs简介
2014/11/28 Javascript
node.js中的fs.fchmodSync方法使用说明
2014/12/16 Javascript
jquery使用正则表达式验证email地址的方法
2015/01/22 Javascript
jQuery Ajax调用WCF服务详细教程
2015/03/31 Javascript
javascript实现图片上传前台页面
2015/08/18 Javascript
微信小程序 SocketIO 实例讲解
2016/10/13 Javascript
canvas实现弧形可拖动进度条效果
2017/05/11 Javascript
基于JS代码实现简单易用的倒计时 x 天 x 时 x 分 x 秒效果
2017/07/13 Javascript
jquery ajaxfileuplod 上传文件 essyui laoding 效果【防止重复上传文件】
2018/05/26 jQuery
Vue使用vue-area-linkage实现地址三级联动效果的示例
2018/06/27 Javascript
详解wepy开发小程序踩过的坑(小结)
2019/05/22 Javascript
[42:48]完美世界DOTA2联赛PWL S3 Magma vs INK ICE 第二场 12.11
2020/12/16 DOTA
python脚本实现数据导出excel格式的简单方法(推荐)
2016/12/30 Python
Python获取当前路径实现代码
2017/05/08 Python
Python 使用Numpy对矩阵进行转置的方法
2019/01/28 Python
python实现按行分割文件
2019/07/22 Python
Python线上环境使用日志的及配置文件
2019/07/28 Python
用python中的matplotlib绘制方程图像代码
2019/11/21 Python
python中有关时间日期格式转换问题
2019/12/25 Python
Python装饰器实现方法及应用场景详解
2020/03/26 Python
详解torch.Tensor的4种乘法
2020/09/03 Python
利用python绘制中国地图(含省界、河流等)
2020/09/21 Python
美国隐形眼镜零售商:LensPure
2019/03/10 全球购物
学校政风行风自查自纠报告
2014/10/21 职场文书
2015年教师工作总结范文
2015/03/31 职场文书
浅谈GO中的Channel以及死锁的造成
2022/03/18 Golang