JavaScript內存優化

相對C/C++ 而言,咱們所用的JavaScript 在內存這一方面的處理已經讓咱們在開發中更注重業務邏輯的編寫。可是隨着業務的不斷複雜化,單頁面應用、移動HTML5 應用和Node.js 程序等等的發展,JavaScript 中的內存問題所致使的卡頓、內存溢出等現象也變得再也不陌生。javascript

1. 語言層面的內存管理

1.1 做用域

做用域(scope)是JavaScript 編程中一個很是重要的運行機制,在同步JavaScript 編程中它並不能充分引發初學者的注意,但在異步編程中,良好的做用域控制技能成爲了JavaScript 開發者的必備技能。另外,做用域在JavaScript 內存管理中起着相當重要的做用。php

在JavaScript中,能造成做用域的有函數的調用、with語句和全局做用域。如如下代碼爲例:html

var foo = function() {
  var local = {};
};
foo();
console.log(local); //=> undefined

var bar = function() {
  local = {};
};
bar();
console.log(local); //=> {}

這裏咱們定義了foo()函數和bar()函數,他們的意圖都是爲了定義一個名爲local的變量。但最終的結果卻大相徑庭。前端

foo()函數中,咱們使用var語句來聲明定義了一個local變量,而由於函數體內部會造成一個做用域,因此這個變量便被定義到該做用域中。並且foo()函數體內並無作任何做用域延伸的處理,因此在該函數執行完畢後,這個local變量也隨之被銷燬。而在外層做用域中則沒法訪問到該變量。java

而在bar()函數內,local變量並無使用var語句進行聲明,取而代之的是直接把local做爲全局變量來定義。故外層做用域能夠訪問到這個變量。node

local = {};
// 這裏的定義等效於
global.local = {};

1.2 做用域鏈

在JavaScript編程中,你必定會遇到多層函數嵌套的場景,這就是典型的做用域鏈的表示。 如如下代碼所示:mysql

function foo() {
  var val = 'hello';

  function bar() {
    function baz() {
      global.val = 'world;'
    }
    baz();
    console.log(val); //=> hello
  }
  bar();
}
foo();

根據前面關於做用域的闡述,你可能會認爲這裏的代碼所顯示的結果是world,但實際的結果倒是hello。不少初學者在這裏就會開始感到困惑了,那麼咱們再來看看這段代碼是怎麼工做的。jquery

因爲JavaScript 中,變量標識符的查找是從當前做用域開始向外查找,直到全局做用域爲止。因此JavaScript 代碼中對變量的訪問只能向外進行,而不能逆而行之。web

baz()函數的執行在全局做用域中定義了一個全局變量val。而在bar()函數中,對val這一標識符進行訪問時,按照從內到外厄德查找原則:在bar函數的做用域中沒有找到,便到上一層,即foo()函數的做用域中查找。算法

然而,使你們產生疑惑的關鍵就在這裏:本次標識符訪問在foo()函數的做用域中找到了符合的變量,便不會繼續向外查找,故在baz()函數中定義的全局變量val並無在本次變量訪問中產生影響。

1.3 閉包

咱們知道JavaScript 中的標識符查找遵循從內到外的原則。但隨着業務邏輯的複雜化,單一的傳遞順序已經遠遠不能知足日益增多的新需求。

咱們先來看看下面的代碼:

function foo() {
  var local = 'Hello';
  return function() {
    return local;
  };
}
var bar = foo();
console.log(bar()); //=> Hello

這裏所展現的讓外層做用域訪問內層做用域的技術即是閉包(Closure)。得益於高階函數的應用,使foo()函數的做用域獲得『延伸』。

foo()函數返回了一個匿名函數,該函數存在於foo()函數的做用域內,因此能夠訪問到foo()函數做用域內的local變量,並保存其引用。而因這個函數直接返回了local變量,因此在外層做用域中即可直接執行bar()函數以得到local變量。

閉包是JavaScript 的高級特性,咱們能夠藉助它來實現更多更復雜的效果來知足不一樣的需求。可是要注意的是由於把帶有​​內部變量引用的函數帶出了函數外部,因此該做用域內的變量在函數執行完畢後的並不必定會被銷燬,直到內部變量的引用被所有解除。因此閉包的應用很容易形成內存沒法釋放的狀況。

2. JavaScript 的內存回收機制

