看完離編寫高性能的JavaScript又近了一步

副標題:常見的JavaScript內存泄露javascript

這是關於JavaScript內存泄露相關的序列文章中一篇。因爲時間有限更新進度會有點慢,但會持續更新的。本身也在學習中,不免對某些知識點的理解不是很正確,因此纔將文章放置github上,一是想與你們分享,二是方便持續更新,三是便於實時修正錯誤點。也但願看本文的各位同窗能多提issues,我會根據提的意見不斷完善文章。最後但願各位能從文章中有所收穫----->🎉 enjoy reading, enjoy life 🐳php

✏️最新內容請以github上的爲準❗️

序列文章連接

什麼是內存泄露

內存泄漏指因爲疏忽或錯誤形成程序未能釋放已經再也不使用的內存。內存泄漏並不是指內存在物理上的消失,而是應用程序分配某段內存後,因爲設計錯誤,致使在釋放該段內存以前就失去了對該段內存的控制,從而形成了內存的浪費。 內存泄漏一般狀況下只能由得到程序源代碼的程序員才能分析出來。然而,有很多人習慣於把任何不須要的內存使用的增長描述爲內存泄漏,即便嚴格意義上來講這是不許確的。 ————wikipediahtml

⚠️注:下文中標註的CG是Chrome瀏覽器中Devtools的【Collect garbage】按鈕縮寫,表示回收垃圾操做 java

cg

意外的全局變量

JavaScript對未聲明變量的處理方式:在全局對象上建立該變量的引用(即全局對象上的屬性,不是變量,由於它能經過delete刪除)。若是在瀏覽器中,全局對象就是window對象。node

若是未聲明的變量緩存大量的數據,會致使這些數據只有在窗口關閉或從新刷新頁面時才能被釋放。這樣會形成意外的內存泄漏。jquery

function foo(arg) {
    bar = "this is a hidden global variable with a large of data";
}
複製代碼

等同於:git

function foo(arg) {
    window.bar = "this is an explicit global variable with a large of data";
}
複製代碼

另外,經過this建立意外的全局變量:程序員

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

// 當在全局做用域中調用foo函數,此時this指向的是全局對象(window),而不是'undefined'
foo();
複製代碼

解決方法:

在JavaScript文件中添加'use strict',開啓嚴格模式,能夠有效地避免上述問題。github

function foo(arg) {
 "use strict" // 在foo函數做用域內開啓嚴格模式
    bar = "this is an explicit global variable with a large of data";// 報錯:由於bar尚未被聲明
}
複製代碼

若是須要在一個函數中使用全局變量,能夠像以下代碼所示,在window上明確聲明:web

function foo(arg) {
    window.bar = "this is a explicit global variable with a large of data";
}
複製代碼

這樣不只可讀性高,並且後期維護也方便

談到全局變量,須要注意那些用來臨時存儲大量數據的全局變量,確保在處理完這些數據後將其設置爲null或從新賦值。全局變量也經常使用來作cache,通常cache都是爲了性能優化纔用到的,爲了性能,最好對cache的大小作個上限限制。由於cache是不能被回收的,越高cache會致使越高的內存消耗。

console.log

console.log:向web開發控制檯打印一條消息,經常使用來在開發時調試分析。有時在開發時,須要打印一些對象信息,但發佈時卻忘記去掉console.log語句,這可能形成內存泄露。

在傳遞給console.log的對象是不能被垃圾回收 ♻️,由於在代碼運行以後須要在開發工具能查看對象信息。因此最好不要在生產環境中console.log任何對象。

實例------>demos/log.html

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Leaker</title>
</head>

