這篇文章是針對瀏覽器的 JavaScript 腳本,Node.js 大同小異,這裏不涉及到 Node.js 的場景。固然 Node.js 做爲服務端語言,必然更關注內存泄漏的問題。javascript
用戶通常不會在一個 Web 頁面停留比較久,即便有一點內存泄漏,重載頁面內存也會跟着釋放。並且瀏覽器也有自動回收內存的機制,因此咱們前端其實並無像 C、C++ 這類語言同樣,特別關注內存泄漏的問題。html
可是若是咱們對內存泄漏沒有什麼概念,有時候仍是有可能由於內存泄漏,致使頁面卡頓。瞭解內存泄漏,如何避免內存泄漏,也是咱們提高前端技能的必經之路。前端
俗話說好記憶不如爛筆頭,因此本人就總結了一些內存泄漏相關的知識,避免一些低級的內存泄漏問題。vue
在硬件級別上,計算機內存由大量觸發器組成。每一個觸發器包含幾個晶體管,可以存儲一個位。單個觸發器能夠經過惟一標識符尋址,所以咱們能夠讀取和覆蓋它們。所以,從概念上講,咱們能夠把咱們的整個計算機內存看做是一個巨大的位數組,咱們能夠讀和寫。java
這麼底層的概念,瞭解下就好,絕大多數數狀況下,JavaScript 語言做爲大家高級語言,無需咱們使用二進制進直接進行讀和寫。程序員
內存也是有生命週期的,無論什麼程序語言,通常能夠按順序分爲三個週期:es6
分配期web
分配所須要的內存算法
使用期chrome
使用分配到的內存(讀、寫)
釋放期
不須要時將其釋放和歸還
內存分配 -> 內存使用 -> 內存釋放。
在計算機科學中,內存泄漏指因爲疏忽或錯誤形成程序未能釋放已經再也不使用的內存。內存泄漏並不是指內存在物理上的消失,而是應用程序分配某段內存後,因爲設計錯誤,致使在釋放該段內存以前就失去了對該段內存的控制,從而形成了內存的浪費。
若是內存不須要時,沒有通過生命週期的釋放期,那麼就存在內存泄漏。
內存泄漏簡單理解:無用的內存還在佔用,得不到釋放和歸還。比較嚴重時,無用的內存會持續遞增,從而致使整個系統卡頓,甚至崩潰。
像 C 語言這樣的底層語言通常都有底層的內存管理接口,好比
malloc()
和free()
。相反,JavaScript是在建立變量(對象,字符串等)時自動進行了分配內存,而且在不使用它們時「自動」釋放。 釋放的過程稱爲垃圾回收。這個「自動」是混亂的根源,並讓JavaScript(和其餘高級語言)開發者錯誤的感受他們能夠不關心內存管理。
JavaScript 內存管理機制和內存的生命週期是一一對應的。首先須要分配內存,而後使用內存,最後釋放內存。
其中 JavaScript 語言不須要程序員手動分配內存,絕大部分狀況下也不須要手動釋放內存,對 JavaScript 程序員來講一般就是使用內存(即便用變量、函數、對象等)。
JavaScript 定義變量就會自動分配內存的。咱們只需瞭解 JavaScript 的內存是自動分配的就足夠了。
看下內存自動分配的例子:
// 給數值變量分配內存
let number = 123;
// 給字符串分配內存
const string = "xianshannan";
// 給對象及其包含的值分配內存
const object = {
a: 1,
b: null
};
// 給數組及其包含的值分配內存(就像對象同樣)
const array = [1, null, "abra"];
// 給函數(可調用的對象)分配內存
function func(a){
return a;
}
複製代碼
使用值的過程其實是對分配內存進行讀取與寫入的操做。讀取與寫入多是寫入一個變量或者一個對象的屬性值,甚至傳遞函數的參數。
根據上面的內存自動分配例子,咱們繼續內存使用的例子:
// 寫入內存
number = 234;
// 讀取 number 和 func 的內存,寫入 func 參數內存
func(number);
複製代碼
前端界通常稱垃圾內存回收爲 GC
(Garbage Collection,即垃圾回收)。
內存泄漏通常都是發生在這一步,JavaScript 的內存回收機制雖然能回收絕大部分的垃圾內存,可是仍是存在回收不了的狀況,若是存在這些狀況,須要咱們手動清理內存。
之前一些老版本的瀏覽器的 JavaScript 回收機制沒那麼完善,常常出現一些 bug 的內存泄漏,不過如今的瀏覽器基本都沒這些問題了,已過期的知識這裏就不作深究了。
這裏瞭解下如今的 JavaScript 的垃圾內存的兩種回收方式,熟悉下這兩種算法能夠幫助咱們理解一些內存泄漏的場景。
這是最初級的垃圾收集算法。此算法把「對象是否再也不須要」簡化定義爲「對象有沒有其餘對象引用到它」。若是沒有引用指向該對象(零引用),對象將被垃圾回收機制回收。
看下下面的例子,「這個對象」的內存被回收了嗎?
// 「這個對象」分配給 a 變量
var a = {
a: 1,
b: 2,
}
// b 引用「這個對象」
var b = a;
// 如今,「這個對象」的原始引用 a 被 b 替換了
a = 1;
複製代碼
當前執行環境中,「這個對象」內存尚未被回收的,須要手動釋放「這個對象」的內存(固然是還沒離開執行環境的狀況下),例如:
b = null;
// 或者 b = 1,反正替換「這個對象」就好了
複製代碼
這樣引用的"這個對象"的內存就被回收了。
ES6 把引用有區分爲強引用和弱引用,這個目前只有再 Set 和 Map 中才有。
強引用纔會有引用計數疊加,只有引用計數爲 0 的對象的內存纔會被回收,因此通常須要手動回收內存(手動回收的前提在於標記清除法還沒執行,還處於當前執行環境)。
而弱引用沒有觸發引用計數疊加,只要引用計數爲 0,弱引用就會自動消失,無需手動回收內存。
當變量進入執行環境時標記爲「進入環境」,當變量離開執行環境時則標記爲「離開環境」,被標記爲「進入環境」的變量是不能被回收的,由於它們正在被使用,而標記爲「離開環境」的變量則能夠被回收
環境能夠理解爲咱們的做用域,可是全局做用域的變量只會在頁面關閉纔會銷燬。
// 假設這裏是全局變量
// b 被標記進入環境
var b = 2;
function test() {
var a = 1;
// 函數執行時,a 被標記進入環境
return a + b;
}
// 函數執行結束,a 被標記離開環境,被回收
// 可是 b 就沒有被標記離開環境
test();
複製代碼
JavaScript 的內存回收機制雖然能回收絕大部分的垃圾內存,可是仍是存在回收不了的狀況。程序員要讓瀏覽器內存泄漏,瀏覽器也是管不了的。
下面有些例子是在執行環境中,沒離開當前執行環境,還沒觸發標記清除法。因此你須要讀懂上面 JavaScript 的內存回收機制,才能更好理解下面的場景。
// 在全局做用域下定義
function count(number) {
// basicCount 至關於 window.basicCount = 2;
basicCount = 2;
return basicCount + number;
}
複製代碼
不過在 eslint 幫助下,這種場景如今基本沒人會犯了,eslint 會直接報錯,瞭解下就好。
無用的計時器忘記清理是新手最容易犯的錯誤之一。
就拿一個 vue 組件來作例子。
<template>
<div></div>
</template>
<script> export default { methods: { refresh() { // 獲取一些數據 }, }, mounted() { setInterval(function() { // 輪詢獲取數據 this.refresh() }, 2000) }, } </script>
複製代碼
上面的組件銷燬的時候,setInterval
仍是在運行的,裏面涉及到的內存都是無法回收的(瀏覽器會認爲這是必須的內存,不是垃圾內存),須要在組件銷燬的時候清除計時器,以下:
<template>
<div></div>
</template>
<script> export default { methods: { refresh() { // 獲取一些數據 }, }, mounted() { this.refreshInterval = setInterval(function() { // 輪詢獲取數據 this.refresh() }, 2000) }, beforeDestroy() { clearInterval(this.refreshInterval) }, } </script>
複製代碼
無用的事件監聽器忘記清理是新手最容易犯的錯誤之一。
仍是繼續使用 vue 組件作例子。
<template>
<div></div>
</template>
<script> export default { mounted() { window.addEventListener('resize', () => { // 這裏作一些操做 }) }, } </script>
複製代碼
上面的組件銷燬的時候,resize 事件仍是在監聽中,裏面涉及到的內存都是無法回收的(瀏覽器會認爲這是必須的內存,不是垃圾內存),須要在組件銷燬的時候移除相關的事件,以下:
<template>
<div></div>
</template>
<script> export default { mounted() { this.resizeEventCallback = () => { // 這裏作一些操做 } window.addEventListener('resize', this.resizeEventCallback) }, beforeDestroy() { window.removeEventListener('resize', this.resizeEventCallback) }, } </script>
複製代碼
若是對 Set 不熟悉,能夠看這裏。
以下是有內存泄漏的(成員是引用類型的,即對象):
let map = new Set();
let value = { test: 22};
map.add(value);
value= null;
複製代碼
須要改爲這樣,纔沒內存泄漏:
let map = new Set();
let value = { test: 22};
map.add(value);
map.delete(value);
value = null;
複製代碼
有個更便捷的方式,使用 WeakSet,WeakSet 的成員是弱引用,內存回收不會考慮到這個引用是否存在。
let map = new WeakSet();
let value = { test: 22};
map.add(value);
value = null;
複製代碼
若是對 Map 不熟悉,能夠看這裏。
以下是有內存泄漏的(鍵值是引用類型的,即對象):
let map = new Map();
let key = new Array(5 * 1024 * 1024);
map.set(key, 1);
key = null;
複製代碼
須要改爲這樣,纔沒內存泄漏:
let map = new Map();
let key = new Array(5 * 1024 * 1024);
map.set(key, 1);
map.delete(key);
key = null;
複製代碼
有個更便捷的方式,使用 WeakMap,WeakMap 的鍵名是弱引用,內存回收不會考慮到這個引用是否存在。
let map = new WeakMap();
let key = new Array(5 * 1024 * 1024);
map.set(key, 1);
key = null;
複製代碼
這個跟上面的被遺忘的事件監聽器的道理是同樣的。
假設訂閱發佈事件有三個方法 emit
、on
、off
三個方法。
仍是繼續使用 vue 組件作例子。
<template>
<div @click="onClick"></div>
</template>
<script> import customEvent from 'event' export default { methods: { onClick() { customEvent.emit('test', { type: 'click' }) }, }, mounted() { customEvent.on('test', data => { // 一些邏輯 console.log(data) }) }, } </script>
複製代碼
上面的組件銷燬的時候,自定義 test 事件仍是在監聽中,裏面涉及到的內存都是無法回收的(瀏覽器會認爲這是必須的內存,不是垃圾內存),須要在組件銷燬的時候移除相關的事件,以下:
<template>
<div @click="onClick"></div>
</template>
<script> import customEvent from 'event' export default { methods: { onClick() { customEvent.emit('test', { type: 'click' }) }, }, mounted() { customEvent.on('test', data => { // 一些邏輯 console.log(data) }) }, beforeDestroy() { customEvent.off('test') }, } </script>
複製代碼
閉包是常用的,閉包能給咱們帶來不少便利。
首先看下這個代碼:
function closure() {
const name = 'xianshannan'
return () => {
return name
.split('')
.reverse()
.join('')
}
}
const reverseName = closure()
// 這裏調用了 reverseName
reverseName();
複製代碼
上面有沒有內存泄漏?
上面是沒有內存泄漏的,name
變量是要用到的(非垃圾),name
內存沒法回收。這也是從側面反映了閉包的缺點,內存佔用相對高,量多了會有性能影響。
可是改爲這樣就是有內存泄漏的:
function closure() {
const name = 'xianshannan'
return () => {
return name
.split('')
.reverse()
.join('')
}
}
closure()
複製代碼
嚴格來講,這樣是有內存泄漏的,name
變量是被 closure
返回的函數調用了,可是返回的函數沒被使用,這個場景下 name
就屬於垃圾內存。name
不是必須的,可是仍是佔用了內存,也不可被回收。
固然這種也是極端狀況,不多人會犯這種低級錯誤。這個例子可讓咱們更清楚的認識內存泄漏。
每一個頁面上的 DOM 都是佔用內存的,假設有一個頁面 A 元素,咱們獲取到了 A 元素 DOM 對象,而後賦值到了一個變量(內存指向是同樣的),而後移除了頁面的 A 元素,若是這個變量因爲其餘緣由沒有被回收,那麼就存在內存泄漏,以下面的例子:
class Test {
constructor() {
this.elements = {
button: document.querySelector('#button'),
div: document.querySelector('#div'),
span: document.querySelector('#span'),
}
}
removeButton() {
document.body.removeChild(this.elements.button)
// this.elements.button = null
}
}
const a = new Test()
a.removeButton()
複製代碼
上面的例子 button 元素 雖然在頁面上移除了,可是內存指向換爲了 this.elements.button
,內存佔用仍是存在的。因此上面的代碼還須要這樣寫: this.elements.button = null
,手動釋放這個內存。
內存泄漏時,內存通常都是會週期性的增加,咱們能夠藉助谷歌瀏覽器的開發者工具進行判別。
這裏不進行詳細的開發者工具使用說明,詳細看谷歌開發者工具,不過谷歌瀏覽器是不斷迭代更新的,有些文檔落後了,界面長得不同。
本人測試的谷歌版本爲:版本 76.0.3809.100(正式版本) (64 位)。
這裏針對下面例子進行一步一步的排查和找到問題出如今哪裏:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<div id="app">
<button id="run">運行</button>
<button id="stop">中止</button>
</div>
<script> const arr = [] for (let i = 0; i < 200000; i++) { arr.push(i) } let newArr = [] function run() { newArr = newArr.concat(arr) } let clearRun document.querySelector('#run').onclick = function() { clearRun = setInterval(() => { run() }, 1000) } document.querySelector('#stop').onclick = function() { clearInterval(clearRun) } </script>
</body>
</html>
複製代碼
上面例子的代碼能夠直接運行的,怎麼運行我就很少說了。
訪問上面的代碼頁面,打開谷歌開發者工具,切換至 Performance 選項,勾選 Memory
選項。
在頁面上點擊運行按鈕,而後在開發者工具上面點擊左上角的錄製按鈕,10 秒後在頁面上點擊中止按鈕,5 秒後中止內存錄制。獲得的內存走勢以下:
由上圖可知,10 秒以前內存週期性增加,10 後點擊了中止按鈕,內存平穩,再也不遞增。
咱們可使用內存走勢圖判斷當前頁面是否有內存泄漏。通過測試上面的代碼 20000
個數組項改成 20
個數組項,內存走勢也同樣能看出來。
上一步確認是內存泄漏問題後,咱們繼續利用谷歌開發者工具進行問題查找。
訪問上面的代碼頁面,打開谷歌開發者工具,切換至 Memory 選項。頁面上點擊運行按鈕,而後點擊開發者工具左上角錄製按鈕,錄製完成後繼續點擊錄製,知道錄製完三個爲止。而後點擊頁面的中止按鈕,再連續錄製 3 次內存(不要清理以前的錄製)。下圖就是進行這些步驟後的截圖:
從這裏也能夠看出,點擊運行按鈕後,內存在不斷遞增。點擊中止按鈕後,內存就平穩了。雖然咱們也可使用這樣的方式來判別是否存在內存泄漏,可是不夠第一步的方法便捷,走勢圖也更直觀。
而後第二步的主要目的來了,記錄 JavaScript 堆內存纔是內存錄制的主要目的,咱們能夠看到哪一個堆佔用的內存更高。
在剛纔的錄製中選擇 Snapshot 3 ,而後按照 Shallow Size 進行逆序排序(不瞭解的能夠看內存術語),以下:
從內存記錄中,發現 array 對象佔用最大,展開後發現,第一個 object elements
佔用最大,選擇這個 object elements
後能夠在下面看到 newArr
變量,而後點擊 test:23
,只要是高亮下劃線的地方均可以進去看看 (測試頁面是 test.html),能夠跳轉到 newArr
附近。