1. 程式人生 > >How Javascript works (Javascript工作原理) (三) 內存管理及如何處理 4 類常見的內存泄漏問題

How Javascript works (Javascript工作原理) (三) 內存管理及如何處理 4 類常見的內存泄漏問題

當前 高頻 listen tno 閑置 memory 結束 點擊 手動

個人總結:

1.內存回收機制—標記清楚算法:從根(全局變量)開始向後代變量檢測,任何根變量所到達不了的變量會被標記為內存垃圾。

這是 JavaScript 工作原理的第三章。

我們將會討論日常使用中另一個被開發者越來越忽略的重要話題,這都是日益成熟和復雜的編程語言的鍋,即內存管理問題。

概述

像 C 語言擁有底層的內存管理原語如 malloc()free()。開發者使用這些原語來顯式從操作系統分配和釋放內存。

與此同時,當創建事物(對象,字符串等)的時候,JavaScript 分配內存並且當它們不再使用的時候 "自動釋放" 內存,這一過程稱為內存垃圾回收。這個乍看起來本質上是 "自動化釋放內存" 的釋放資源是引起混亂的原因,並且給予 JavaScript(及其它高級語言)開發者一個錯誤的印象即他們可以選擇忽略內存管理。這是一個巨大的錯誤。

即使是當使用高級語言的時候,開發者也應該要理解內存管理(或者至少是一些基礎)。有時候自動化內存管理會存在一些問題(比如垃圾回收中的 bugs 或者實施的局限性等等),為了能夠合理地處理內存泄漏問題(或者以最小代價和代碼缺陷來尋找一個合適的方案),開發者就必須理解內存管理。

內存生命周期

不管你使用哪種編程語言,內存生命周期幾乎是一樣的:

技術分享圖片

以下是每一步生命周期所發生事情的一個概述:

  • 分配內存-內存是由操作系統分配,這樣程序就可以使用它。在底層語言(例如 C 語言),開發者可以顯式地操作內存。而在高級語言中,操作系統幫你處理。
  • 使用內存-這是程序實際使用之前分配的內存的階段。當你在代碼中使用已分配的變量的時候,就會發生內存讀寫的操作。
  • 釋放內存-該階段你可以釋放你不再使用的整塊內存,該內存就可以被釋放且可以被再利用。和內存分配操作一樣,該操作也是用底層語言顯式編寫的。

為快速瀏覽調用堆棧和動態內存管理的概念,你可以閱讀第一篇文章。

啥是內存?

在直接跳向 JavaScript 內存管理之前,先來簡要地介紹一下內存及其工作原理。

從硬件層面看,計算機內存是由大量的 flip flops 所組成的(這裏大概查了下,即大量的二進制電路所組成的)。每個 flip flop 包含少量晶體管並能夠存儲一個比特位。單個的 flip flops 可以通過一個唯一標識符尋址,所以就可以讀和覆寫它們。因此,理論上,我們可以把整個計算機內存看成是由一個巨大的比特位數組所組成的,這樣就可以進行讀和寫。

作為猿類,我們並不擅長用位來進行所有的邏輯思考和計算,所以我們把位組織成一個更大的組,這樣就可以用來表示數字。8 位稱為一字節。除了字節還有字(16 或 32 位)。

內存中存儲著很多東西:

  • 所有變量及所有程序使用的其它數據。
  • 程序代碼,包括操作系統的代碼。

編譯器和操作系統一起協作來為你進行內存管理,但是建議你了解一下底層是如何實現的。

當編譯代碼的時候,編譯器會檢查原始數據類型並提前計算出程序運行所需要的內存大小。在所謂的靜態堆棧空間中,所需的內存大小會被分配給程序。這些變量所分配到的內存所在的空間之所以被稱為靜態內存空間是因為當調用函數的時候,函數所需的內存會被添加到現存內存的頂部。當函數中斷,它們被以 LIFO(後進先出) 的順序移出內存。比如,考慮如下代碼:

int n; // 4 字節
int x[4]; // 4 個元素的數組,每個數組元素 4 個字節
double m; // 8 字節

