一個(gè)Vue頁面的內(nèi)存泄漏分析

??什么是內(nèi)存泄露?內(nèi)存泄露是指new了一塊內(nèi)存定枷,但無法被釋放或者被垃圾回收孤澎。new了一個(gè)對(duì)象之后,它申請(qǐng)占用了一塊堆內(nèi)存欠窒,當(dāng)把這個(gè)對(duì)象指針置為null時(shí)或者離開作用域?qū)е卤讳N毀覆旭,那么這塊內(nèi)存沒有人引用它了在JS里面就會(huì)被自動(dòng)垃圾回收。但是如果這個(gè)對(duì)象指針沒有被置為null岖妄,且代碼里面沒辦法再獲取到這個(gè)對(duì)象指針了型将,就會(huì)導(dǎo)致無法釋放掉它指向的內(nèi)存,也就是說發(fā)生了內(nèi)存泄露荐虐。為什么代碼里面會(huì)拿不到這個(gè)對(duì)象指針了呢七兜,舉一個(gè)例子:

// 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這個(gè)變量就一會(huì)直存在了福扬,直到你把頁面關(guān)了腕铸,因?yàn)閐ate的引用是在另一個(gè)module里面惜犀,可以理解為模塊就是一個(gè)閉包對(duì)外是不可見的。所以如果你是希望這個(gè)date對(duì)象一直存在狠裹、需要一直使用的話虽界,那么沒有問題,但是如果想用一次就不用了那就會(huì)有問題涛菠,這個(gè)對(duì)象一直在內(nèi)存里面沒有被釋放就發(fā)生了內(nèi)存泄露莉御。

??另一種比較隱蔽并且很常見的內(nèi)存泄露是事件綁定,形成了一個(gè)閉包俗冻,導(dǎo)致一些變量一直存在礁叔。如下例子所示:

// 一個(gè)圖片懶惰加載引擎示例
class ImageLazyLoader {
    constructor ($photoList) {
        $(window).on('scroll', () => {
            this.showImage($photoList);
        });
    }
    showImage ($photoList) {
        $photoList.each(img => {
            // 通過位置判斷圖片滑出來了就加載
            img.src = $(img).attr('data-src');
        });
    }
}
 
// 點(diǎn)擊分頁的時(shí)候就初始化一個(gè)圖片懶惰加載的
$('.page').on('click', function () {
    new ImageLazyLoader($('img.photo'));
});

??這是一個(gè)圖片懶惰加載的模型,每次點(diǎn)分頁的時(shí)候就會(huì)清掉上一頁的數(shù)據(jù)更新為當(dāng)前頁的DOM迄薄,并重新初始化一個(gè)懶惰加載的引擎晴圾。它里面監(jiān)聽了scroll事件,對(duì)傳進(jìn)來的圖片列表的DOM進(jìn)行處理噪奄。每點(diǎn)一次分頁就會(huì)重新new一個(gè),這里就發(fā)生了內(nèi)存泄露人乓,主要是以下3行代碼導(dǎo)致的:

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

??因?yàn)檫@里的事件綁定形成了一個(gè)閉包勤篮,this/$photoList這兩個(gè)變量一直沒有被釋放,this是指向ImageLazyLoader的實(shí)例色罚,而$photoList是指向DOM結(jié)點(diǎn)碰缔,當(dāng)清除掉上一頁的數(shù)據(jù)的時(shí)候,相關(guān)DOM結(jié)點(diǎn)已經(jīng)從DOM樹分離出來了戳护,但是仍然還有一個(gè)$photoList指向它們金抡,導(dǎo)致這些DOM結(jié)點(diǎn)無法被垃圾回收一直在內(nèi)存里面,就發(fā)生了內(nèi)存泄露腌且。由于this變量也被閉包困住了沒有被釋放梗肝,所以還有一個(gè)ImageLazyLoader的實(shí)例發(fā)生內(nèi)存泄露。

??這個(gè)的解決方法比較簡單铺董,就是銷毀實(shí)例的時(shí)候把綁定的事件off掉巫击,如下代碼所示:

class ImageLazyLoader {
    constructor ($photoList) {
        this.scrollShow = () => {
            this.showImage($photoList);
        };
        $(window).on('scroll', this.scrollShow);
    }
    // 新增一個(gè)事件解綁                           
    clear () {                     
        $(window).off('scroll', this.scrollShow);
    }
    showImage ($photoList) {
        $photoList.each(img => {
            // 通過位置判斷圖片滑出來了就加載
            img.src = $(img).attr('data-src');
        });
        // 判斷如果圖片已全部顯示,就把事件解綁了
        if (this.allShown) {
            this.clear();
        }
    }
}
 
// 點(diǎn)擊分頁的時(shí)候就初始化一個(gè)圖片懶惰加載的
let lazyLoader = null;
$('.page').on('click', function () {
    lazyLoader && (lazyLoader.clear());
    lazyLoader = new ImageLazyLoader($('img.photo'));
});

??在每次實(shí)例化一個(gè)ImageLazyLoader之前把先把上一個(gè)實(shí)例clear掉精续,clear里面進(jìn)行解綁坝锰,由于JS有構(gòu)造函數(shù)但是沒有解構(gòu)函數(shù),所以需要自己寫一個(gè)clear重付,在外面手動(dòng)調(diào)一下clear顷级。同時(shí)在事件的執(zhí)行過程的合適時(shí)機(jī)自動(dòng)把事件給解綁了,上面是判斷如果所有的圖片都展示出來了那么就沒必要監(jiān)聽scroll事件了直接解綁了确垫。這樣就能解決內(nèi)存泄露的問題了弓颈,能夠觸發(fā)自動(dòng)垃圾回收帽芽。

??為什么把事件解綁了,就不會(huì)有閉包引用了呢恨豁?因?yàn)镴S引擎檢測到那個(gè)閉包沒用了嚣镜,就把那個(gè)閉包銷毀了,那么閉包引用的外部變量也自然會(huì)被置空橘蜜。

??好了菊匿,基礎(chǔ)知識(shí)就講解到這里,現(xiàn)在用Chrome devtools的內(nèi)存檢測工具來實(shí)際操作一遍计福,方便發(fā)現(xiàn)頁面的一些內(nèi)存泄露行為跌捆。為了避免裝給瀏覽器裝的一些插件造成影響,使用Chome的隱身模式頁面象颖,它會(huì)把所有的插件都給禁掉佩厚。

??然后打開devtools,切到Memory的tab说订,選中Heap snapshot抄瓦,如下所示:


Heap snapshot.png

??什么叫heap snapshot呢?翻譯一下就是堆快照陶冷,給當(dāng)前內(nèi)存堆拍一張照片钙姊。因?yàn)閯?dòng)態(tài)申請(qǐng)的內(nèi)存都是在堆里面的,而局部變量是在內(nèi)存棧里面埂伦,是由操作系統(tǒng)分配管理的是不會(huì)內(nèi)存泄露了煞额。所以關(guān)心堆的情況就好了。

??然后做一些增刪改DOM的操作沾谜,如:
???(1)? 彈一個(gè)框膊毁,然后把彈框給關(guān)了
???(2)? 單頁面的點(diǎn)擊跳轉(zhuǎn)到另一個(gè)路由,然后再點(diǎn)后退返回
???(3)? 點(diǎn)擊分頁觸發(fā)動(dòng)態(tài)改DOM

??就是先增加DOM基跑,然后把這些DOM給刪了婚温,看一下這些被刪除的DOM是否還有對(duì)象引用它們。

??這里我是第2種方式的場景涩僻,檢測單頁面應(yīng)用的某個(gè)路由頁面是否存在內(nèi)存泄露缭召。先打開首頁,點(diǎn)到另一個(gè)頁面逆日,再點(diǎn)后退嵌巷,接著點(diǎn)一下垃圾回收的按鈕:


垃圾回收.png

??觸發(fā)垃圾回收,避免一些不必要的干擾室抽。
??然后再點(diǎn)一下拍照按鈕:


拍照按鈕.png

??它就會(huì)把當(dāng)前頁面的內(nèi)存堆掃描一遍顯示出來搪哪,如下圖所示:

內(nèi)存堆.png

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

detached.png

