高效使用 JavaScript 閉包

BrockFitts 8年前發布 | 13K 次閱讀 閉包 前端技術 JavaScript

 

在 Node.js 中,廣泛采用不同形式的閉包來支持 Node 的異步和事件驅動編程模型。通過很好地理解閉包,您可以確保所開發應用程序的功能正確性、穩定性和可伸縮性。

閉包是一種將數據與處理數據的代碼相關聯的自然方式,它使用 continuation passing(后繼傳遞) 作為主要的語義風格。使用閉包時,您在一個封閉范圍內定義的數據源可供該范圍內創建的函數訪問,甚至在已經從邏輯上退出這個封閉范圍時也是如此。在函數是一等 (first-class) 變量的語言中(比如 JavaScript),此行為非常重要,因為函數的生命周期決定了函數可以看到的數據元素的生命周期。在此環境中,很容易由于疏忽而在內存中保留比期望的多得多的數據,這樣做很危險。

本教程將介紹在 Node 中使用閉包的 3 種主要用例:

  • 完成處理函數
  • 中間函數
  • 監聽器函數

對于每種用例,我們都提供了示例代碼,并指出了閉包的預期壽命和在壽命內保留的內存量。此信息可在設計 JavaScript 應用程序時幫助您深入了解這些用例如何影響內存使用,從而避免應用程序中的內存泄漏。

閱讀: 了解 JavaScript 應用程序中的內存泄漏

閱讀: JavaScript 中的內存泄漏模式

閱讀: 針對 IBM SDK for Node.js 的核心轉儲調試

閉包和異步編程

如果您熟悉傳統的順序編程,那么在首次嘗試了解異步模型時,您可能會問以下問題:

  • 如果異步調用一個函數,您如何確保在調用時它后面(或周圍)的代碼可以處理該范圍內的可用數據?或者換句話說,您如何實現依賴于異步調用的結果和副作用的剩余代碼?
  • 執行異步調用后,程序繼續執行與異步調用無關的代碼,您如何在異步調用完成后返回到最初的調用范圍來繼續運行?

閉包和回調可以回答這些問題。在最常見和最簡單的用例中,異步方法采用了一個回調方法(具有一個關聯的閉包)作為一個參數。此函數通常是在異步方法的調用位置上以內聯方式進行定義的,而且該函數能訪問圍繞調用位置的范圍的數據元素(局部變量和參數)。

舉例而言,看看以下 JavaScript 代碼:

function outer(a) {
  var b= 20; 
  function inner(c) {
   var d = 40;
   return a * b / (d  c);
 }
 return inner;
}

var x = outer(10);
var y = x(30);

這是一個實時調試會話中的同一段代碼的快照:

inner 函數在第 17 行調用(前面清單中的第 11 行)并在第 11 行上執行(該清單的第 5 行)。在第 16 行(清單中第 10 行),調用了 outer 函數 — 它返回 inner 函數。如屏幕截圖所示,在第 17 行調用了 inner 函數并在第 11 行執行時,它能夠訪問它的局部變量( c 和 d ) outer 函數中定義的變量( a 和 b ) — 盡管在第 16 行完成對 outer 函數的調用時已退出 outer 函數的范圍。

“ 要避免內存泄漏,了解回調方法何時和在多長時間內保持可訪問性很重要。 ”

回調方法處于一個可調用它的狀態(也就是說,從垃圾收集角度,可以訪問它),所以它保持它能訪問的所有數據元素處于活動狀態。要避免內存泄漏,了解回調方法何時和在多長時間內保持該狀態很重要。

總體上講,閉包通常在至少 3 種用例中很有用。在所有這 3 種用例中,基本前提都是一樣的:一小段可重用的代碼(一個可調用的函數)能夠處理并可選地保留一個上下文。

用例 1:完成處理函數

在完成處理函數模式中,將一個函數 ( C1 ) 作為參數傳遞給某個方法 ( M1 ),并在 M1 完成后調用 C1 作為完成處理函數。作為該模式的一部分, M1 的實現可確保在不再需要 C1 后,它保留的對 C1 的引用會被清除。 C1 常常需要調用 M1 的范圍中的一個或多個數據元素。提供對此范圍的訪問能力的閉包在創建 C1 時定義。常見的一種方法是使用在調用 M1 的地方以內聯方式定義的匿名方法。結果會得到一個 C1 閉包,它提供了訪問可供 M1 使用的所有變量和參數的能力。