編譯器會立即計算出代碼所需的內存:4 + 4 x 4 + 8 = 28 字節。

編譯器是這樣處理當前整數和浮點數的大小的。大約 20 年前,整數一般是 2 字節而 浮點數是 4 字節。代碼不用依賴於當前基礎數據類型的字節大小。

編譯器會插入標記,標記會和操作系統協商從堆棧中獲取所需要的內存大小,以便在堆棧中存儲變量。

在以上示例中,編譯知道每個變量的準確內存地址。事實上,當你編寫變量 n 的時候,會在內部把它轉換為類似 "內存地址 412763" 的樣子。

註意到這裏當我們試圖訪問 x[4] 時候,將會訪問到 m 相關的數據。這是因為我們訪問了數組中不存在的數組元素-它超過了最後一個實際分配到內存的數組元素 x[3] 4 字節,並且有可能會讀取(或者覆寫) m 的位。這幾乎可以確定會產生其它程序所預料不到的後果。

技術分享圖片

當函數調用其它函數的時候,各個函數都會在被調用的時候取得其在堆棧中的各自分片內存地址。函數會把保存它所有的本地變量,但也會有一個程序計數器用來記住函數在其執行環境中的地址。當函數運行結束時,其內存塊可以再次被用作其它用途。

動態內存分配

不幸的是,想要知道編譯時一個變量需要多少內存並沒有想象中那般容易。設想一下若要做類似如下事情:

int n = readInput(); // 從用戶讀取信息
...
// 創建一個含有 n 個元素的數組

這裏,編譯器並不知道編譯時數組需要多少內存,因為這是由用戶輸入的數組元素的值所決定的。

因此,就不能夠在堆棧中為變量分配內存空間。相反,程序需要在運行時顯式地從操作系統分配到正確的內存空間。這裏的內存是由動態內存空間所分配的。靜態和動態內存分配的差異總結如下圖表:

技術分享圖片

*靜態和動態分配內存的區別*

為了完全理解動態內存分配的工作原理,我們需要花點時間了解指針,這個就可能有點跑題了 ^.^。如果你對指針感興趣,請留言,然後我們將會在以後的章節中討論更多關於指針的內容。

JavaScript 中的內存分配

