標籤:非同步 res 聲明變數 顯示 google 混淆 順序 結束 介紹
相對C/C++ 而言,我們所用的JavaScript 在記憶體這一方面的處理已經讓我們在開發中更注重商務邏輯的編寫。但是隨著業務的不斷複雜化,單頁面應用、移動HTML5 應用和Node.js 程式等等的發展,JavaScript 中的記憶體問題所導致的卡頓、記憶體溢出等現象也變得不再陌生。
1. 語言層面的記憶體管理1.1 範圍
範圍(scope)是JavaScript 編程中一個非常重要的運行機制,在同步JavaScript 編程中它並不能充分引起初學者的注意,但在非同步編程中,良好的範圍控制技能成為了JavaScript 開發人員的必備技能。另外,範圍在JavaScript 記憶體管理中起著至關重要的作用。
在JavaScript中,能形成範圍的有函數的調用、with
語句和全域範圍。如以下代碼為例:
var foo = function() { var local = {};};foo();console.log(local); //=> undefinedvar bar = function() { local = {};};bar();console.log(local); //=> {}
這裡我們定義了foo()
函數和bar()
函數,他們的意圖都是為了定義一個名為local
的變數。但最終的結果卻截然不同。
在foo()
函數中,我們使用var
語句來聲明定義了一個local
變數,而因為函數體內部會形成一個範圍,所以這個變數便被定義到該範圍中。而且foo()
函數體內並沒有做任何範圍延伸的處理,所以在該函數執行完畢後,這個local
變數也隨之被銷毀。而在外層範圍中則無法訪問到該變數。
而在bar()
函數內,local
變數並沒有使用var
語句進行聲明,取而代之的是直接把local
作為全域變數來定義。故外層範圍可以訪問到這個變數。
local = {};// 這裡的定義等效於global.local = {};
1.2 範圍鏈
在JavaScript編程中,你一定會遇到多層函數嵌套的情境,這就是典型的範圍鏈的表示。 如以下代碼所示:
function foo() { var val = ‘hello‘; function bar() { function baz() { global.val = ‘world;‘ } baz(); console.log(val); //=> hello } bar();}foo();
根據前面關於範圍的闡述,你可能會認為這裡的代碼所顯示的結果是world
,但實際的結果卻是hello
。很多初學者在這裡就會開始感到困惑了,那麼我們再來看看這段代碼是怎麼工作的。
由於JavaScript 中,變數標識符的尋找是從當前範圍開始向外尋找,直到全域範圍為止。所以JavaScript 代碼中對變數的訪問只能向外進行,而不能逆而行之。
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 中所使用的記憶體回收演算法主要有以下幾種:
- Scavange 演算法:通過複製的方式進行記憶體空間管理,主要用於新生代的記憶體空間;
- 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();// ......
閱讀完這段代碼,你能否說出這部分代碼在執行過後,有哪些對象是依然存活的嗎?
根據相關原則,這段代碼中沒有被回收釋放的對象有val
和bar()
,究竟是什麼原因使他們無法被回收?
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 等前端模組化載入解決方案,都是採用類似的形式:
// RequireJSdefine([‘jquery‘], function($) { // 主業務代碼});// SeaJSdefine(‘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 引擎就不會將其回收銷毀。如此該變數就會一直存在於老生代堆記憶體中,直到頁面被關閉。
那麼我們就一直遵循一個原則:絕對不要使用全域變數。雖然全域變數在開發中確實很省事,但是全域變數所導致的問題遠比其所帶來的方便更嚴重。
- 使變數不易被回收;
- 多人協作時容易產生混淆;
- 在範圍鏈中容易被幹擾。
配合上面的封裝函數,我們也可以通過封裝函數來處理『全域變數』。
3.3 手工解除變數引用
如果在業務代碼中,一個變數已經確切是不再需要了,那麼就可以手工解除變數引用,以使其被回收。
var data = { /* some big data */ };// blah blah blahdata = 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)的技術,這種風格的程式編寫將函數的業務重點從傳回值轉移到回呼函數中去。而且其相比閉包的好處也不少:
- 如果傳入的參數是基礎類型(如字串、數值),回呼函數中傳入的形參就會是複製值,業務代碼使用完畢以後,更容易被回收;
- 通過回調,我們除了可以完成同步的請求外,還可以用在非同步編程中,這也就是現在非常流行的一種編寫風格;
- 回呼函數自身通常也是臨時的匿名函數,一旦請求函數執行完畢,回呼函數自身的引用就會被解除,自身也得到回收。
3.5 良好的閉包管理
當我們的業務需求(如迴圈事件綁定、私人屬性、含參回調等)一定要使用閉包時,請謹慎對待其中的細節。
迴圈綁定事件可謂是JavaScript 閉包入門的必修課,我們假設一個情境:有六個按鈕,分別對應六種事件,當使用者點擊按鈕時,在指定的地方輸出相應的事件。
var btns = document.querySelectorAll(‘.btn‘); // 6 elementsvar output = document.querySelector(‘#output‘);var events = [1, 2, 3, 4, 5, 6];// Case 1for (var i = 0; i < btns.length; i++) { btns[i].onclick = function(evt) { output.innerText += ‘Clicked ‘ + events[i]; };}// Case 2for (var i = 0; i < btns.length; i++) { btns[i].onclick = (function(index) { return function(evt) { output.innerText += ‘Clicked ‘ + events[index]; }; })(i);}// Case 3for (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 工具來對我們的程式進行記憶體檢查。
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. 小結
很快又來到了文章的結束,這篇分享主要向大家展示了以下幾點內容:
- JavaScript 在語言層面上,與記憶體使用量息息相關的東西;
- JavaScript 中的記憶體管理、回收機制;
- 如何更高效地使用記憶體,以至於讓出產的JavaScript 能更有拓展的活力;
- 如何在遇到記憶體問題的時候,進行記憶體檢查。
JavaScript記憶體最佳化