一個示例是 setTimeout() 方法。計時器過期后,調用完成函數 (completion function),并清除為計時器保留的完成函數 ( C1 ) 引用:

function CustomObject() {
}

function run() {
  var data = new CustomObject()
  setTimeout(function() {
    data.i = 10
  }, 100)
}
run()

完成函數使用來自調用 setTimeout 方法的上下文的 data 變量。甚至在 run() 方法完成后,為完成處理函數創建的閉包仍有可能引用 CustomObject ,而不會對它進行垃圾收集。

內存保留

閉包上下文是在定義完成函數 ( C1 ) 時創建的,該上下文由可在創建 C1 的范圍中訪問的變量和參數組成。 C1 閉包會保留到以下時刻:

  • 完成方法被調用并完成運行, 或者 計時器被清除。
  • 不會發生對 C1 的其他引用。(對于匿名函數,如果滿足此列表中的前述條件,則不會發生任何其他引用。)

通過使用 Chrome 開發者工具 ,我們可以看到表示計時器的 Timeout 對象通過 _onTimeout 字段而擁有完成函數(傳遞給 setTimeout 的匿名方法)的引用:

高效使用 JavaScript 閉包

盡管計時器已過期,但 Timeout 對象、 _onTimeout 字段 和閉包函數都通過對它們的一個引用而保留在堆中 — 在系統中掛起的超時事件。激活計時器且后續回調完成時,會刪除事件循環中的掛起事件。所有 3 個對象都無法再訪問,而且它們符合在后續垃圾收集周期中收集的條件。

清除計時器時(通過 clearTimeout 方法),會從 _onTimeout 字段中刪除完成函數,而且,即使由于主函數保留了對 Timeout 對象的引用而保留了該對象,(只要不再發生對該函數的其他引用)該函數仍然可以在后續垃圾收集周期中收集。

在此屏幕截圖中,將會對比觸發計時器之前和之后獲取的 堆轉儲

高效使用 JavaScript 閉包

#New 列顯示了在轉儲之間添加的新對象,#Deleted 列顯示了在轉儲之間收集的對象。突出顯示的部分顯示, CustomObject 存在于第一個轉儲中,但已被收集且未包含在第二個轉儲中,因此釋放了 12 字節內存。

在此模式下,自然的執行流程使內存僅保留到完成處理函數 ( C1 ) 將其 “完成” 該方法 ( M1 ) 的工作處理完之時。結果是(只要及時完成應用程序調用的方法)您不需要特別注意避免內存泄漏。

設計實現此模式的函數時,請確保在觸發回調時清除了對回調函數的所有引用。這樣,即可確保滿足使用您的函數的應用程序的內存保留預期。

用例 2:中間函數

在某些情況下,您需要能夠以更加反復、迭代式和出乎意料的方式處理數據,無論數據是以異步創建還是同步方式創建的。對于這些情況,您可返回一個 中間函數 ,可調用該函數一次或多次來訪問所需的數據或完成所需的計算。與完成處理函數一樣,您在定義函數時創建閉包,閉包提供了訪問定義該函數的范圍中包含的所有變量和參數的能力。

此模式的一個例子是數據流處理,其中服務器返回一大塊數據,每收到一個數據塊,就會調用客戶端的數據接收器回調。因為數據流是異步的,所以操作(比如數據積累)必須是迭代式的,并以一種出乎意料的方式執行。下面的程序演示了此場景:

function readData() {
  var buf = new Buffer(1024 * 1024 * 100)
  var index = 0
  buf.fill('g')  //simulates real read

  return function() {
    index++
    if (index < buf.length) { 
      return buf[index-1]   
    } else {
      return ''
    } 
  }
}

var data = readData()
var next = data()
while (next !== '') {
  // process data()
  next = data()
}

在這種情況下,只要 data 變量仍在范圍中,就會保留 buf 。 buf 緩沖區的大小會導致保留大量內存,即使這對應用程序開發者而言不那么明顯。我們可以使用 Chrome 開發者工具查看此效果,如在完成 while 循環后獲得的快照所示:保留了更大的緩沖區,盡管不再使用它。

高效使用 JavaScript 閉包

內存保留

甚至在應用程序完成中間函數后,對該函數的引用仍會讓關聯閉包保持活動狀態。要讓該數據變得可以收集,應用程序必須重寫此引用 — 例如按下列方式設置對中間函數的引用:

// Manual cleanup 
data = null;

