來源 | 前端印象(ID: Lpyexplore) 不知道在座的各位有沒有被問到過這樣一個問題:如果頁面卡頓,你覺得可能是什么原因造成的,?有什么辦法鎖定原因并解決嗎,?
這是一個非常寬泛而又有深度的問題,,他涉及到很多的頁面性能優(yōu)化問題,我依稀還記得當初面試被問到這個問題時我是這么回答的:
先會檢查是否是網絡請求太多,,導致數據返回較慢,,可以適當做一些緩存 也有可能是某塊資源的 bundle 太大,可以考慮拆分一下 然后排查一下 js 代碼,,是不是某處有過多循環(huán)導致占用主線程時間過長 后來了解到了,,感官上的長時間運行頁面卡頓也有可能是因為內存泄漏 引起的
- 1 -
內存泄漏的定義 那什么是內存泄漏呢,?借助別的大佬給出的定義,內存泄漏就是指由于疏忽或者程序的某些錯誤造成未能釋放已經不再使用的內存的情況,。簡單來講就是假設某個變量占用 100M 的內存,,而你又用不到這個變量,但是這個變量沒有被手動的回收或自動回收,,即仍然占用 100M 的內存空間,,這就是一種內存的浪費,即內存泄漏
- 2 -
JS 的數據存儲 JavaScript 的內存空間分為棧內存 和堆內存 ,,前者用來存放一些簡單變量,,后者用來存放復雜對象
簡單變量指的是 JS 的基本數據類型,例如:String,、Number,、Boolean、null,、undefined,、Symbol、BigInt 復雜對象指的是 JS 的引用數據類型,,例如:Object,、Array、Function... - 3 -
JS 垃圾回收機制 根據內存泄漏的定義,,有些變量或數據不再被使用或不需要了,,那么它就是垃圾變量或垃圾數據,如果其一直保存在內存中,,最終可能會導致內存占用過多的情況,。那么此時就需要對這些垃圾數據進行回收,這里引入了垃圾回收機制 的概念 例如 C/C++ 采用的就是手動回收的機制,,即先用代碼為某個變量分配一定的內存,,然后在不需要了后,再用代碼手動釋放掉內存 而 JavaScript 采用的則是自動回收的機制,,即我們不需要關心何時為變量分配多大的內存,,也不需要關心何時去釋放內存,,因為這一切都是自動的。但這不表示我們不需要關心內存的管理?。,。?!否則也不會有本文討論的內存泄露了 接下來就講一下 JavaScript 的垃圾回收機制 通常全局狀態(tài)(window)下的變量是不會被自動回收的,,所以我們來討論一下局部作用域下的內存回收情況 function fn1 ( ) { let a = { name : '零一' } let b = 3 function fn2 ( ) { let c = [1 , 2 , 3 ] } fn2() return a }let res = fn1()
圖中左側為 棧 空間 ,用于存放一些執(zhí)行上下文和基本類型數據,;右側為 堆空間 ,用于存放一些復雜對象數據
當代碼執(zhí)行到 fn2() 時,,??臻g內的執(zhí)行上下文從上往下依次是 fn2 函數執(zhí)行上下文 => fn1 函數執(zhí)行上下文 => 全局執(zhí)行上下文 待 fn2 函數內部執(zhí)行完畢以后,就該退出 fn2 函數執(zhí)行上下文了,,即箭頭向下移動,,此時 fn2 函數執(zhí)行上下文會被清除并釋放棧內存空間,如圖所示: 待 fn1 函數內部執(zhí)行完畢以后,,就該退出 fn1函數執(zhí)行上下文 了,,即箭頭再向下移動,此時 fn1函數執(zhí)行上下文 會被清除并釋放相應的棧內存空間,,如圖所示:
此時 處于全局的執(zhí)行上下文中,。 JavaScript 的垃圾回收器會每隔一段時間遍歷調用棧, 假設此時觸發(fā)了垃圾回收機制,,當遍歷調用棧時發(fā)現變量 b 和變量 c 沒有被任何變量所引用 ,,所以認定它們是垃圾數據 并給它們打上標記。 因為fn1函數執(zhí)行完后將變量 a 返回了 出去,, 并存儲在 全局變量 res 中,,所以認定其為活動數據 并打上相應標記。 待空閑時刻 就 會將標記上垃圾數據的變量給全部清除掉,,釋放相應的內存,,如圖所示:
從這我們得出幾點結論: JavaScript 的垃圾回收機制是自動執(zhí)行的,并且會通過標記來識別并清除垃圾數據 在離開局部作用域后,,若該作用域內的變量沒有被外部作用域所引用,,則在后續(xù)會被清除 補充: JavaScript 的垃圾回收機制有著很多的步驟,上述只講到了標記-清除,,其實還有其它的過程,,這里簡單介紹一下就不展開討論了。例如:標記-整理,,在清空部分垃圾數據后釋放了一定的內存空間后會可能會留下大面積的不連續(xù)內存片段,,導致后續(xù)可能無法為某些對象分配連續(xù)內存,,此時需要整理一下內存空間;交替執(zhí)行,,因為 JavaScript 是運行在主線程上的,,所以執(zhí)行垃圾回收機制時會暫停 js 的運行,若垃圾回收執(zhí)行時間過長,,則會給用戶帶來明顯的卡頓現象,所以垃圾回收機制會被分成一個個的小任務,,穿插在js任務之中,,即交替執(zhí)行,盡可能得保證不會帶來明顯的卡頓感
- 4 -
Chrome devTools 查看內存情況 在了解一些常見的內存泄漏的場景之前,,先簡單介紹一下如何使用 Chrome 的開發(fā)者工具來查看js內存情況
首先打開 Chrome 的無痕模式,,這樣做的目的是為了屏蔽掉 Chrome 插件對我們之后測試內存占用情況的影響
然后打開開發(fā)者工具,找到 Performance 這一欄,,可以看到其內部帶著一些功能按鈕,,例如:開始錄制按鈕;刷新頁面按鈕,;清空記錄按鈕,;記錄并可視化js內存、節(jié)點,、事件監(jiān)聽器按鈕,;觸發(fā)垃圾回收機制按鈕等等 簡單錄制一下百度頁面,看看我們能獲得什么,,如下動圖所示:
從上圖中我們可以看到,,在頁面從零到加載完成這個過程中 JS Heap(js堆內存)、documents(文檔),、Nodes(DOM節(jié)點),、Listeners(監(jiān)聽器)、GPU memory(GPU內存)的最低值,、最高值以及隨時間的走勢曲線,,這也是我們主要關注的點 再來看看開發(fā)者工具中的 Memory 一欄,其主要是用于記錄頁面堆內存的具體情況以及js堆內存隨加載時間線動態(tài)的分配情況 堆快照就像照相機一樣,,能記錄你當前頁面的堆內存情況,,每快照一次就會產生一條快照記錄,如圖所示: 如上圖所示,,剛開始執(zhí)行了一次快照,,記錄了當時堆內存空間占用為 13.9MB,然后我們點擊了頁面中某些按鈕,又執(zhí)行一次快照,,記錄了當時堆內存空間占用為 13.4MB,。并且點擊對應的快照記錄,能看到當時所有內存中的變量情況(結構,、占總占用內存的百分比...) 然后我們還可以看一下頁面動態(tài)的內存變化情況,,如圖所示: 在開始記錄后,我們可以看到圖中右上角有起伏的藍色與灰色的柱形圖,,其中藍色 表示當前時間線下占用著的內存,;灰色 表示之前占用的內存空間已被清除釋放。 從上圖過程來看,,我們可以看到剛開始處于的 tab 所對應顯示的頁面中占用了一定的堆內存空間,,成藍色柱形,在點擊別的 tab 后,,原 tab 對應的內容消失,,并且原來藍色的柱形變成灰色(表示原占用的內存空間得到了釋放),同時新 tab 所對應顯示的頁面也占用了一定的堆內存空間,。因此后續(xù)我們就可以針對這個圖來查看內存的占用與清除情況 - 5 -
內存泄漏的場景 那么到底有哪些情況會出現內存泄漏 的情況呢?這里列舉了常見的幾種: 接下來介紹一下各種情況,,并嘗試用剛才講到的兩種方法來捕捉問題所在 5.1 閉包使用不當 文章開頭的例子中,,在退出 fn1函數執(zhí)行上下文后,該上下文中的變量 a 本應被當作垃圾數據給回收掉,,但因 fn1函數最終將變量 a 返回并賦值給全局變量res,,其產生了對變量 a 的引用,所以變量 a 被標記為活動變量并一直占用著相應的內存,,假設變量 res 后續(xù)用不到,,這就算是一種閉包使用不當的例子 接下來嘗試使用 Performance和Memory 來查看一下閉包導致的內存泄漏問題,為了使內存泄漏的結果更加明顯,,我們稍微改動一下文章開頭的例子,,代碼如下: <button onclick ='myClick()' > 執(zhí)行fn1函數</button > <script > function fn1 ( ) { let a = new Array (10000 ) // 這里設置了一個很大的數組對象 let b = 3 function fn2 ( ) { let c = [1 , 2 , 3 ] } fn2() return a } let res = [] function myClick ( ) { res.push(fn1()) }</script >
設置了一個按鈕,每次執(zhí)行就會將 fn1函數的返回值添加到全局數組變量 res中,,是為了能在 performacne 的曲線圖中看出效果,,如圖所示: 在每次錄制開始時手動觸發(fā)一次垃圾回收機制,這是為了確認一個初始的堆內存基準線,,便于后面的對比,,然后我們點擊了幾次按鈕,即往全局數組變量 res 中添加了幾個比較大的數組對象,,最后再觸發(fā)一次垃圾回收,,發(fā)現錄制結果的 JS Heap 曲線剛開始成階梯式上升的,最后的曲線的高度比基準線要高,,說明可能是存在內存泄漏的問題 在得知有內存泄漏的情況存在時,,我們可以改用 Memory 來更明確得確認問題和定位問題 首先可以用 Allocation instrumentation on timeline 來確認問題,,如下圖所示: 在我們每次點擊按鈕后,動態(tài)內存分配情況圖上都會出現一個藍色的柱形,,并且在我們觸發(fā)垃圾回收后,,藍色柱形都沒變成灰色柱形,即之前分配的內存并未被清除 所以此時我們就可以更明確得確認內存泄漏的問題是存在的了,,接下來就精準定位問題,,可以利用 Heap snapshot 來定位問題,如圖所示: 第一次先點擊快照記錄初始的內存情況,,然后我們多次點擊按鈕后再次點擊快照,,記錄此時的內存情況,發(fā)現從原來的 1.1M 內存空間變成了 1.4M 內存空間,,然后我們選中第二條快照記錄,,可以看到右上角有個All objects的字段,其表示展示的是當前選中的快照記錄所有對象的分配情況,,而我們想要知道的是第二條快照與第一條快照的區(qū)別在哪,,所以選擇 Object allocated between Snapshot1 and Snapshot2,即展示第一條快照和第二條快照存在差異的內存對象分配情況,,此時可以看到 Array 的百分比很高,,初步可以判斷是該變量存在問題,點擊查看詳情后就能查看到該變量對應的具體數據了 以上就是一個判斷閉包帶來內存泄漏問題并簡單定位的方法了 5.2 全局變量 全局的變量一般是不會被垃圾回收掉的,,在文章開頭也提到過了,。當然這并不是說變量都不能存在全局,只是有時候會因為疏忽而導致某些變量流失到全局,,例如未聲明變量,,卻直接對某變量進行賦值,就會導致該變量在全局創(chuàng)建,,如下所示: function fn1 ( ) { // 此處變量name未被聲明 name = new Array (99999999 ) } fn1()
此時這種情況就會在全局自動創(chuàng)建一個變量 name,,并將一個很大的數組賦值給 name,又因為是全局變量,,所以該內存空間就一直不會被釋放 解決辦法的話,,自己平時要多加注意,不要在變量未聲明前賦值,,或者也可以開啟嚴格模式,,這樣就會在不知情犯錯時,收到報錯警告,,例如: function fn1 ( ) { 'use strict' ; name = new Array (99999999 ) } fn1()
5.3 分離的 DOM 節(jié)點 什么叫 DOM 節(jié)點 ,?假設你手動移除了某個 dom 節(jié)點,本應釋放該 dom 節(jié)點所占用的內存,但卻因為疏忽導致某處代碼仍對該被移除節(jié)點有引用,,最終導致該節(jié)點所占內存無法被釋放,,例如這種情況: <div id ='root' > <div class ='child' > 我是子元素</div > <button > 移除</button > </div > <script > let btn = document .querySelector('button' ) let child = document .querySelector('.child' ) let root = document .querySelector('#root' ) btn.addEventListener('click' , function ( ) { root.removeChild(child) })</script >
該代碼所做的操作就是點擊按鈕后移除.child 的節(jié)點,雖然點擊后,,該節(jié)點確實從 dom 被移除了,,但全局變量 child 仍對該節(jié)點有引用,所以導致該節(jié)點的內存一直無法被釋放,,可以嘗試用 Memory 的快照功能來檢測一下,,如圖所示: 同樣的先記錄一下初始狀態(tài)的快照,然后點擊移除按鈕后,,再點擊一次快照,,此時內存大小我們看不出什么變化,因為移除的節(jié)點占用的內存實在太小了可以忽略不計,,但我們可以點擊第二條快照記錄,,在篩選框里輸入 detached,于是就會展示所有脫離了卻又未被清除的節(jié)點對象 <div id ='root' > <div class ='child' > 我是子元素</div > <button > 移除</button > </div > <script > let btn = document .querySelector('button' ) btn.addEventListener('click' , function ( ) { let child = document .querySelector('.child' ) let root = document .querySelector('#root' ) root.removeChild(child) })</script >
改動很簡單,,就是將對.child 節(jié)點的引用移動到了 click 事件的回調函數中,,那么當移除節(jié)點并退出回調函數的執(zhí)行上文后就會自動清除對該節(jié)點的引用,那么自然就不會存在內存泄漏的情況了,,我們來驗證一下,,如下圖所示: 5.4 控制臺的打印 控制臺的打印也會造成內存泄漏嗎,??,?,?是的呀,如果瀏覽器不一直保存著我們打印對象的信息,,我們?yōu)楹文茉诿看未蜷_控制的 Console 時看到具體的數據呢,?先來看一段測試代碼: <button > 按鈕</button > <script > document .querySelector('button' ).addEventListener('click' , function ( ) { let obj = new Array (1000000 ) console .log(obj); })</script >
我們在按鈕的點擊回調事件中創(chuàng)建了一個很大的數組對象并打印,用 performance 來驗證一下: 開始錄制,,先觸發(fā)一次垃圾回收清除初始的內存,,然后點擊三次按鈕,即執(zhí)行了三次點擊事件,,最后再觸發(fā)一次垃圾回收,。查看錄制結果發(fā)現 JS Heap 曲線成階梯上升,并且最終保持的高度比初始基準線高很多,,這說明每次執(zhí)行點擊事件創(chuàng)建的很大的數組對象 obj 都因為 console.log 被瀏覽器保存了下來并且無法被回收 接下來注釋掉 console.log,,再來看一下結果: <button > 按鈕</button > <script > document .querySelector('button' ).addEventListener('click' , function ( ) { let obj = new Array (1000000 ) // console.log(obj); })</script >
可以看到沒有打印以后,每次創(chuàng)建的 obj 都立馬被銷毀了,并且最終觸發(fā)垃圾回收機制后跟初始的基準線同樣高,,說明已經不存在內存泄漏的現象了 其實同理,,console.log 也可以用 Memory 來進一步驗證 最后簡單總結一下:在開發(fā)環(huán)境下,可以使用控制臺打印便于調試,,但是在生產環(huán)境下,,盡可能得不要在控制臺打印數據。所以我們經常會在代碼中看到類似如下的操作: // 如果在開發(fā)環(huán)境下,,打印變量obj if (isDev) { console .log(obj) }
這樣就避免了生產環(huán)境下無用的變量打印占用一定的內存空間,,同樣的除了 console.log 之外,console.error,、console.info,、console.dir等等都不要在生產環(huán)境下使用 5.5 遺忘的定時器 其實定時器也是平時很多人會忽略的一個問題,比如定義了定時器后就再也不去考慮清除定時器了,,這樣其實也會造成一定的內存泄漏,。來看一個代碼示例: <button > 開啟定時器</button > <script > function fn1 ( ) { let largeObj = new Array (100000 ) setInterval(() => { let myObj = largeObj }, 1000 ) } document .querySelector('button' ).addEventListener('click' , function ( ) { fn1() })</script >
這段代碼是在點擊按鈕后執(zhí)行 fn1函數,fn1函數內創(chuàng)建了一個很大的數組對象 largeObj,,同時創(chuàng)建了一個 setInterval 定時器,,定時器的回調函數只是簡單的引用了一下變量 largeObj,我們來看看其整體的內存分配情況吧: 按道理來說點擊按鈕執(zhí)行 fn1函數后會退出該函數的執(zhí)行上下文,,緊跟著函數體內的局部變量應該被清除,,但圖中 performance 的錄制結果顯示似乎是存在內存泄漏問題的,即最終曲線高度比基準線高度要高,,那么再用 Memory 來確認一次: 在我們點擊按鈕后,,從動態(tài)內存分配的圖上看到出現一個藍色柱形,說明瀏覽器為變量 largeObj 分配了一段內存,,但是之后這段內存并沒有被釋放掉,,說明的確存在內存泄漏的問題,原因其實就是因為 setInterval 的回調函數內對變量 largeObj 有一個引用關系,,而定時器一直未被清除,,所以變量 largeObj 的內存也自然不會被釋放 那么我們如何來解決這個問題呢,假設我們只需要讓定時器執(zhí)行三次就可以了,,那么我們可以改動一下代碼: <button > 開啟定時器</button > <script > function fn1 ( ) { let largeObj = new Array (100000 ) let index = 0 let timer = setInterval(() => { if (index === 3 ) clearInterval(timer); let myObj = largeObj index ++ }, 1000 ) } document .querySelector('button' ).addEventListener('click' , function ( ) { fn1() })</script >
現在我們再通過 performance 和 memory 來看看還不會存在內存泄漏的問題 這次的錄制結果就能看出,,最后的曲線高度和初始基準線的高度一樣,說明并沒有內存泄漏的情況 這里做一個解釋,,圖中剛開始出現的藍色柱形是因為我在錄制后刷新了頁面,,可以忽略;然后我們點擊了按鈕,,看到又出現了一個藍色柱形,,此時就是為fn1函數中的變量 largeObj 分配了內存,,3s后該內存又被釋放了,即變成了灰色柱形,。所以我們可以得出結論,,這段代碼不存在內存泄漏的問題 簡單總結一下: 大家在平時用到了定時器,如果在用不到定時器后一定要清除掉,,否則就會出現本例中的情況,。除了 setTimeout 和 setInterval,其實瀏覽器還提供了一個API也可能就存在這樣的問題,,那就是requestAnimationFrame - 6 -
總結 在項目過程中,,如果遇到了某些性能問題可能跟內存泄漏有關時,就可以參照本文列舉的 5 種情況去排查,,一定能找到問題所在并給到解決辦法的,。 雖然 JavaScript 的垃圾回收是自動的,但我們有時也是需要考慮要不要手動清除某些變量的內存占用的,,例如你明確某個變量在一定條件下再也不需要,,但是還會被外部變量引用導致內存無法得到釋放時,你可以用 null 對該變量重新賦值就可以在后續(xù)垃圾回收階段釋放該變量的內存了,。 這個挑戰(zhàn) ,,是年輕人不可逃避的!
觀看視頻內容