<body>
  <input type="button" value="click">
  <script> !function () { function Leaker() { this.init(); }; Leaker.prototype = { init: function () { this.name = (Array(100000)).join('*'); console.log("Leaking an object %o: %o", (new Date()), this);// this對象不能被回收 }, destroy: function () { // do something.... } }; document.querySelector('input').addEventListener('click', function () { new Leaker(); }, false); }() </script>
</body>

</html>
複製代碼

這裏結合Chrome的Devtools–>Performance作一些分析,操做步驟以下:

⚠️注:最好在隱藏窗口中進行分析工做,避免瀏覽器插件影響分析結果

  1. 開啓【Performance】項的記錄
  2. 執行一次CG,建立基準參考線
  3. 連續單擊【click】按鈕三次,新建三個Leaker對象
  4. 執行一次CG
  5. 中止記錄

能夠看出【JS Heap】線最後沒有降回到基準參考線的位置,顯然存在沒有被回收的內存。若是將代碼修改成:

!function () {
      function Leaker() {
        this.init();
      };
      Leaker.prototype = {
        init: function () {
          this.name = (Array(100000)).join('*');
        },

        destroy: function () {
          // do something....
        }
      };
      document.querySelector('input').addEventListener('click', function () {
        new Leaker();
      }, false);
    }()
複製代碼

去掉console.log("Leaking an object %o: %o", (new Date()), this);語句。重複上述的操做步驟,分析結果以下:

從對比分析結果可知,console.log打印的對象是不會被垃圾回收器回收的。所以最好不要在頁面中console.log任何大對象,這樣可能會影響頁面的總體性能,特別在生產環境中。除了console.log外,另外還有console.dirconsole.errorconsole.warn等都存在相似的問題,這些細節須要特別的關注。

closures(閉包)

當一個函數A返回一個內聯函數B,即便函數A執行完,函數B也能訪問函數A做用域內的變量,這就是一個閉包——————本質上閉包是將函數內部和外部鏈接起來的一座橋樑。

function foo(message) {
    function closure() {
        console.log(message)
    };
    return closure;
}

// 使用
var bar = foo("hello closure!");
bar()// 返回 'hello closure!'
複製代碼

在函數foo內建立的函數closure對象是不能被回收掉的,由於它被全局變量bar引用,處於一直可訪問狀態。經過執行bar()能夠打印出hello closure!。若是想釋放掉能夠將bar = null便可。

因爲閉包會攜帶包含它的函數的做用域,所以會比其餘函數佔用更多的內存。過分使用閉包可能會致使內存佔用過多。

實例------>demos/closures.html

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Closure</title>
</head>

<body>
  <p>不斷單擊【click】按鈕</p>
  <button id="click_button">Click</button>
  <script> function f() { var str = Array(10000).join('#'); var foo = { name: 'foo' } function unused() { var message = 'it is only a test message'; str = 'unused: ' + str; } function getData() { return 'data'; } return getData; } var list = []; document.querySelector('#click_button').addEventListener('click', function () { list.push(f()); }, false); </script>
</body>

</html>
複製代碼

這裏結合Chrome的Devtools->Memory工具進行分析,操做步驟以下:

⚠️注:最好在隱藏窗口中進行分析工做,避免瀏覽器插件影響分析結果

  1. 選中【Record allocation timeline】選項
  2. 執行一次CG
  3. 單擊【start】按鈕開始記錄堆分析
  4. 連續單擊【click】按鈕十屢次
  5. 中止記錄堆分析

closure

上圖中藍色柱形條表示隨着時間新分配的內存。選中其中某條藍色柱形條,過濾出對應新分配的對象:

closure

查看對象的詳細信息:

closure

從圖可知,在返回的閉包做用鏈(Scopes)中攜帶有它所在函數的做用域,做用域中還包含一個str字段。而str字段並無在返回getData()中使用過。爲何會存在在做用域中,按理應該被GC回收掉, why:question:

緣由是在相同做用域內建立的多個內部函數對象是共享同一個變量對象(variable object)。若是建立的內部函數沒有被其餘對象引用,無論內部函數是否引用外部函數的變量和函數,在外部函數執行完,對應變量對象便會被銷燬。反之,若是內部函數中存在有對外部函數變量或函數的訪問(能夠不是被引用的內部函數),而且存在某個或多個內部函數被其餘對象引用,那麼就會造成閉包,外部函數的變量對象就會存在於閉包函數的做用域鏈中。這樣確保了閉包函數有權訪問外部函數的全部變量和函數。瞭解了問題產生的緣由,即可以對症下藥了。對代碼作以下修改:

function f() {
      var str = Array(10000).join('#');
      var foo = {
        name: 'foo'
      }
      function unused() {
        var message = 'it is only a test message';
        // str = 'unused: ' + str; //刪除該條語句
      }
      function getData() {
        return 'data';
      }
      return getData;
    }

    var list = [];
    
    document.querySelector('#click_button').addEventListener('click', function () {
      list.push(f());
    }, false);
複製代碼

getData()和unused()內部函數共享f函數對應的變量對象,由於unused()內部函數訪問了f做用域內str變量,因此str字段存在於f變量對象中。加上getData()內部函數被返回,被其餘對象引用,造成了閉包,所以對應的f變量對象存在於閉包函數的做用域鏈中。這裏只要將函數unused中str = 'unused: ' + str;語句刪除即可解決問題。

closure

查看一下閉包信息:

closure

DOM泄露

在JavaScript中,DOM操做是很是耗時的。由於JavaScript/ECMAScript引擎獨立於渲染引擎,而DOM是位於渲染引擎,相互訪問須要消耗必定的資源。如Chrome瀏覽器中DOM位於WebCore,而JavaScript/ECMAScript位於V8中。假如將JavaScript/ECMAScript、DOM分別想象成兩座孤島,兩島之間經過一座收費橋鏈接,過橋須要交納必定「過橋費」。JavaScript/ECMAScript每次訪問DOM時,都須要交納「過橋費」。所以訪問DOM次數越多,費用越高,頁面性能就會受到很大影響。瞭解更多ℹ️

爲了減小DOM訪問次數,通常狀況下,當須要屢次訪問同一個DOM方法或屬性時,會將DOM引用緩存到一個局部變量中。但若是在執行某些刪除、更新操做後,可能會忘記釋放掉代碼中對應的DOM引用,這樣會形成DOM內存泄露。

實例------>demos/dom.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Dom-Leakage</title>
</head>
<body>
  <input type="button" value="remove" class="remove" style="display:none;">
  <input type="button" value="add" class="add">

  <div class="container">
    <pre class="wrapper"></pre>
  </div>
  <script> // 由於要屢次用到pre.wrapper、div.container、input.remove、input.add節點,將其緩存到本地變量中, var wrapper = document.querySelector('.wrapper'); var container = document.querySelector('.container'); var removeBtn = document.querySelector('.remove'); var addBtn = document.querySelector('.add'); var counter = 0; var once = true; // 方法 var hide = function(target){ target.style.display = 'none'; } var show = function(target){ target.style.display = 'inline-block'; } // 回調函數 var removeCallback = function(){ removeBtn.removeEventListener('click', removeCallback, false); addBtn.removeEventListener('click', addCallback, false); hide(addBtn); hide(removeBtn); container.removeChild(wrapper); } var addCallback = function(){ wrapper.appendChild(document.createTextNode('\t' + ++counter + ':a new line text\n')); // 顯示刪除操做按鈕 if(once){ show(removeBtn); once = false; } } // 綁定事件 removeBtn.addEventListener('click', removeCallback, false); addBtn.addEventListener('click', addCallback, false); </script>
</body>
</html>
複製代碼

這裏結合Chrome瀏覽器的Devtools–>Performance作一些分析,操做步驟以下:

⚠️注:最好在隱藏窗口中進行分析工做,避免瀏覽器插件影響分析結果

  1. 開啓【Performance】項的記錄
  2. 執行一次CG,建立基準參考線
  3. 連續單擊【add】按鈕6次,增長6個文本節點到pre元素中
  4. 單擊【remove】按鈕,刪除剛增長6個文本節點和pre元元素
  5. 執行一次CG
  6. 中止記錄堆分析

dom

從分析結果圖可知,雖然6次add操做增長6個Node,可是remove操做並無讓Nodes節點數降低,即remove操做失敗。儘管還主動執行了一次CG操做,Nodes曲線也沒有降低。所以能夠判定內存泄露了!那問題來了,如何去查找問題的緣由呢?這裏能夠經過Chrome瀏覽器的Devtools–>Memory進行診斷分析,執行以下操做步驟:

⚠️注:最好在隱藏窗口中進行分析工做,避免瀏覽器插件影響分析結果

  1. 選中【Take heap snapshot】選項
  2. 連續單擊【add】按鈕6次,增長6個文本節點到pre元素中
  3. 單擊【Take snapshot】按鈕,執行一次堆快照
  4. 單擊【remove】按鈕,刪除剛增長6個文本節點和pre元元素
  5. 單擊【Take snapshot】按鈕,執行一次堆快照
  6. 選中生成的第二個快照報告,並將視圖由"Summary"切換到"Comparison"對比模式,在[class filter]過濾輸入框中輸入關鍵字:Detached

dom

從分析結果圖可知,致使整個pre元素和6個文本節點沒法別回收的緣由是:代碼中存在全局變量wrapper對pre元素的引用。知道了產生的問題緣由,即可對症下藥了。對代碼作以下就修改:

// 由於要屢次用到pre.wrapper、div.container、input.remove、input.add節點,將其緩存到本地變量中,
    var wrapper = document.querySelector('.wrapper');
    var container = document.querySelector('.container');
    var removeBtn = document.querySelector('.remove');
    var addBtn = document.querySelector('.add');
    var counter = 0;
    var once = true;
    // 方法
    var hide = function(target){
      target.style.display = 'none';
    }
    var show = function(target){
      target.style.display = 'inline-block';
    }
    // 回調函數
    var removeCallback = function(){
      removeBtn.removeEventListener('click', removeCallback, false);
      addBtn.removeEventListener('click', addCallback, false);
      hide(addBtn);
      hide(removeBtn);
      container.removeChild(wrapper);
     
      wrapper = null;//在執行刪除操做時,將wrapper對pre節點的引用釋放掉
    }
    var addCallback = function(){
      wrapper.appendChild(document.createTextNode('\t' + ++counter + ':a new line text\n'));
      // 顯示刪除操做按鈕
      if(once){
        show(removeBtn);
        once = false;
      }
    }
    // 綁定事件
    removeBtn.addEventListener('click', removeCallback, false);
    addBtn.addEventListener('click', addCallback, false);
複製代碼

在執行刪除操做時,將wrapper對pre節點的引用釋放掉,即在刪除邏輯中增長wrapper = null;語句。再次在Devtools–>Performance中重複上述操做:

dom

小試牛刀------>demos/dom_practice.html

再來看看網上的一個實例,代碼以下:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Practice</title>
</head>
<body>
  <div id="refA"><ul><li><a href="#"></a></li><li><a href="#"></a></li><li><a href="#" id="refB"></a></li></ul></div>
  <div></div>
  <div></div>

  <script> var refA = document.getElementById('refA'); var refB = document.getElementById('refB'); document.body.removeChild(refA); // #refA不能GC回收,由於存在變量refA對它的引用。將其對#refA引用釋放,但仍是沒法回收#refA。 refA = null; // 還存在變量refB對#refA的間接引用(refB引用了#refB,而#refB屬於#refA)。將變量refB對#refB的引用釋放,#refA就能夠被GC回收。 refB = null; </script>
</body>
</html>
複製代碼

整個過程以下圖所演示:

有興趣的同窗可使用Chrome的Devtools工具,驗證一下分析結果,實踐很重要~~~:high_brightness:

timers

在JavaScript經常使用setInterval()來實現一些動畫效果。固然也可使用鏈式setTimeout()調用模式來實現:

setTimeout(function() {
  // do something. . . .
  setTimeout(arguments.callee, interval);
}, interval);
複製代碼

若是在不須要setInterval()時,沒有經過clearInterval()方法移除,那麼setInterval()會不停地調用函數,直到調用clearInterval()或窗口關閉。若是鏈式setTimeout()調用模式沒有給出終止邏輯,也會一直運行下去。所以再不須要重複定時器時,確保對定時器進行清除,避免佔用系統資源。另外,在使用setInterval()setTimeout()來實現動畫時,沒法確保定時器按照指定的時間間隔來執行動畫。爲了能在JavaScript中建立出平滑流暢的動畫,瀏覽器爲JavaScript動畫添加了一個新API-requestAnimationFrame()。關於setInterval、setTimeout與requestAnimationFrame實現動畫上的區別➹猛擊😊

實例------>demos/timers.html

以下經過setInterval()實現一個clock的小實例,不過代碼存在問題的,有興趣的同窗能夠先嚐試找一下問題的所在~~~~~😎 操做:

  • 單擊【start】按鈕開始clock,同時web開發控制檯會打印實時信息
  • 單擊【stop】按鈕中止clock,同時web開發控制檯會輸出中止信息
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>setInterval</title>
</head>
<body>
  <input type="button" value="start" class="start">
  <input type="button" value="stop" class="stop">

  <script> var counter = 0; var clock = { start: function () { setInterval(this.step.bind(null, ++counter), 1000); }, step: function (flag) { var date = new Date(); var h = date.getHours(); var m = date.getMinutes(); var s = date.getSeconds(); console.log("%d-----> %d:%d:%d", flag, h, m, s); } } document.querySelector('.start').addEventListener('click', clock.start.bind(clock), false); document.querySelector('.stop').addEventListener('click', function () { console.log('----> stop <----'); clock = null; }, false); </script>
</body>
</html>
複製代碼

上述代碼存在兩個問題:

  1. 若是不斷的單擊【start】按鈕,會斷生成新的clock。

  2. 單擊【stop】按鈕不能中止clock。

輸出結果:

針對暴露出的問題,對代碼作以下修改:

var counter = 0;
    var clock = {
      timer: null,
      start: function () {
        // 解決第一個問題
        if (this.timer) {
          clearInterval(this.timer);
        }
        this.timer = setInterval(this.step.bind(null, ++counter), 1000);
      },
      step: function (flag) {
        var date = new Date();
        var h = date.getHours();
        var m = date.getMinutes();
        var s = date.getSeconds();
        console.log("%d-----> %d:%d:%d", flag, h, m, s);
      },
      // 解決第二個問題
      destroy: function () {
        console.log('----> stop <----');
        clearInterval(this.timer);
        node = null;
        counter = void(0);
      }
    }
    document.querySelector('.start').addEventListener('click', clock.start.bind(clock), false);
    document.querySelector('.stop').addEventListener('click', clock.destroy.bind(clock), false);
複製代碼

EventListener

作移動開發時,須要對不一樣設備尺寸作適配。如在開發組件時,有時須要考慮處理橫豎屏適配問題。通常作法,在橫豎屏發生變化時,須要將組件銷燬後再從新生成。而在組件中會對其進行相關事件綁定,若是在銷燬組件時,沒有將組件的事件解綁,在橫豎屏發生變化時,就會不斷地對組件進行事件綁定。這樣會致使一些異常,甚至可能會致使頁面崩掉。

實例------>demos/callbacks.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>callbacks</title>
</head>
<body>
  <div class="container"></div>
  <script> var container = document.querySelector('.container'); var counter = 0; var createHtml = function (n, counter) { var template = `${(new Array(n)).join(`<div>${counter}: this is a new data <input type="button" value="remove"></div>`)}` container.innerHTML = template; } var resizeCallback = function (init) { createHtml(10, ++counter); // 事件委託 container.addEventListener('click', function (event){ var target = event.target; if(target.tagName === 'INPUT'){ container.removeChild(target.parentElement) } }, false); } window.addEventListener('resize', resizeCallback, false); resizeCallback(true); </script>
</body>
</html>
複製代碼

頁面是存在問題的,這裏結合Devtools–>Performance分析一下問題所在,操做步驟以下:

⚠️注:最好在隱藏窗口中進行分析工做,避免瀏覽器插件影響分析結果

  1. 開啓Performance項的記錄
  2. 執行一次CG,建立基準參考線
  3. 對窗口大小進行調整
  4. 執行一次CG
  5. 中止記錄

callbacks

如分析結果所示,在窗口大小變化時,會不斷地對container添加代理事件。

同一個元素節點註冊了多個相同的EventListener,那麼重複的實例會被拋棄。這麼作不會讓得EventListener被重複調用,也不須要用removeEventListener手動清除多餘的EventListener,由於重複的都被自動拋棄了。而這條規則只是針對於命名函數。對於匿名函數,瀏覽器會將其看作不一樣的EventListener,因此只要將匿名的EventListener,命名一下就能夠解決問題:

var container = document.querySelector('.container');
    var counter = 0;
    var createHtml = function (n, counter) {
      var template = `${(new Array(n)).join(`<div>${counter}: this is a new data <input type="button" value="remove"></div>`)}`
      container.innerHTML = template;
    }
    // 
    var clickCallback = function (event) {
      var target = event.target;
      if (target.tagName === 'INPUT') {
        container.removeChild(target.parentElement)
      }
    }
    var resizeCallback = function (init) {
      createHtml(10, ++counter);
      // 事件委託
      container.addEventListener('click', clickCallback, false);
    }
    window.addEventListener('resize', resizeCallback, false);
    resizeCallback(true);
複製代碼

在Devtools–>Performance中再重複上述操做,分析結果以下:

callback

在開發中,開發者不多關注事件解綁,由於瀏覽器已經爲咱們處理得很好了。不過在使用第三方庫時,須要特別注意,由於通常第三方庫都實現了本身的事件綁定,若是在使用過程當中,在須要銷燬事件綁定時,沒有調用所解綁方法,就可能形成事件綁定數量的不斷增長。以下連接是我在項目中使用jquery,碰見到相似問題:jQuery中忘記解綁註冊的事件,形成內存泄露➹猛擊😊

總結

本文主要介紹了幾種常見的內存泄露。在開發過程,須要咱們特別留意一下本文所涉及到的幾種內存泄露問題。由於這些隨時可能發生在咱們平常開發中,若是咱們對它們不瞭解是很難發現它們的存在。可能在它們將問題影響程度放大時,纔會引發咱們的關注。不過那時可能就晚了,由於產品可能已經上線,接着就會嚴重影響產品的質量和用戶體驗,甚至可能讓咱們承受大量用戶流失的損失。做爲開發的咱們必須把好這個關,讓咱們開發的產品帶給用戶最好的體驗。

參考文章:

相關文章
相關標籤/搜索