這裏我將以Chrome 和Node.js 所使用的,由Google 推出的V8 引擎爲例,簡要介紹一下JavaScript 的內存回收機制,更詳盡的內容能夠購買個人好朋友樸靈的書《深刻淺出Node.js 》進行學習,其中『內存控制』一章中有至關詳細的介紹。

在V8 中,全部的JavaScript 對象都是經過『堆』來進行內存分配的。

當咱們在代碼中聲明變量並賦值時,V8 就會在堆內存中分配一部分給這個變量。若是已申請的內存不足以存儲這個變量時,V8 就會繼續申請內存,直到堆的大小達到了V8 的內存上限爲止。默認狀況下,V8 的堆內存的大小上限在64位系統中爲1464MB,在32位系統中則爲732MB,即約1.4GB 和0.7GB。

另外,V8 對堆內存中的JavaScript 對象進行分代管理:新生代和老生代。新生代即存活週期較短的JavaScript 對象,如臨時變量、字符串等;而老生代則爲通過屢次垃圾回收仍然存活,存活週期較長的對象,如主控制器、服務器對象等。

垃圾回收算法一直是編程語言的研發中是否重要的​​一環,而V8 中所使用的垃圾回收算法主要有如下幾種:

  1. Scavange 算法:經過複製的方式進行內存空間管理,主要用於新生代的內存空間;
  2. Mark-Sweep 算法和Mark-Compact 算法:經過標記來對堆內存進行整理和回收,主要用於老生代對象的檢查和回收。

PS: 更詳細的V8 垃圾回收實現能夠經過閱讀相關書籍、文檔和源代碼進行學習。

咱們再來看看JavaScript 引擎在什麼狀況下會對哪些對象進行回收。

2.1 做用域與引用

初學者經常會誤認爲當函數執行完畢時,在函數內部所聲明的對象就會被銷燬。但實際上這樣理解並不嚴謹和全面,很容易被其致使混淆。

引用(Reference)是JavaScript 編程中十分重要的一個機制,但奇怪的是通常的開發者都不會刻意注意它、甚至不瞭解它。引用是指『代碼對對象的訪問』這一抽象關係,它與C/C++ 的指針有點類似,但並不是同物。引用同時也是JavaScript 引擎在進行垃圾回收中最關鍵的一個機制。

一下面代碼爲例:

// ......
var val = 'hello world';
function foo() {
  return function() {
    return val;
  };
}
global.bar = foo();
// ......

閱讀完這段代碼,你可否說出這部分代碼在執行事後,有哪些對象是依然存活的麼?

根據相關原則,這段代碼中沒有被回收釋放的對象有valbar(),到底是什麼緣由使他們沒法被回收?

JavaScript 引擎是如何進行垃圾回收的?前面說到的垃圾回收算法只是用在回收時的,那麼它是如何知道哪些對象能夠被回收,哪些對象須要繼續生存呢?答案就是JavaScript 對象的引用。

JavaScript 代碼中,哪怕是簡單的寫下一個變量名稱做爲單獨一行而不作任何操做,JavaScript 引擎都會認爲這是對對象的訪問行爲,存在了對對象的引用。爲了保證垃圾回收的行爲不影響程序邏輯的運行,JavaScript 引擎就決不能把正在使用的對象進行回收,否則就亂套了。因此判斷對象是否正在使用中的標準,就是是否仍然存在對該對象的引用。但事實上,這是一種妥協的作法,由於JavaScript 的引用是能夠進行轉移的,那麼就有可能出現某些引用被帶到了全局做用域,但事實上在業務邏輯裏已經不須要對其進行訪問了,應該被回收,可是JavaScript 引擎仍會死板地認爲程序仍然須要它。

如何用正確的姿式使用變量、引用,正是從語言層面優化JavaScript 的關鍵所在。

3. 優化你的JavaScript

終於進入正題了,很是感謝你秉着耐心看到了這裏,通過上面這麼多介紹,相信你已經對JavaScript 的內存管理機制有了不錯的理解,那麼下面的技巧將會讓你如虎添翼。

3.1 善用函數

若是你有閱讀優秀JavaScript 項目的習慣的話,你會發現,不少大牛在開發前端JavaScript 代碼的時候,經常會使用一個匿名函數在代碼的最外層進行包裹。

;(function() {
  // 主業務代碼
})();

有的甚至更高級一點:

;(function(win, doc, $, undefined) {
  // 主業務代碼
})(window, document, jQuery);

