一个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 相关文章推荐
javascript 写类方式之二
Jul 05 Javascript
JS控制文本框textarea输入字数限制的方法
Jun 17 Javascript
自制的文件上传JS控件可支持IE、chrome、firefox etc
Apr 18 Javascript
jquery插件uploadify实现带进度条的文件批量上传
Dec 13 Javascript
js添加千分位的实现代码(超简单)
Aug 01 Javascript
JavaScript 拖拽实例代码
Sep 21 Javascript
BootStrapTable服务器分页实例解析
Dec 20 Javascript
vue.js将unix时间戳转换为自定义时间格式
Jan 03 Javascript
js 判断一个数字是不是2的n次方幂的实例
Nov 26 Javascript
深入理解react-router 路由的实现原理
Sep 26 Javascript
微信小程序学习笔记之函数定义、页面渲染图文详解
Mar 28 Javascript
详解Vue中的基本语法和常用指令
Jul 23 Javascript
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
thinkphp在模型中自动完成session赋值示例代码
2014/09/09 PHP
php简单计算页面加载时间的方法
2015/06/19 PHP
PHP信号处理机制的操作代码讲解
2019/04/19 PHP
javascript 当前日期转化为中文的实现代码
2010/05/13 Javascript
SlideView 图片滑动(扩展/收缩)展示效果
2010/08/01 Javascript
使用js获取地址栏参数的方法推荐(超级简单)
2016/06/14 Javascript
JavaScript数组去重由慢到快由繁到简(优化篇)
2016/08/26 Javascript
JS实现最简单的冒泡排序算法
2017/02/15 Javascript
彻底学会Angular.js中的transclusion
2017/03/12 Javascript
JS将unicode码转中文方法
2017/05/08 Javascript
react-native DatePicker日期选择组件的实现代码
2017/09/12 Javascript
JS中利用FileReader实现上传图片前本地预览功能
2018/03/02 Javascript
NodeJs之word文件生成与解析的实现代码
2019/04/01 NodeJs
解决JQuery的ajax函数执行失败alert函数弹框一闪而过问题
2019/04/10 jQuery
基于layui的table插件进行复选框联动功能的实现方法
2019/09/19 Javascript
vue中get请求如何传递数组参数的方法示例
2019/11/08 Javascript
[03:32]2014DOTA2西雅图邀请赛 CIS外卡赛赛前black专访
2014/07/09 DOTA
利用python批量给云主机配置安全组的方法教程
2017/06/21 Python
Python探索之URL Dispatcher实例详解
2017/10/28 Python
python飞机大战pygame游戏背景设计详解
2019/12/17 Python
Windows下实现将Pascal VOC转化为TFRecords
2020/02/17 Python
django迁移文件migrations的实现
2020/03/31 Python
Pycharm生成可执行文件.exe的实现方法
2020/06/02 Python
DRF框架API版本管理实现方法解析
2020/08/21 Python
Python实现异步IO的示例
2020/11/05 Python
amazeui树节点自动展开折叠面板并选中第一个树节点的实现
2020/08/24 HTML / CSS
路易威登和香奈儿手袋:LuxeDH
2017/01/12 全球购物
Lands’ End英国官方网站:高质量男女服装
2017/10/07 全球购物
德国旅行、体验和活动的预订平台:Watado
2019/12/04 全球购物
行政求职信
2014/07/04 职场文书
2014年党员发展工作总结
2014/12/02 职场文书
2014年校长工作总结
2014/12/11 职场文书
2015年餐厅服务员工作总结
2015/04/23 职场文书
开展警示教育活动总结
2015/05/09 职场文书
高考要来啦!用Python爬取历年高考数据并分析
2021/06/03 Python
关于Redis的主从复制及哨兵问题
2022/06/16 Redis