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

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

// 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对象一直存在、须要一直使用的话,那么没有问题,可是若是想用一次就不用了那就会有问题,这个对象一直在内存里面没有被释放就发生了内存泄露。html

另外一种比较隐蔽而且很常见的内存泄露是事件绑定,造成了一个闭包,致使一些变量一直存在。以下例子所示:前端

// 一个图片懒惰加载引擎示例
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行代码致使的:vue

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

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

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

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事件了直接解绑了。这样就能解决内存泄露的问题了,可以触发自动垃圾回收。web

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

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

而后打开devtools,切到Memory的tab,选中Heap snapshot,以下所示:markdown

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

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

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

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

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

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

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

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

而后再点一下拍照按钮:

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

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

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

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

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

它会提示你是在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不为空,没有改进以下图所示:

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

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

mounted () {
    EventBus.$on('goToNextHomeworkTask', this.go2NextQuestion);
}复制代码

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

destroyed () {
    EventBus.$off('goToNextHomeworkTask', this.go2NextQuestion);
}复制代码

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

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

能够发现是一个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();
}复制代码

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

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

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

其中有一个是.animate-container:

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

这个是一个用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的引用就会把它给释放了,问题解决了,而后再从新拍一张照片:

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

// 中止loading动画
    stopLoading () {
        loadingAnimate && bodymovinAnimate._stopAnimate(loadingAnimate);
        loadingAnimate = null;
    },复制代码

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

最后div还剩下3个有distance:

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

还有一个是Vue的:

这些都是使用的库形成的内存泄露,暂时先无论。

再去分析其它的标签也有相似的状况。

因此综合上面的分析,形成内存泄露的可能会有如下几种状况:

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

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

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

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

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

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

DOM相关的内存泄露一般也是由于闭包和事件绑定引发的。绑了(全局)事件以后,在不须要的时候须要把它解绑。固然直接绑在div上面的能够直接把div删了,绑在它上面的事件就天然解绑了。

【号外】《高效前端》已经第二次印刷

相关文章
相关标签/搜索