現在,我們將會介紹在 JavaScript 中是如何分配內存的((第一步)。

JavaScript 通過聲明變量值,自己處理內存分配工作而不需要開發者幹涉。

var n = 374; // 為數字分配內存
var s = ‘sessionstack‘; // 為字符串分配內存

var o = {
  a: 1,
  b: null
}; // 為對象及其值分配內存

var a = [1, null, ‘str‘]; // (類似對象)為數組及其數組元素值分配內存

function f(a) {
  return a + 3;
} // 分配一個函數(一個可調用對象)

// 函數表達式也分配一個對象
someElement.addEventListener(‘click‘, function() {
  someElement.style.backgroundColor = ‘blue‘;
}, false);

一些函數調用也會分配一個對象:

var d = new Date(); // 分配一個日期對象

var e = document.createElement(‘div‘); // 分配一個 DOM 元素

可以分配值或對象的方法:

var s1 = ‘sessionstack‘;
var s2 = s1.substr(0, 3); // s2 為一個新字符串
// 因為字符串是不可變的,所以 JavaScript 可能會選擇不分配內存而只是存儲數組 [0, 3] 的內存地址範圍。
var a1 = [‘str1‘, ‘str2‘];
var a2 = [‘str3‘, ‘str4‘];
var a3 = a1.concat(a2);
// 包含 4 個元素的新數組由 a1 和 a2 數組元素所組成

JavaScript 中的內存使用

JavaScript 中使用分配的內存主要指的是內存讀寫。

可以通過為變量或者對象屬性賦值,亦或是為函數傳參來使用內存。

釋放不再使用的內存

大多數的內存管理問題是出現在這一階段。

痛點在於檢測出何時分配的內存是閑置的。它經常會要求開發者來決定程序中的這段內存是否已經不再使用,然後釋放它。

高級程序語言集成了一塊稱為垃圾回收器的軟件,該軟件的工作就是追蹤內存分配和使用情況以便找出並自動釋放閑置的分配內存片段。

不幸的是,這是個近似的過程,因為判定一些內存片段是否閑置的普遍問題在於其不可判定性(不能為算法所解決)。

大多數的垃圾回收器會收集那些不再被訪問的內存,比如引用該內存的所有變量超出了內存尋址範圍。然而還是會有低於近似值的內存空間被收集,因為在任何情況下仍然可能會有變量在內存尋址範圍內引用該內存地址,即使該內存是閑置的。

內存垃圾回收

由於找出 "不再使用" 的內存的不可判定性,針對這一普遍問題,垃圾回收實現了一個有限的解決方案。本小節將會闡述必要的觀點來理解主要的內存垃圾回收算法及其局限性。

內存引用

引用是內存垃圾回收算法所依賴的主要概念之一。

在內存管理上下文中,如果對象 A 訪問了另一個對象 B 表示 A 引用了對象 B(可以隱式或顯式)。舉個栗子,一個 JavaScript 對象有引用了它的原型(隱式引用)和它的屬性值(顯式引用)。

在這個上下文中,"對象" 的概念被拓展超過了一般的 JavaScript 對象並且包含函數作用域(或者全局詞法作用域)。

詞法作用域定義了如何在嵌套函數中解析變量名。即使父函數已經返回,內部的函數仍然會包含父函數的作用域。

垃圾回收引用計數

這是最簡單的內存垃圾回收算法。當一個對象被 0 引用,會被標記為 "可回收內存垃圾"。

看下如下代碼:

var o1 = {
  o2: {
    x: 1
  }
};

// 創建兩個對象。
// ‘o1‘ 引用對象 ‘o2‘ 作為其屬性。全部都是不可回收的。

// ‘o3‘ 是第二個引用 ‘o1‘ 對象的變量
var o3 = o1;

o1 = 1; // 現在,原先在 ‘o1‘ 中的對象只有一個單一的引用,以變量 ‘o3‘ 來表示

// 引用對象的 ‘o2‘ 屬性。
// 該對象有兩個引用:一個是作為屬性,另一個是 ‘o4‘ 變量
var o4 = o3.o2;

// ‘o1‘ 對象現在只有 0 引用,它可以被作為內存垃圾回收。
// 然而,其 ‘o2‘ 屬性仍然被變量 ‘o4‘ 所引用,所以它的內存不能夠被釋放。
o3 = ‘374‘;

o4 = null;
// ‘o1‘ 中的 ‘o2‘ 屬性現在只有 0 引用了。所以 ‘o1‘ 對象可以被回收。

循環引用是個麻煩事

循環引用會造成限制。在以下的示例中,創建了兩個互相引用的對象,這樣就會造成循環引用。函數調用之後他們將會超出範圍,所以,實際上它們是無用且可以釋放對他們的引用。然而,引用計數算法會認為由於兩個對象都至少互相引用一次,所以他們都不可回收的。

function f() {
  var o1 = {};
  var o2 = {};
  o1.P = O2; // O1 引用 o2
  o2.p = o1; // o2 引用 o1. 這就造成循環引用
}

f();

技術分享圖片

標記-清除算法

為了判斷是否需要釋放對對象的引用,算法會確定該對象是否可獲得。

標記-清除算法包含三個步驟:

  • 根:一般來說,根指的是代碼中引用的全局變量。就拿 JavaScript 來說,window 對象即是根的全局變量。Node.js 中相對應的變量為 "global"。垃圾回收器會構建出一份所有根變量的完整列表。
  • 隨後,算法會檢測所有的根變量及他們的後代變量並標記它們為激活狀態(表示它們不可回收)。任何根變量所到達不了的變量(或者對象等等)都會被標記為內存垃圾。
  • 最後,垃圾回收器會釋放所有非激活狀態的內存片段然後返還給操作系統。

技術分享圖片


標記-清除算法的動態圖示

該算法比之前的算法要好,因為對象零引用可以讓對象不可獲得。反之則不然,正如之前所看到的循環引用。

從 2012 年起,所有的現代瀏覽器都內置了一個標記-清除垃圾回收器。前些年所有對於 JavaScript 內存垃圾收集(分代/增量/並發/並行 垃圾收集)的優化都是針對標記-清除算法的實現的優化,但既沒有提升垃圾收集算法本身,也沒有提升判定對象是否可獲得的能力。

你可以查看這篇文章 來了解追蹤內存垃圾回收的詳情及包含優化了的標記-清除算法。

循環引用不再讓人蛋疼

在之前的第一個示例中,當函數返回,全局對象不再引用這兩個對象。結果,內存垃圾回收器發現它們是不可獲得的。

技術分享圖片

即使兩個對象互相引用,也不能夠從根變量獲得他們。

內存垃圾回收器的反直觀行為

雖然內存垃圾回收器很方便,但是它們也有其一系列的代價。其中之一便是不確定性。意思即內存垃圾回收具有不可預見性。你不能確定內存垃圾收集的確切時機。這意味著在某些情況下,程序會使用比實際需要更多的內存。在其它情況下,在特定的交互敏感的程序中,你也許需要註意那些內存垃圾收集短暫停時間。雖然不確定性意味著不能夠確定什麽時候可以進行內存垃圾收集,但是大多數 GC 的實現都是在內存分配期間進行內存垃圾回收的一般模式。如果沒有進行內存分配,大多數的內存垃圾回收就會保持閑置狀態。考慮以下情況:

  • 分配一段固定大小的內存。
  • 大多數的元素(或所有)被標記為不可獲得(假設我們賦值我們不再需要的緩存為 null )
  • 不再分配其它內存。

在該情況下,大多數的內存垃圾回收器不會再運行任何的內存垃圾回收。換句話說,即使可以對該不可獲得的引用進行垃圾回收,但是內存收集器不會進行標記。雖然這不是嚴格意義上的內存泄漏,但是這會導致高於平常的內存使用率。

內存泄漏是啥?

正如內存管理所說的那樣,內存泄漏即一些程序在過去時使用但處於閑置狀態,卻沒有返回給操作系統或者可用的內存池。

技術分享圖片

編程語言喜歡多種內存管理方法。然而,某個內存片段是否被使用是一個不確定的問題。換句話說,只有開發人員清楚某個內存片段是否可以返回給操作系統。

某些編程語言會為開發者提供功能函數來解決這個問題。其它的編程語言完全依賴於開發者全權掌控哪個內存片段是可回收的。維其百科上有關於手動和自動內存管理的好文章。

四種常見的 JavaScript 內存泄漏

1: 全局變量

JavaScript 以一種有趣的方式來處理未聲明變量:當引用一個未聲明的變量,會在全局對象上創建一個新的變量。在瀏覽器中,全局對象是 window,這意味著如下代碼:

function foo(arg) {
  bar = "some text";
}

等同於:

function foo(arg) {
  window.bar = "some text";
}

變量 bar 本意是只能在 foo 函數中被引用。但是如果你沒有用 var 來聲明變量,那麽將會創建一個多余的全局變量。在上面的例子中,並不會造成大的事故。但你可以很自然地想象一個更具破壞性的場景。

你也可以使用 this 關鍵字不經意地創建一個全局變量。

function foo() {
  this.var1 = "potential accidental global";
}

// 調用 foo 函數自身,this 會指向全局對象(window)而不是未定義

你可以通過在 JavaScript 文件的頂部添加 ‘use strict‘ 來避免以上的所有問題,‘use strict‘ 會切換到更加嚴格的 JavaScript 解析模式,這樣就可以防止創建意外的全局變量。

意外的全局變量的確是個問題,而代碼經常會被顯式定義的全局變量所汙染,根據定義這些全局變量是不會被內存垃圾回收器所收集的。你需要特別註意的是使用全局變量來臨時存儲和處理大型的位信息。只有在必要的時候使用全局變量來存儲數據,記得一旦你不再使用的時候,把它賦值為 null 或者對其再分配。

2:定時器及被遺忘的回調函數

因為經常在 JavaScript 中使用 setInterval,所以讓我們以它為例。

框架中提供了觀察者和接受回調的其它指令通常會確保當他們的實例不可獲得的時候,所有對回調的引用都會變成不可獲得。很容易找到如下代碼:

var serverData = loadData();
setInterval(function() {
  var renderer = document.getElementById(‘renderer‘);
  if (renderer) {
    renderer.innerHTML = JSON.stringify(serverData);
  }
}, 5000); // 這將會每隔大約 5 秒鐘執行一次

以上代碼片段展示了使用定時器來引用不再需要的節點或數據的後果。

renderer 對象會在某些時候被替換或移除,這樣就會導致由定時處理程序封裝的代碼變得冗余。當這種情況發生的時候,不管是定時處理程序還是它的依賴都不會被垃圾回收,這是由於需要先停止定時器(記住,定時器仍然處於激活狀態)。這可以歸結為保存和處理數據加載的 serverData 變量也不會被垃圾回收。

當使用觀察者的時候,你需要確保一旦你不再需要它們的時候顯式地移除它們(不再需要觀察者或者對象變得不可獲得)。

幸運的是,大多數現代瀏覽器都會替你進行處理:當被觀察者對象變得不可獲得時,即使你忘記移除事件監聽函數,瀏覽器也會自動回收觀察者處理程序。以前,一些老掉牙的瀏覽器處理不了這些情況(如老舊的 IE6)。

那麽,最佳實踐是當對象被廢棄的時候,移除觀察者處理程序。查看如下例子:

var element = document.getElementById(‘launch-button‘);
var counter = 0;

function onClick(event) {
  counter++;
  element.innerHTML = ‘text‘ + counter;
}

element.addEventListener(‘click‘, onClick);

// Do stuff
element.removeEventListener(‘click‘, onClick);
element.parentNode.removeChild(element);
// 現在當元素超出範圍
// 即使在不能很好處理循環引用的瀏覽器中也會回收元素和 onClick 事件

在讓一個 DOM 節點不可獲得之前,你不再需要調用 removeEventListener,因為現代瀏覽器支持用內存垃圾回收器來檢測並適當地處理 DOM 節點的生命周期。

如果你使用 jQuery API(其它的庫和框架也支持的 API),你可以在廢棄節點之前移除事件監聽函數。jQuery 也會確保即使在老舊的瀏覽器之中,也不會產生內存泄漏。

閉包

閉包是 JavaScript 的一個重要功能:嵌套函數可以訪問外部(封閉)函數的變量。鑒於 JavaScript 運行時的實現細節,以下方法可能會造成內存泄漏:

var theThing = null

var replaceThing = function () {
  var originalThing = theThing;
  var unused = function () {
    if (originalThing) // 引用 originalThing
      console.log("hi");
  };
  theThing = {
    longStr: new Array(1000000).join(‘*‘),
    someMethod: function () {
      console.log("message");
    }
  };
};
setInterval(replaceThing, 1000);

當調用 replaceThing 的時候,theThing 對象由一個大數組和新的閉包(someMethod)所組成。而 originalThingunused變量創建的閉包所引用(即引用 replaceThing 函數之前的 theThing 變量)。需要記住的是當一旦為同一個父作用域中的閉包創建閉包作用域的時候,該閉包作用域是共享的。

在這樣的情況下,閉包 someMethodunused 共享相同的作用域。unused 引用了 origintalThing。即使 unused 永不使用,也可以在 replaceThing 的作用域外使用 someMethod 函數。然後由於 someMethodunused 共享相同的閉包作用域,unused 變量引用 originalThing 會強迫 unused 保持激活狀態(兩個閉包共享作用域)。這會阻止內存垃圾回收。

在以上例子中,閉包 someMethodunused 共享作用域,而 unused 引用 origintalThing。可以在 replaceThing 作用域外通過 theThing 使用 someMethod,即使 unused 從未被使用。事實上,由於 someMethodunused 共享閉包作用域,unused 引用 origintalThing 要求 unused 保持激活狀態。

所有的這些行為會導致內存泄漏。當你不斷地運行如上代碼片段,你將會發現飆升的內存使用率。當內存垃圾回收器運行的時候,這些內存使用率不會下降。這裏會創建出一份閉包鏈表(當前情況下,其根變量是 theThing),每個閉包作用域都間接引用了大數組。

該問題是由 Metor 小組發現的並且他們寫了一篇很好的文章來詳細描述該問題。

4: 源自 DOM 引用

有時候,開發者會在數據結構中存儲 DOM 節點。

假設你想要快速更新幾行表格內容。如果你在一個字典或者數組中保存對每個表格行的引用,這將會造成重復引用相同的 DOM 元素:一個在 DOM 樹中而另一個在字典中。如果你想要釋放對這些表格行的引用,你需要記得讓這些引用變成不可獲得。

var elements = {
  button: document.getElementById(‘button‘),
  image: document.getElementById(‘image‘)
};

function doStuff() {
  elements.image.src = ‘http://example.com/image_name.png‘;
}

function removeImage() {
    // image 元素是 body 元素的直系後代元素
    document.body.removeChild(document.getElementById(‘image‘));
    // 這時,我們仍然在 elements 全局對象中引用了 #button 元素
    // 換句話說,按鈕元素仍然在內存中且不能夠被垃圾回收器收集
}

你還需要額外考慮的情況是引用 DOM 樹中的內節點或者葉節點。如果你在代碼中保存著對一個單元格的引用,這時候當你決定從 DOM 中移除表格,卻仍然會保持對該單元格的引用,這就會導致大量的內存泄漏。你可以認為內存垃圾回收器將會釋放除了該單元格以外的內存。而這還沒完。因為單元格是表格的一個後代元素而後代元素保存著對其父節點的引用,對一個單元格的引用會導致無法釋放整個表格所占用的內存。

內存管理心得

以下內容為個人原創分享。By 三月。

指導思想

盡可能減少內存占用,盡可能減少 GC。

  • 減少 GC 次數

    瀏覽器會不定時回收垃圾內存,稱為 GC,不定時觸發,一般在向瀏覽器申請新內存時,瀏覽器會檢測是否到達一個臨界值再進行觸發。一般來說,GC 會較為耗時,GC 觸發時可能會導致頁面卡頓及丟幀。故我們要盡可能避免GC的觸發。GC 無法通過代碼觸發,但部分瀏覽器如 Chrome,可在 DevTools -> TimeLine 頁面手動點擊 CollectGarbage 按鈕觸發 GC。

  • 減少內存占用

    降低內存占用,可避免內存占用過多導致的應用/系統卡頓,App 閃退等,在移動端尤為明顯。當內存消耗較多時,瀏覽器可能會頻繁觸發 GC。而如前所述,GC 發生在申請新內存時,若能避免申請新內存,則可避免GC 觸發。

優化方案

使用對象池

對象池**(英語:object pool pattern)是一種設計模式。**一個對象池包含一組已經初始化過且可以使用的對象,而可以在有需求時創建和銷毀對象。池的用戶可以從池子中取得對象,對其進行操作處理,並在不需要時歸還給池子而非直接銷毀它。這是一種特殊的工廠對象。

若初始化、實例化的代價高,且有需求需要經常實例化,但每次實例化的數量較少的情況下,使用對象池可以獲得顯著的效能提升。從池子中取得對象的時間是可預測的,但新建一個實例所需的時間是不確定。

以上摘自維基百科。

使用對象池技術能顯著優化需頻繁創建對象時的內存消耗,但建議按不同使用場景做以下細微優化。

  1. 按需創建

    默認創建空對象池,按需創建對象,用完歸還池子。

  2. 預創建對象

    如在高頻操作下,如滾動事件、TouchMove事件、resize事件、for 循環內部等頻繁創建對象,則可能會觸發GC的發生。故在特殊情況下,可優化為提前創建對象放入池子。

    高頻情況下,建議使用截流/防抖及任務隊列相關技術。

  3. 定時釋放

    對象池內的對象不會被垃圾回收,若極端情況下創建了大量對象回收進池子卻不釋放只會適得其反。

    故池子需設計定時/定量釋放對象機制,如以已用容量/最大容量/池子使用時間等參數來定時釋放對象。

其他優化tips

  1. 盡可能避免創建對象,非必要情況下避免調用會創建對象的方法,如 Array.sliceArray.mapArray.filter、字符串相加、$(‘div‘)ArrayBuffer.slice 等。

  2. 不再使用的對象,手動賦為 null,可避免循環引用等問題。

  3. 使用 Weakmap

  4. 生產環境勿用 console.log 大對象,包括 DOM、大數組、ImageData、ArrayBuffer 等。因為 console.log 的對象不會被垃圾回收。詳見Will console.log prevent garbage collection?。

  5. 合理設計頁面,按需創建對象/渲染頁面/加載圖片等。

    • 避免如下問題:

      • 為了省事兒,一次性請求全部數據。
      • 為了省事兒,一次性渲染全部數據,再做隱藏。
      • 為了省事兒,一次性加載/渲染全部圖片。
    • 使用重復 DOM 等,如重復使用同一個彈窗而非創建多個。

      如 Vue-Element 框架中,PopOver/Tooltip 等組件用於表格內時會創建 m * n 個實例,可優化為只創建一個實例,動態設置位置及數據。

  6. ImageData 對象是 JS 內存殺手,避免重復創建 ImageData 對象。

  7. 重復使用 ArrayBuffer。

  8. 壓縮圖片、按需加載圖片、按需渲染圖片,使用恰當的圖片尺寸、圖片格式,如 WebP 格式。

圖片處理優化

假設渲染一張 100KB 大小,300x500 的透明圖片,粗略的可分為三個過程:

  1. 加載圖片

    加載圖片二進制格式到內存中並緩存,此時消耗了100KB 內存 & 100KB 緩存。

  2. 解碼圖片

    將二進制格式解碼為像素格式,此時占用寬 * 高 * 24(透明為32位)比特大小的內存,即 300 * 500 * 32,約等於 585 KB,這裏約定名為像素格式內存。個人猜測此時瀏覽器會回收加載圖片時創建的 100KB 內存。

  3. 渲染圖片

    通過 CPU 或 GPU 渲染圖片,若為 GPU 渲染,則還需上傳到 GPU 顯存,該過程較為耗時,由圖片尺寸 / 顯存位寬決定,圖片尺寸越大,上傳時間越慢,占用顯存越多。

其中,較舊的瀏覽器如Firefox回收像素內存時機較晚,若渲染了大量圖片時會內存占用過高。

PS:瀏覽器會復用同一份圖片二進制內存及像素格式內存,瀏覽器渲染圖片會按以下順序去獲取數據:

顯存 >> 像素格式內存 >> 二進制內存 >> 緩存 >> 從服務器獲取。我們需控制和優化的是二進制內存及像素內存的大小及回收。

總結一下,瀏覽器渲染圖片時所消耗內存由圖片文件大小內存、寬高、透明度等所決定,故建議:

  1. 使用 CSS3、SVG、IconFont、Canvas 替代圖片。展示大量圖片的頁面,建議使用 Canvas 渲染而非直接使用img標簽。具體詳見 Javascript的Image對象、圖像渲染與瀏覽器內存兩三事。

  2. 適當壓縮圖片,可減小帶寬消耗及圖片內存占用。

  3. 使用恰當的圖片尺寸,即響應式圖片,為不同終端輸出不同尺寸圖片,勿使用原圖縮小代替 ICON 等,比如一些圖片服務如 OSS。

  4. 使用恰當的圖片格式,如使用WebP格式等。詳細圖片格式對比,使用場景等建議查看web前端圖片極限優化策略。

  5. 按需加載及按需渲染圖片。

  6. 預加載圖片時,切記要將 img 對象賦為 null,否則會導致圖片內存無法釋放。

    當實際渲染圖片時,瀏覽器會從緩存中再次讀取。

  7. 將離屏 img 對象賦為 null,src 賦為 null,督促瀏覽器及時回收內存及像素格式內存。

  8. 將非可視區域圖片移除,需要時再次渲染。和按需渲染結合時實現很簡單,切換 src 與 v-src 即可。

How Javascript works (Javascript工作原理) (三) 內存管理及如何處理 4 類常見的內存泄漏問題