??它就會(huì)顯示所有已經(jīng)分離了DOM樹的DOM結(jié)點(diǎn),重點(diǎn)關(guān)注distance值不為空的坪圾,這個(gè)distance表示距離DOM根結(jié)點(diǎn)的距離晓折。上圖展示的這些div具體是啥呢惑朦?我們把鼠標(biāo)放上去不動(dòng)等個(gè)2s,它就會(huì)顯示這個(gè)div的DOM信息:

DOM信息.png

??通過className等信息可以知道它就是那個(gè)要檢查的頁面的DOM節(jié)點(diǎn)漓概,在下面的Object的窗口里面依次展開它的父結(jié)點(diǎn)漾月,可以看到它最外面的父結(jié)點(diǎn)是一個(gè)VueComponent實(shí)例:

VueComponent實(shí)例.png

??下面黃色字體native_bind表示有個(gè)事件指向了它,黃色表示引用仍然生效胃珍,把鼠標(biāo)放到native_bind上面停留2秒:

native_bind.png

??它會(huì)提示你是在homework-web.vue這個(gè)文件有一個(gè)getScale函數(shù)綁定在了window上面梁肿,查看一下這個(gè)文件確實(shí)是有一個(gè)綁定:

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

??所以雖然Vue組件把DOM刪除了,但是還有個(gè)引用存在觅彰,導(dǎo)致組件實(shí)例沒有被釋放吩蔑,組件里面又有一個(gè)$el指向DOM,所以DOM也沒有被釋放填抬。

??但是看代碼的話是在beforeDestroyed里面解綁的:

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

??所以應(yīng)該沒有問題爸蚍摇?
??定睛一看飒责,傻眼了赘娄,原來函數(shù)名寫錯(cuò)了,應(yīng)該是:

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

??發(fā)現(xiàn)了一個(gè)隱藏多日的bug宏蛉,因?yàn)檫@個(gè)比較隱蔽擅憔,就算寫錯(cuò)了也不會(huì)有明顯的感知了。

??把這個(gè)地方改一下檐晕,重復(fù)操作一遍,再拍一張內(nèi)存快照蚌讼。我們發(fā)現(xiàn)游離的div節(jié)點(diǎn)仍然是74個(gè)且disance不為空辟灰,沒有改進(jìn)如下圖所示:

內(nèi)存.png

??難道剛剛改得不對(duì)?繼續(xù)查看剛剛第2個(gè)節(jié)點(diǎn):

第2個(gè)節(jié)點(diǎn).png

??可以發(fā)現(xiàn)篡石,這次是有一個(gè)事件總線EventBus的事件綁定指向了它芥喇,說明除了剛剛那個(gè)resize事件綁定之外,還有一個(gè)EventBus的事件沒有釋放凰萨,事件名稱是gToNextHomworkTask继控。我們搜一下這個(gè)事件是在哪里綁的,可以找到它是在路由組件的一個(gè)子組件里面綁的:

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

??果不其然胖眷,這個(gè)組件只有$on武通,沒有$off,所以導(dǎo)致組件卸載的時(shí)候仍然有一個(gè)事件的引用珊搀。所以需要在這個(gè)組件的destroyed里面給$off掉:

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

??改完后刷新頁面操作第3次冶忱,再拍一張內(nèi)存快照,比較尷尬的是情況還是一樣:

第3次內(nèi)存.png

??說明還有人引用它境析,繼續(xù)查看是誰引用了沒有釋放:

watch監(jiān)聽.png

??可以發(fā)現(xiàn)是一個(gè)Vuex的$store的watch監(jiān)聽沒有釋放囚枪,借助Watcher的cb屬性可以知道具體是哪個(gè)監(jiān)聽函數(shù)派诬。利用簡單的文本搜索發(fā)現(xiàn)是在一個(gè)子組件里面進(jìn)行了watch

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

??watch里面有一個(gè)this指針指向了組件的DOM元素,由于子組件沒有被釋放链沼,那么包含它的父組件自然不會(huì)被釋放默赂,所以一層層往上,導(dǎo)致最外面那個(gè)路由組件也不會(huì)被釋放括勺。

??這個(gè)需要在destroyed的時(shí)候unwatch一下:

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

??處理完之后再拍一張內(nèi)存快照缆八,如下圖所示:

