好程式設計師HTML5培訓技術分享JavaScript 閉包

好程式設計師IT發表於2019-04-03

  1. 概述

 

  閉包 (closures) ,在 MDN 解釋為:

 

  Closures are functions that refer to independent (free) variables (variables that are used locally, but defined in an enclosing scope). In other words, these functions 'remember' the environment in which they were created.

 

  閉包是指那些能夠訪問獨立 ( 自由 ) 變數的函式 ( 變數在本地使用,但定義在一個封閉的作用域中 ) 。換句話說,這些函式可以“記憶”它被建立時候的環境。

 

  閉包是 JavaScript 語言的一個特色,當然也是它的一大難點,很多高階應用都要依靠閉包實現,或者我們平常編碼過程中,也在有意無意間使用到閉包。

 

  2. 作用域鏈

 

  在理解閉包,首先就要理解 JavaScript 中的作用域鏈。

 

  在 JavaScript 中有兩種作用域:全域性作用域和函式作用域 ( ES6 中引入了塊級作用域 )

 

  在函式中定義的變數只能在本函式體中使用到,在函式外部不能直接呼叫函式體內部定義的變數,但函式中可以呼叫到全域性作用域中定義的變數。

 

  如果函式中有內嵌函式的定義,則在內嵌函式中可以訪問到外部函式中定義的變數,也可訪問到全域性作用域中的變數,但在外部函式中不能訪問內嵌函式中定義的變數。這樣,就形成了作用域鏈,即內嵌函式可呼叫父級或祖先級函式中定義的變數,但父級函式不能呼叫子級或後代函式中定義的變數。

 

  function outer(){

 

  var outVar = 10;

 

  function inner(){

 

  var inVar = 20;

 

  console.log("inner 中呼叫外部函式變數 outVar = " + outVar);

 

  }

 

  inner();

 

  console.log("outer 中呼叫內嵌函式變數 inVar = " + inVar);

 

  }

 

  outer();

 

  執行結果:

 

  inner 中呼叫外部函式變數 outVar = 10

 

  ReferenceError: Can't find variable: inVar

 

  在 JavaScript 中,變數的作用域是由它在原始碼中所處位置決定的,並且巢狀的函式可以訪問到其外層作用域中宣告的變數。

 

  3. 閉包

 

  如果有這樣一種需求,我們需要在外部使用到函式內的變數,但正常情況下,透過直接呼叫的方式是不能訪問到的,這就需要變通的方法了。

 

  function outer() {

 

  var i = 1;

 

  var inner = function(){

 

  return ++i;

 

  }

 

  return inner;

 

  }

 

  var result = outer();

 

  console.log(" 第一次呼叫: " + result());

 

  console.log(" 第二次呼叫: " + result());

 

  console.log(" 第三次呼叫: " + result());

 

  執行結果:

 

  第一次呼叫: 2

 

  第二次呼叫: 3

 

  第三次呼叫: 4

 

  上例中,我們要使用到 outer 函式內部的變數 i ,每次列印是在原有數值基礎上自增 1 。因在函式外部不能直接透過變數名對其進行訪問,而巢狀在內部的 inner 函式則能夠訪問到外部函式變數 i ,所以返回了內部函式的引用 inner ,這樣,當 outer 函式呼叫結束後,放置在 result 中的實際為內嵌函式的引用,這樣就可以繼續使用到在 outer 函式內部定義的變數 i 了。這就是閉包。

 

  以前常用到的定時器,相信大家寫過類似的程式碼片段:

 

  function fn(){

 

  var i = 0;

 

  var timer = setInterval(function(){

 

  console.log(i++);

 

  if(i > 10)

 

  clearInterval(timer);

 

  }, 50);

 

  }

 

  fn();

 

  fn 函式呼叫結束後,按理說在 fn 函式內部的區域性變數 i timer 作用域該結束了,但 setInterval() 函式的非同步執行過程中,仍然可以使用到這兩個變數的值。這也是典型的閉包使用情況。

 

  4. 一個故事

 

  來說明閉包可以有哪些適用場景前,我喜歡下面這個例子。

 

  很久很久以前:

 

  有一位公主 ......

 

  function princess() {

 

  她住在一個充滿冒險的奇妙世界裡,遇到了她的白馬王子。白馬王子帶著她騎著獨角獸開始周遊世界,與巨龍戰鬥,巧遇會說話的動物,還有很多其他的不可思議的新奇事物。

 

  var adventures = [];

 

  function princeCharming() { /* ... */ }

 

  var unicorn = { /* ... */ },

 

  dragons = [ /* ... */ ],

 

  squirrel = "Hello!";

 

  但她不得不回到自己乏味的王國裡,例行去見那些成年人。

 

  return {

 

  她會經常給大人分享她最近作為公主時的充滿奇幻的冒險經歷。

 

  sayStory: function() {

 

  return adventures[adventures.length - 1];

 

  }

 

  };

 

  }

 

  但在大人的眼裡,公主僅僅只是一個小女孩兒 ......

 

  var littleGirl = princess();

 

  ...... 在講著一些神奇的、充滿幻想的故事。

 

  littleGirl.sayStory();

 

  即便所有大人都知道他們眼前的小女孩是真的公主,但是他們絕不相信有巨龍或獨角獸,因為他們自己從來沒有見到過。大人們說它們只存在於小女孩的想象之中。

 

  但是我們卻知道小女孩述說的是事實 ......

 

  5. 閉包適用場景

 

  通常閉包有如下兩種適用場景:

 

  · 在記憶體中維持變數,如快取資料

 

  · 保護函式體內變數的安全,如為物件設定私有屬性

 

  5.1 快取資料

 

  一個比較常用到的例子就是,利用迴圈為元素繫結事件。

 

  讓每個 div 元素被點選時,都能正確彈出當前被點選的 div 的索引:

 

  div-1

 

  div-2

 

  div-3

 

  div-4

 

  div-5

 

  如果使用如下寫法:

 

  這時,在每個 div 上點選時彈出的結果都是你點選的 div 索引為: 5 。這是因為事件處理是非同步的,但事件繫結是同步的,會先執行完迴圈體的 5 次操作,為每個 div 繫結上 onclick 事件。

 

  這個過程中,變數 i 的值一直在遞增變化,當所有 div 元素都被遍歷後, i 的值自增到 5 退出迴圈結構。函式 handle 呼叫結束後,由於在事件響應程式中仍然存在變數 i 的引用,如果釋放變數 i 的資源,會導致事件響應程式執行錯誤,所以為了保證事件響應程式中仍然能正確使用到變數 i ,會將變數 i 的值一直保留在記憶體中,但保留的 i 的值為 5

 

  如果要正確輸出索引值,可使用閉包修改如下:

 

  在為每個 div 繫結事件時,呼叫 clk() 函式將與 div 關聯的變數值 i 傳遞到 clk() 函式內部使用,因為內部返回了一個內嵌函式的引用,該內嵌函式功能的實現依賴於外部函式中的區域性變數 index ,所以 index 變數的值會在記憶體中得以快取。

 

  由於每個 div 繫結事件時,都呼叫了 clk() 函式來實現事件繫結操作,所以與之對應的變數索引 i 的數值也都在記憶體中得以快取,只是這個值不是以 i 的名稱來快取。當我們再次測試時,就可以正確列印出所點選 div 的索引了。

 

  當然以上功能的實現也可以透過自定義屬性方式實現:

 

  或是透過 let 命令來實現:

 

  5.2 為物件設定私有屬性

 

  如果有一個物件,擁有年齡這樣一個屬性,我們要限定年齡的取值範圍在 18~25 歲之間,以類似 Java 物件導向的方式來實現,可模擬如下:

 

  age 表示學生的年齡,這樣的一個變數如果對於任何人都可以修改值,那麼如果給定一個負值,比如 -35 ,雖然就語法上來說沒問題,但就實際邏輯來說,一個人不可能年齡為 -35 歲,所以為了保障這種資料的安全,可以使用閉包來解決。

 

  對 Student 函式內部的區域性變數 age 來說,本應該在 Student() 函式透過 new 呼叫結束後就釋放掉資源,但在物件的 getAge/setAge 方法中仍然有對其的引用,釋放資源會導致 getAge/setAge 功能不能正常完成,所以其值會儲存在記憶體中。但要修改 age 年齡值時,由於它的作用域問題,我們沒法在 Student 函式外直接透過呼叫 age 的方式來修改,僅能使用提供的 setAge 方法介面修改 age 值,這就保證了對 age 修改賦值的安全性。

 

  6. 一點誤解

 

  以前在查閱資料時,經常見到說不要輕易使用閉包,否則容易造成記憶體洩漏的說法。

 

  直到看到這篇文章:《 js 閉包測試》

 

  閉包裡面的變數是我們需要使用到的變數 (lives) ,而記憶體洩漏通常是指訪問不到的變數依然佔據記憶體空間,不能夠對其佔據的空間再次利用。顯然閉包是不屬於訪問不到的記憶體空間。

 

  之所以有這樣的說法,大概是因為 IE ,特別是 IE6 bug 吧。當然這是 IE 瀏覽器的問題,不是閉包的問題。

 

  現代瀏覽器在 JavaScript 引擎中大都最佳化處理了閉包情形下的垃圾回收,所以關於記憶體洩漏的說法,我們大可不必再理會了。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69913892/viewspace-2640231/,如需轉載,請註明出處,否則將追究法律責任。

相關文章