甚至連如RequireJS, SeaJS, OzJS 等前端模塊化加載解決方案,都是採用相似的形式:

// RequireJS
define(['jquery'], function($) {
  // 主業務代碼
});

// SeaJS
define('m​​odule', ['dep', 'underscore'], function($, _) {
  // 主業務代碼
});

若是你說不少Node.js 開源項目的代碼都沒有這樣處理的話,那你就錯了。Node.js 在實際運行代碼以前,會把每個.js 文件進行包裝,變成以下的形式:

(function(exports, require, module, __dirname, __filename) {
  // 主業務代碼
});

這樣作有什麼好處?咱們都知道文章開始的時候就說了,JavaScript中能造成做用域的有函數的調用、with語句和全局做用域。而咱們也知道,被定義在全局做用域的對象,頗有多是會一直存活到進程退出的,若是是一個很大的對象,那就麻煩了。好比有的人喜歡在JavaScript中作模版渲染:

<?php
  $db = mysqli_connect(server, user, password, 'myapp');
  $topics = mysqli_query($db, "SELECT * FROM topics;");
?>
<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>你是猴子請來的逗比麼?</title>
</head>
<body>
  <ul id="topics"></ul>
  <script type="text/tmpl" id="topic-tmpl">
    <li class="topic">
      <h1><%=title%></h1>
      <p><%=content%></p>
    </li>
  </script>
  <script type="text/javascript">
    var data = <?php echo json_encode($topics); ?>;
    var topicTmpl = document.querySelector('#topic-tmpl').innerHTML;
    var render = function(tmlp, view) {
      var complied = tmlp
        .replace(/\n/g, '\\n')
        .replace(/<%=([\s\S]+?)%>/g, function(match, code) {
          return '" + escape(' + code + ') + "';
        });

      complied = [
        'var res = "";',
        'with (view || {}) {',
          'res = "' + complied + '";',
        '}',
        'return res;'
      ].join('\n');

      var fn = new Function('view', complied);
      return fn(view);
    };

    var topics = document.querySelector('#topics');
    function init()     
      data.forEach(function(topic) {
        topics.innerHTML += render(topicTmpl, topic);
      });
    }
    init();
  </script>
</body>
</html>

這種代碼在新手的做品中常常能看獲得,這裏存在什麼問題呢?若是在從數據庫中獲取到的數據的量是很是大的話,前端完成模板渲染之後,data變量便被閒置在一邊。可由於這個變量是被定義在全局做用域中的,因此JavaScript引擎不會將其回收銷燬。如此該變量就會一直存在於老生代堆內存中,直到頁面被關閉。

但是若是咱們做出一些很簡單的修改,在邏輯代碼外包裝一層函數,這樣效果就大不一樣了。當UI渲染完成以後,代碼對data的引用也就隨之解除,而在最外層函數執行完畢時,JavaScript引擎就開始對其中的對象進行檢查,data也就能夠隨之被回收。

3.2 絕對不要定義全局變量

咱們剛纔也談到了,當一個變量被定義在全局做用域中,默認狀況下JavaScript 引擎就不會將其回收銷燬。如此該變量就會一直存在於老生代堆內存中,直到頁面被關閉。

那麼咱們就一直遵循一個原則:絕對不要使用全局變量。雖然全局變量在開發中確實很省事,可是全局變量所致使的問題遠比其所帶來的方便更嚴重。

  1. 使變量不易被回收;
  2. 多人協做時容易產生混淆;
  3. 在做用域鏈中容易被幹擾。

配合上面的包裝函數,咱們也能夠經過包裝函數來處理『全局變量』。

3.3 手工解除變量引用

若是在業務代碼中,一個變量已經確切是再也不須要了,那麼就能夠手工解除變量引用,以使其被回收。

var data = { /* some big data */ };
// blah blah blah
data = null;

3.4 善用回調

除了使用閉包進行內部變量訪問,咱們還可使用如今十分流行的回調函數來進行業務處理。

function getData(callback) {
  var data = 'some big data';

  callback(null, data);
}

getData(function(err, data) {
  console.log(data);
});