最終內(nèi)存信息.png

??雖然還是74個(gè)但是distance已經(jīng)為空了,可對(duì)比前3步distance都不為空朝刊,并且下面Object展開沒有找到標(biāo)黃的部分了耀里,也就是說這個(gè)路由組件內(nèi)存泄露的問題已經(jīng)得到解決。

??我們繼續(xù)查看其它distance不為空的div節(jié)點(diǎn)拾氓,如下圖所示冯挎,可以按照distance排下序:

distance.png

??其中有一個(gè)是.animate-container

animate-container.png

??它是一個(gè)用來放lottie動(dòng)畫的DOM容器,lottie對(duì)象里面仍有引用它:

lottie.png

??這個(gè)是一個(gè)用lottie做的loading動(dòng)畫咙鞍,當(dāng)loading結(jié)束的時(shí)候房官,我會(huì)手動(dòng)調(diào)一下它的stop api停止動(dòng)畫,并且把.animte-containerremove掉续滋,但是為什么lottie還不肯放過它呢翰守?我的代碼是這么寫的:

let loadingAnimate = null;
let bodymovinAnimate = {
    // 顯示loading動(dòng)畫
    showLoading () {
        loadingAnimate = bodymovinAnimate._showAnimate();
        return loadingAnimate;
    },
    // 停止loading動(dòng)畫
    stopLoading () {
        loadingAnimate && bodymovinAnimate._stopAnimate(loadingAnimate);
    },
    // 開始lottie動(dòng)畫
    _showAnimate () {
        const animate = lottie.loadAnimation({
            // 參數(shù)省略
        }); 
        return animate;
    }
    // 結(jié)束lottie動(dòng)畫
    _stopAnimate (animate) {
        animate.stop();
        let $container = $(animate.wrapper).closest('.bodymovin-container');
        $container.remove();
    },
};
export default bodymovinAnimate;

??我猜想是調(diào)了stop之后lottie仍然沒有釋放對(duì)DOM的引用,因?yàn)?code>stop之后還能夠夠支持重新start的疲酌,所以它得咬著DOM不放蜡峰,因此如果要徹底結(jié)束動(dòng)畫,應(yīng)該不是調(diào)stop朗恳,查了一下它還有一個(gè)destroy的方法湿颅,把stop換成destroy

// 結(jié)束lottie動(dòng)畫
    _stopAnimate (animate) {
        animate.destroy();
        let $container = $(animate.wrapper).closest('.bodymovin-container');
        $container.remove();
    }

??這樣改了之后,lottie的引用就會(huì)把它給釋放了粥诫,問題解決了油航,然后再重新拍一張照片:

exports.default.png

??仍然有一個(gè)exports.default指向它,它是webpack的模塊怀浆,我猜想是因?yàn)楸疚拈_篇提到的例子的原因谊囚,就是模塊形成了閉包,它的變量沒有被釋放造成內(nèi)存泄露执赡,所以在stopLoading里面把它置成null

// 停止loading動(dòng)畫
    stopLoading () {
        loadingAnimate && bodymovinAnimate._stopAnimate(loadingAnimate);
        loadingAnimate = null;
    }

??這樣試了之后镰踏,.animate-container這個(gè)DOM對(duì)象就沒有人引用它了。
??最后div還剩下3個(gè)有distance

distance 信息.png

??其中兩個(gè)是jq$.support.boxSizingReliable沙合,是jq用來檢測boxszing是否可用創(chuàng)建的div:

jq boxsizing.png

??還有一個(gè)是Vue的:

vue boxsizing.png

這些都是使用的庫造成的內(nèi)存泄露余境,暫時(shí)先不管。
再去分析其它的標(biāo)簽也有類似的情況。

所以綜合上面的分析芳来,造成內(nèi)存泄露的可能會(huì)有以下幾種情況:

(1)監(jiān)聽在window/body等事件沒有解綁

(2)綁在EventBus的事件沒有解綁

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

(4)模塊形成的閉包內(nèi)部變量使用完后沒有置成null

(5)使用第三方庫創(chuàng)建含末,沒有調(diào)用正確的銷毀函數(shù)