此代碼允許對閉包上下文進行垃圾收集。下面這個來自堆轉儲的屏幕截圖(在將 data 設置為 null 后獲取)表明可以通過手動廢棄對保留的數據執行垃圾收集:

高效使用 JavaScript 閉包

突出顯示的行表明,緩沖區已被收集,它的關聯內存已被釋放。

通常,可以構造中間函數來限制潛在的內存泄漏。例如,一個允許增量讀取大數據集的中間函數,可以刪除對返回的數據部分的引用。但在這些情況下,一定要注意此方法不得給應用程序中采用非中間函數方式訪問該數據的其他部分帶來問題。

創建實現中間模式的 API 時,請小心地記錄下內存保留特征,以便用戶了解確保所有引用都被廢棄的需求。更好的方法是,盡可能實現您的 API,使保留的數據可在中間函數中不再需要它時被釋放。

例如,本節中的前一個示例中的函數可重寫為:

return function() {
    index++;
    if (index < buf.length) { 
      return buf[index-1]   
    } else {
      buf = null
      return 
    } 
  }

此版本可確保在不再需要大型緩沖區時,可以收集它們。

用例 3:監聽器函數

一種常見模式是注冊函數來監聽特定事件的發生情況。但問題是,監聽器函數的生命周期通常是無限期的,或者不為應用程序所知。因此,監聽器函數最可能導致內存泄漏。

“ 監聽器函數最可能導致內存泄漏。 ”

大多數流處理/緩沖方案都使用該機制來緩存或積累一個外部方法中定義的瞬時數據,而在一個匿名閉包函數中進行訪問。您無法控制安裝的監聽器的生命周期或對其一無所知時,就會出現風險,如下面的示例所示:

var EventEmitter = require('events').EventEmitter
var ev = new EventEmitter()

function run() {
    var buf = new Buffer(1024 * 1024 * 100)
    var index = 0
    buf.fill('g')
    ev.on('readNext', function() {
      var ret = buf[index]
      index++
      return ret
    });
}

內存保留

下面的屏幕截圖(在調用 run() 方法后獲取)展示了如何為大型緩沖區 buf 保留內存。通過支配樹可以看到,這個大型緩沖區由于與該事件的關聯而保持活動:

高效使用 JavaScript 閉包

回調函數(監聽器)保留的數據會在撤銷注冊處理函數之前一直保持活動狀態 — 甚至在讀取了所有數據后仍會保持活動狀態。在某些情況下,對監聽器的各次回調之間可能不再需要數據。如果可能,通常最好根據需要分配數據,而不是在各次調用之間保留它。

在其他情況下,您無法避免在監聽器的各次調用之間保留數據。解決方案是確保 API 提供了一種途徑來在不再需要回調時撤銷注冊它們。這是一個示例:

// Because our closure is anonymous, we can't remove the listener by name, 
// so we clean all listeners.
ev.removeAllListeners()

此用例的一個著名的例子是一種典型的 HTTP 服務器實現:

var http = require('http');

function runServer() {

    /* data local to runServer, but also accessible to
     * the closure context retained for the anonymous 
     * callback function by virtue of the lexical scope
     * in the outer enclosure.
     */
    var buf = new Buffer(1024 * 1024 * 100);
    buf.fill('g');

    http.createServer(function (req, res) {
      res.end(buf);
    }).listen(8080);

}
runServer();

盡管此示例展示了一種使用內部函數的便捷方式,但請注意,只要服務器對象處于活動狀態,回調函數(和緩沖區對象)就都是活動的。只在服務器關閉后,該對象才符合收集條件。在下面的屏幕截圖中可以看到,由于服務器請求監聽器使用了緩沖區,所以該緩沖區將保持活動狀態:

高效使用 JavaScript 閉包

由此得出的教訓是,對于任何保留大量數據的監聽器,都需要理解并記錄監聽器的必要壽命,確保在不再需要監聽器時注銷它。另一種明智的方法是,確保監聽器在各次調用之間保留最少量的數據,因為它們通常具有很長的壽命。

結束語

閉包是一種強大的編程結構,能夠以更加靈活的、出乎意料的方式在代碼和數據之間實現綁定。但是,習慣于 Java 或 C++ 等舊式語言的程序員可能不熟悉它的范圍語義。為了避免內存泄漏,一定要理解閉包的特征和它們的生命周期。

 

via: http://www.ibm.com/developerworks/cn/web/wa-use-javascript-closures-efficiently/index.html?ca=drs-

 本文由用戶 BrockFitts 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!