回調函數是一種後續傳遞風格(Continuation Passing Style, CPS)的技術,這種風格的程序編寫將函數的業務重點從返回值轉移到回調函數中去。並且其相比閉包的好處也很多:

  1. 若是傳入的參數是基礎類型(如字符串、數值),回調函數中傳入的形參就會是複製值,業務代碼使用完畢之後,更容易被回收;
  2. 經過回調,咱們除了能夠完成同步的請求外,還能夠用在異步編程中,這也就是如今很是流行的一種編寫風格;
  3. 回調函數自身一般也是臨時的匿名函數,一旦請求函數執行完畢,回調函數自身的引用就會被解除,自身也獲得回收。

3.5 良好的閉包管理

當咱們的業務需求(如循環事件綁定、私有屬性、含參回調等)必定要使用閉包時,請謹慎對待其中的細節。

循環綁定事件可謂是JavaScript 閉包入門的必修課,咱們假設一個場景:有六個按鈕,分別對應六種事件,當用戶點擊按鈕時,在指定的地方輸出相應的事件。

var btns = document.querySelectorAll('.btn'); // 6 elements
var output = document.querySelector('#output');
var events = [1, 2, 3, 4, 5, 6];

// Case 1
for (var i = 0; i < btns.length; i++) {
  btns[i].onclick = function(evt) {
    output.innerText += 'Clicked ' + events[i];
  };
}

// Case 2
for (var i = 0; i < btns.length; i++) {
  btns[i].onclick = (function(index) {
    return function(evt) {
      output.innerText += 'Clicked ' + events[index];
    };
  })(i);
}

// Case 3
for (var i = 0; i < btns.length; i++) {
  btns[i].onclick = (function(event) {
    return function(evt) {
      output.innerText += 'Clicked ' + event;
    };
  })(events[i]);
}

這裏第一個解決方案顯然是典型的循環綁定事件錯誤,這裏不細說,詳細能夠參照我給一個網友的回答;而第二和第三個方案的區別就在於閉包傳入的參數。

第二個方案傳入的參數是當前循環下標,然後者是直接傳入相應的事件對象。事實上,後者更適合在大量數據應用的時候,由於在JavaScript的函數式編程中,函數調用時傳入的參數是基本類型對象,那麼在函數體內獲得的形參會是一個複製值,這樣這個值就被看成一個局部變量定義在函數體的做用域內,在完成事件綁定以後就能夠對events變量進行手工解除引用,以減輕外層做用域中的內存佔用了。並且當某個元素被刪除時,相應的事件監聽函數、事件對象、閉包函數也隨之被銷燬回收。

3.6 內存不是緩存

緩存在業務開發中的做用舉足輕重,能夠減輕時空資源的負擔。但須要注意的是,不要輕易將內存看成緩存使用。內存對於任何程序開發來講都是寸土寸金的東西,若是不是很重要的資源,請不要直接放在內存中,或者制定過時機制,自動銷燬過時緩存。

4. 檢查JavaScript 的內存使用狀況

在平時的開發中,咱們也能夠藉助一些工具來對JavaScript 中內存使用狀況進行分析和問題排查。

4.1 Blink / Webkit 瀏覽器

在Blink / Webkit 瀏覽器中(Chrome, Safari, Opera etc.),咱們能夠藉助其中的Developer Tools 的Profiles 工具來對咱們的程序進行內存檢查。

Developer Tools - Profiles

4.2 Node.js 中的內存檢查

在Node.js 中,咱們可使用node-heapdump 和node-memwatch 模塊進​​行內存檢查。

var heapdump = require('heapdump');
var fs = require('fs');
var path = require('path');
fs.writeFileSync(path.join(__dirname, 'app.pid'), process.pid);
// ...

在業務代碼中引入node-heapdump 以後,咱們須要在某個運行時期,向Node.js 進程發送SIGUSR2 信號,讓node-heapdump 抓拍一份堆內存的快照。

$ kill -USR2 (cat app.pid)

這樣在文件目錄下會有一個以heapdump-<sec>.<usec>.heapsnapshot格式命名的快照文件,咱們可使用瀏覽器的Developer Tools中的Profiles工具將其打開,並進行檢查。

5. 小結

很快又來到了文章的結束,這篇分享主要向你們展現瞭如下幾點內容:

  1. JavaScript 在語言層面上,與內存使用息息相關的東西;
  2. JavaScript 中的內存管理、回收機制;
  3. 如何更高效地使用內存,以致於讓出產的JavaScript 能更有拓展的活力;
  4. 如何在遇到內存問題的時候,進行內存檢查。
相關文章
相關標籤/搜索