??并且可以借助Chrome的內(nèi)存分析工具進(jìn)行快速排查,本文主要是用到了內(nèi)存堆快照的基本功能即舌,讀者可以嘗試分析自己的頁面是否存在內(nèi)存泄漏佣盒,方法是做一些操作如彈個(gè)框然后關(guān)了,拍一張堆快照顽聂,搜索detached肥惭,按distance排序,把非空的節(jié)點(diǎn)展開父級(jí)紊搪,找到標(biāo)黃的字樣說明蜜葱,那些就是存在沒有釋放的引用。也就是說這個(gè)方法主要是分析仍然存在引用的游離DOM節(jié)點(diǎn)耀石。因?yàn)轫撁娴膬?nèi)存泄露通常是和DOM相關(guān)的牵囤,普通的JS變量由于有垃圾回收所以一般不會(huì)有問題,除非使用閉包把變量困住了用完了又沒有置空滞伟。

??DOM相關(guān)的內(nèi)存泄露通常也是因?yàn)殚]包和事件綁定引起的揭鳞。綁了(全局)事件之后,在不需要的時(shí)候需要把它解綁梆奈。當(dāng)然直接綁在div上面的可以直接把div刪了野崇,綁在它上面的事件就自然解綁了。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末亩钟,一起剝皮案震驚了整個(gè)濱河市乓梨,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌清酥,老刑警劉巖督禽,帶你破解...
    沈念sama閱讀 218,941評(píng)論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異总处,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)睛蛛,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,397評(píng)論 3 395
  • 文/潘曉璐 我一進(jìn)店門鹦马,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人忆肾,你說我怎么就攤上這事荸频。” “怎么了客冈?”我有些...
    開封第一講書人閱讀 165,345評(píng)論 0 356
  • 文/不壞的土叔 我叫張陵旭从,是天一觀的道長。 經(jīng)常有香客問我,道長和悦,這世上最難降的妖魔是什么退疫? 我笑而不...
    開封第一講書人閱讀 58,851評(píng)論 1 295
  • 正文 為了忘掉前任,我火速辦了婚禮鸽素,結(jié)果婚禮上剥懒,老公的妹妹穿的比我還像新娘屯伞。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,868評(píng)論 6 392
  • 文/花漫 我一把揭開白布垒在。 她就那樣靜靜地躺著,像睡著了一般咬腕。 火紅的嫁衣襯著肌膚如雪壶硅。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,688評(píng)論 1 305
  • 那天瓦呼,我揣著相機(jī)與錄音喂窟,去河邊找鬼。 笑死吵血,一個(gè)胖子當(dāng)著我的面吹牛谎替,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播蹋辅,決...
    沈念sama閱讀 40,414評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼钱贯,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了侦另?” 一聲冷哼從身側(cè)響起秩命,我...
    開封第一講書人閱讀 39,319評(píng)論 0 276
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎褒傅,沒想到半個(gè)月后弃锐,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,775評(píng)論 1 315
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡殿托,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,945評(píng)論 3 336
  • 正文 我和宋清朗相戀三年霹菊,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片支竹。...
    茶點(diǎn)故事閱讀 40,096評(píng)論 1 350
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡旋廷,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出礼搁,到底是詐尸還是另有隱情饶碘,我是刑警寧澤,帶...
    沈念sama閱讀 35,789評(píng)論 5 346
  • 正文 年R本政府宣布馒吴,位于F島的核電站扎运,受9級(jí)特大地震影響瑟曲,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜豪治,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,437評(píng)論 3 331
  • 文/蒙蒙 一洞拨、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧鬼吵,春花似錦扣甲、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,993評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至涣脚,卻和暖如春示辈,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背遣蚀。 一陣腳步聲響...
    開封第一講書人閱讀 33,107評(píng)論 1 271
  • 我被黑心中介騙來泰國打工矾麻, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人芭梯。 一個(gè)月前我還...
    沈念sama閱讀 48,308評(píng)論 3 372
  • 正文 我出身青樓险耀,卻偏偏與公主長得像,于是被迫代替她去往敵國和親玖喘。 傳聞我的和親對(duì)象是個(gè)殘疾皇子甩牺,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,037評(píng)論 2 355

推薦閱讀更多精彩內(nèi)容