一起聊聊JavaScript閉包(總結分享)

2022-02-17 19:00:27
本篇文章給大家帶來了關於JavaScript中閉包的相關知識,其中包括從堆疊的角度看待閉包、閉包的共用變數問題等相關問題,希望對大家有幫助。

1.閉包自結

閉包概念:

函數執⾏後返回結果是⼀個內部函數,並被外部變數所引⽤,如果內部函數持有被執⾏函數作⽤域的變數,即形成了閉包。可以在內部函數存取到外部函數作⽤域。

使⽤閉包,⼀可以讀取函數中的變數,⼆可以將函數中的變數儲存在記憶體 中,保護變數不被汙染。⽽正因閉包會把函數中的變數值儲存在記憶體中,會對記憶體有消耗,所以不能濫⽤閉包,否則會影響⽹⻚效能,造成記憶體漏失。當不需要使⽤閉包時,要及時釋放記憶體,可將內層函數物件的變數賦值為null。

閉包特點:一個外函數生成的多個閉包記憶體空間彼此獨立。

閉包應用場景:

  1. 在記憶體中維持變數:如果快取資料、柯里化  
  2. 保護函數內的變數安全:如迭代器、生成器。

缺點:閉包會導致原有的作用域鏈不釋放,造成記憶體的洩漏。

  1. 記憶體消耗有負⾯影響。因內部函數儲存了對外部變數的引⽤,導致⽆法被垃圾回收,增⼤記憶體使⽤量,所以使⽤ 不當會導致記憶體漏失
  2. 對處理速度具有負⾯影響。閉包的層級決定了引⽤的外部變數在查詢時經過的作⽤域鏈⻓度
  3. 可能獲取到意外的值(captured value)

優點:

  1. 可以從內部函數存取外部函數的作⽤域中的變數,且存取到的變數⻓期駐紮在記憶體中,可供之後使⽤
  2. 避免變數汙染全域性
  3. 把變數存到獨⽴的作⽤域,作為私有成員存在

2.閉包概念

一個函數和對其周圍狀態(lexical environment,詞法環境)的參照捆綁在一起(或者說函數被參照包圍),這樣的組合就是閉包(closure)。也就是說,閉包讓你可以在一個內層函數中存取到其外層函數的作用域。在 JavaScript 中,每當建立一個函數,閉包就會在函數建立的同時被建立出來。

詞法作用域

請看下面的程式碼:

function init() {
    var name = "Mozilla"; // name 是一個被 init 建立的區域性變數
    function displayName() { // displayName() 是內部函數,一個閉包
        alert(name); // 使用了父函數中宣告的變數
    }
    displayName();
}
init();

init() 建立了一個區域性變數 name 和一個名為 displayName() 的函數。displayName() 是定義在 init() 裡的內部函數,並且僅在 init() 函數體內可用。請注意,displayName() 沒有自己的區域性變數。然而,因為它可以存取到外部函數的變數,所以 displayName() 可以使用父函數 init() 中宣告的變數 name 。

使用這個 JSFiddle 連結執行該程式碼後發現, displayName() 函數內的 alert() 語句成功顯示出了變數 name 的值(該變數在其父函數中宣告)。這個詞法作用域的例子描述了分析器如何在函數巢狀的情況下解析變數名。詞法(lexical)一詞指的是,詞法作用域根據原始碼中宣告變數的位置來確定該變數在何處可用。巢狀函數可存取宣告於它們外部作用域的變數。


3.從堆疊的角度看待閉包

基本資料型別的變數的值一般存在棧記憶體中,基本的資料型別: Number 、Boolean、Undefined、String、Null。;而物件型別的變數的值儲存在堆記憶體中,棧記憶體儲存對應空間地址。

var a = 1 
//a是一個基本資料型別 
var b = {m: 20 } 
//b是一個物件

對應記憶體儲存:

當我們執行 b={m:30}時,堆記憶體就有新的物件{m:30},棧記憶體的b指向新的空間地址( 指向{m:30} ),而堆記憶體中原來的{m:20}就會被程式引擎垃圾回收掉,節約記憶體空間。我們知道js函數也是物件,它也是在堆與棧記憶體中儲存的,我們來看一下轉化:

var a = 1;
function fn(){
    var b = 2
    function fn1(){
        console.log(b)
    }
    fn1()
}
fn()

棧是一種先進後出的資料結構:

  1. 在執行fn前,此時我們在全域性執行環境(瀏覽器就是window作用域),全域性作用域裡有個變數a;
  2. 進入fn,此時棧記憶體就會push一個fn的執行環境,這個環境裡有變數b和函數物件fn1,這裡可以存取自身執行環境和全域性執行環境所定義的變數
  3. 進入fn1,此時棧記憶體就會push 一個fn1的執行環境,這裡面沒有定義其他變數,但是我們可以存取到fn和全域性執行環境裡面的變數,因為程式在存取變數時,是向底層棧一個個找(這就是Javascript語言特有的"鏈式作用域"結構(chain scope),如果找到全域性執行環境裡都沒有對應變數,則程式丟擲underfined的錯誤。
  4. 隨著fn1()執行完畢,fn1的執行環境被杯銷燬,接著執行完fn(),fn的執行環境也會被銷燬,只剩全域性的執行環境下,現在沒有b變數,和fn1函數物件了,只有a 和 fn(函數宣告作用域是window下)

在函數記憶體取某個變數是根據函數作用域鏈來判斷變數是否存在的,而函數作用域鏈是程式根據函數所在的執行環境棧來初始化的,所以上面的例子,我們在fn1裡面列印變數b,根據fn1的作用域鏈的找到對應fn執行環境下的變數b。所以當程式在呼叫某個函數時,做了一下的工作:準備執行環境,初始函數作用域鏈和arguments引數物件

我們現在看下閉包例子

function outer() {
     var  a = '變數1'
     var  inner = function () {
            console.info(a)
     }
    return inner    // inner 就是一個閉包函數,因為他能夠存取到outer函數的作用域
}
var  inner = outer()   // 獲得inner閉包函數
inner()   //"變數1"

當程式執行完var inner = outer(),其實outer的執行環境並沒有被銷燬,因為他裡面的變數a仍然被被inner的函數作用域鏈所參照,當程式執行完inner(), 這時候,inner和outer的執行環境才會被銷燬調;《JavaScript高階程式設計》書中建議:由於閉包會攜帶包含它的函數的作用域,因為會比其他函數佔用更多內容,過度使用閉包,會導致記憶體佔用過多。

4.閉包的共用變數問題

下面通過outer外函數和inner內函數來講解閉包的共用變數問題。

同一個外函數生成的多個閉包是獨立空間還是共用空間如何判斷?請先看範例

//第一種情況 呼叫時給外函數傳入變數值
function outer(name){
    return function(){
        console.log(name)
    }
}

f1 = outer('yang')
f2 = outer('fang')

console.log(f1.toString())
f1() //yang 
f2() //fang
f1() //yang 

//第二種情況:外函數區域性變數值為變化
function count() {
    var arr = [];
    for (var i=1; i<=3; i++) {
        arr.push(function () {
            return i * i;
        });
    }
    return arr;
}

var results = count();
var f1 = results[0];  //16
var f2 = results[1];  //16
var f3 = results[2];  //16
console.log(f1 )

//第三種情況:外函數的區域性變數值變化。
function test(){
    var i = 0;
    return function(){
       console.log(i++)
    }
}; 
var a = test();
var b = test();
//依次執行a,a,b,控制檯會輸出什麼呢?0 1 0  
//b為什麼不是2
a();a();b();

同一個外函數生成的多個閉包是獨立空間還是共用空間如何判斷?

  1. 第一種情況說明多次呼叫外函數生成的不同閉包函數沒有共用name變數
  2. 第二種情況說明外函數內部迴圈生成的多個內函數共用 i 區域性變數
  3. 第三種情況說明a 、b為兩個 不同閉包 函數,同一閉包函數 a 多次呼叫 共用 i 變數,a b之間不共用。

可以總結出記住三個閉包共用變數的原則

  1. 呼叫外函數,就會生成內函數和外函數的區域性變陣列成的閉包。每呼叫一次生成一個閉包函數。不同閉包函數之間記憶體空間彼此獨立。
  2. 呼叫同一個閉包函數多次,共用記憶體空間,即外函數的區域性變數值。
  3. 第二種情況for迴圈。沒有呼叫外函數,只是將內函數存到了陣列中,故並沒有生成3個獨立的閉包函數。而是3個內函數共用一個外函數區域性變數,即3個內函數和外函數區域性變陣列成了一個整體的閉包環境。

簡記:呼叫一次外函數,生成一個獨立的閉包環境;外函數內部生成多個內函數,那麼多個內函數共用一個閉包環境。


5.閉包應用場景

應用場景主要就兩個

  • 在記憶體中維持變數:如果快取資料、柯里化  
  • 保護函數內的變數安全:如迭代器、生成器。

場景一:儲存區域性變數在記憶體中

閉包很有用,因為它允許將函數與其所操作的某些資料(環境)關聯起來。這顯然類似於物件導向程式設計。在物件導向程式設計中,物件允許我們將某些資料(物件的屬性)與一個或者多個方法相關聯。

因此,通常你使用只有一個方法的物件的地方,都可以使用閉包。

在 Web 中,你想要這樣做的情況特別常見。大部分我們所寫的 JavaScript 程式碼都是基於事件的 — 定義某種行為,然後將其新增到使用者觸發的事件之上(比如點選或者按鍵)。我們的程式碼通常作為回撥:為響應事件而執行的函數。

假如,我們想在頁面上新增一些可以調整字號的按鈕。一種方法是以畫素為單位指定 body 元素的 font-size,然後通過相對的 em 單位設定頁面中其它元素(例如header)的字號:

body {
  font-family: Helvetica, Arial, sans-serif;
  font-size: 12px;
}
h1 {
  font-size: 1.5em;
}
h2 {
  font-size: 1.2em;
}

我們的文字尺寸調整按鈕可以修改 body 元素的 font-size 屬性,由於我們使用相對單位,頁面中的其它元素也會相應地調整。

以下是 JavaScript:

function makeSizer(size) {
  return function() {
    document.body.style.fontSize = size + 'px';
  };
}

var size12 = makeSizer(12);
var size14 = makeSizer(14);
var size16 = makeSizer(16);

size12,size14 和 size16 三個函數將分別把 body 文字調整為 12,14,16 畫素。我們可以將它們分別新增到按鈕的點選事件上。如下所示:

document.getElementById('size-12').onclick = size12;
document.getElementById('size-14').onclick = size14;
document.getElementById('size-16').onclick = size16;
<a href="#" id="size-12">12</a>
<a href="#" id="size-14">14</a>
<a href="#" id="size-16">16</a>

場景二:用閉包模擬私有方法,保護區域性變數

程式語言中,比如 Java,是支援將方法宣告為私有的,即它們只能被同一個類中的其它方法所呼叫。

而 JavaScript 沒有這種原生支援,但我們可以使用閉包來模擬私有方法。私有方法不僅僅有利於限制對程式碼的存取:還提供了管理全域性名稱空間的強大能力,避免非核心的方法弄亂了程式碼的公共介面部分。

下面的範例展現瞭如何使用閉包來定義公共函數,並令其可以存取私有函數和變數。這個方式也稱為 模組模式(module pattern):

var Counter = (function() {
  var privateCounter = 0;
  function changeBy(val) {
    privateCounter += val;
  }
  return {
    increment: function() {
      changeBy(1);
    },
    decrement: function() {
      changeBy(-1);
    },
    value: function() {
      return privateCounter;
    }
  }   
})();

console.log(Counter.value()); /* logs 0 */
Counter.increment();
Counter.increment();
console.log(Counter.value()); /* logs 2 */
Counter.decrement();
console.log(Counter.value()); /* logs 1 */

可以將上面的程式碼拆分成兩部分:(function(){}) () 。第1個() 是一個表示式,而這個表示式本身是一個匿名函數,所以在這個表示式後面加 () 就表示執行這個匿名函數。

在之前的範例中,每個閉包都有它自己的詞法環境;而這次我們只建立了一個詞法環境,為三個函數所共用:Counter.increment,Counter.decrement 和 Counter.value。

該共用環境建立於一個立即執行的匿名函數體內。這個環境中包含兩個私有項:名為 privateCounter 的變數和名為 changeBy 的函數。這兩項都無法在這個匿名函數外部直接存取。必須通過匿名函數 返回的三個公共函數存取。

這三個公共函數是共用同一個環境的閉包。多虧 JavaScript 的詞法作用域,它們都可以存取 privateCounter 變數和 changeBy 函數。

你應該注意到我們定義了一個匿名函數,用於建立一個計數器。我們立即執行了這個匿名函數,並將他的值賦給了變數Counter。我們可以把這個函數儲存在另外一個變數makeCounter中,並用他來建立多個計數器。
var makeCounter = function() {
  var privateCounter = 0;
  function changeBy(val) {
    privateCounter += val;
  }
  return {
    increment: function() {
      changeBy(1);
    },
    decrement: function() {
      changeBy(-1);
    },
    value: function() {
      return privateCounter;
    }
  }  
};

var Counter1 = makeCounter();
var Counter2 = makeCounter();
console.log(Counter1.value()); /* logs 0 */
Counter1.increment();
Counter1.increment();
console.log(Counter1.value()); /* logs 2 */
Counter1.decrement();
console.log(Counter1.value()); /* logs 1 */
console.log(Counter2.value()); /* logs 0 */

請注意兩個計數器 Counter1 和 Counter2 是如何維護它們各自的獨立性的。每個閉包都是參照自己詞法作用域內的變數 privateCounter 。

每次呼叫其中一個計數器時,通過改變這個變數的值,會改變這個閉包的詞法環境。然而在一個閉包內對變數的修改,不會影響到另外一個閉包中的變數。

以這種方式使用閉包,提供了許多與物件導向程式設計相關的好處 —— 特別是資料隱藏和封裝。


6.迴圈中建立閉包的一個常見錯誤

在 ECMAScript 2015 引入 let 關鍵字 之前,在迴圈中有一個常見的閉包建立錯誤。參考下面的範例:

<p id="help">Helpful notes will appear here</p>
<p>E-mail: <input type="text" id="email" name="email"></p>
<p>Name: <input type="text" id="name" name="name"></p>
<p>Age: <input type="text" id="age" name="age"></p>
function showHelp(help) {
  document.getElementById('help').innerHTML = help;
}

function setupHelp() {
  var helpText = [
      {'id': 'email', 'help': 'Your e-mail address'},
      {'id': 'name', 'help': 'Your full name'},
      {'id': 'age', 'help': 'Your age (you must be over 16)'}
    ];

  for (var i = 0; i < helpText.length; i++) {
    var item = helpText[i];
    document.getElementById(item.id).onfocus = function() {
      showHelp(item.help);
    }
  }
}

setupHelp();
//一、將function直接返回,會發生閉包
 //二、將函數賦值給一個變數,此變數函數外部使用,此時也是閉包。比如,陣列、多個變數等。 舉例下面也是閉包情況。
 var arr = []
 for (var i = 0; i < 10; i++) {
    arr[i] = function(){console.log(i)}
  }
  arr[6]()此時也是閉包,將十個匿名函數+i組成了一個閉包返回。

陣列 helpText 中定義了三個有用的提示資訊,每一個都關聯於對應的檔案中的input 的 ID。通過迴圈這三項定義,依次為相應input新增了一個 onfocus 事件處理常式,以便顯示幫助資訊。

執行這段程式碼後,您會發現它沒有達到想要的效果。無論焦點在哪個input上,顯示的都是關於年齡的資訊。

原因是賦值給 onfocus 的是閉包。這些閉包是由他們的函數定義和在 setupHelp 作用域中捕獲的環境所組成的。這三個閉包在迴圈中被建立,但他們共用了同一個詞法作用域,在這個作用域中存在一個變數item。這是因為變數item使用var進行宣告,由於變數提升,所以具有函數作用域。當onfocus的回撥執行時,item.help的值被決定。由於迴圈在事件觸發之前早已執行完畢,變數物件item(被三個閉包所共用)已經指向了helpText的最後一項。

解決這個問題的一種方案是使用更多的閉包:特別是使用前面所述的函數工廠:

function showHelp(help) {
  document.getElementById('help').innerHTML = help;
}

function makeHelpCallback(help) {
  return function() {
    showHelp(help);
  };
}

function setupHelp() {
  var helpText = [
      {'id': 'email', 'help': 'Your e-mail address'},
      {'id': 'name', 'help': 'Your full name'},
      {'id': 'age', 'help': 'Your age (you must be over 16)'}
    ];

  for (var i = 0; i < helpText.length; i++) {
    var item = helpText[i];
    document.getElementById(item.id).onfocus = makeHelpCallback(item.help);
  }
}

setupHelp();

這段程式碼可以如我們所期望的那樣工作。所有的回撥不再共用同一個環境, makeHelpCallback 函數為每一個回撥建立一個新的詞法環境。在這些環境中,help 指向 helpText 陣列中對應的字串。

另一種方法使用了匿名閉包:

function showHelp(help) {
  document.getElementById('help').innerHTML = help;
}

function setupHelp() {
  var helpText = [
      {'id': 'email', 'help': 'Your e-mail address'},
      {'id': 'name', 'help': 'Your full name'},
      {'id': 'age', 'help': 'Your age (you must be over 16)'}
    ];

  for (var i = 0; i < helpText.length; i++) {
    (function() {
       var item = helpText[i];
       document.getElementById(item.id).onfocus = function() {
         showHelp(item.help);
       }
    })(); // 馬上把當前迴圈項的item與事件回撥相關聯起來
  }
}

setupHelp();

如果不想使用過多的閉包,你可以用ES2015引入的let關鍵詞:

function showHelp(help) {
  document.getElementById('help').innerHTML = help;
}

function setupHelp() {
  var helpText = [
      {'id': 'email', 'help': 'Your e-mail address'},
      {'id': 'name', 'help': 'Your full name'},
      {'id': 'age', 'help': 'Your age (you must be over 16)'}
    ];

  for (var i = 0; i < helpText.length; i++) {
    let item = helpText[i];
    document.getElementById(item.id).onfocus = function() {
      showHelp(item.help);
    }
  }
}

setupHelp();

這個例子使用let而不是var,因此每個閉包都繫結了塊作用域的變數,這意味著不再需要額外的閉包。

另一個可選方案是使用 forEach()來遍歷helpText陣列,如下所示:

function showHelp(help) {
  document.getElementById('help').innerHTML = help;
}

function setupHelp() {
  var helpText = [
      {'id': 'email', 'help': 'Your e-mail address'},
      {'id': 'name', 'help': 'Your full name'},
      {'id': 'age', 'help': 'Your age (you must be over 16)'}
    ];

  helpText.forEach(function(text) {
    document.getElementById(text.id).onfocus = function() {
      showHelp(text.help);
    }
  });
}

setupHelp();

7.效能考量

如果不是某些特定任務需要使用閉包,在其它函數中建立函數是不明智的,因為閉包在處理速度和記憶體消耗方面對指令碼效能具有負面影響。

但是如果某個函數需要不停新建,那麼使用閉包儲存到記憶體中對效能有好處。

釋放閉包只需要將參照閉包的函數置為null即可。


8.閉包注意事項

第一:多個內函數參照同一區域性變數

function outer() {
      var result = [];
      for (var i = 0; i<10; i++){
        result.[i] = function () {
            console.info(i)
        }
     }
     return result
}

看樣子result每個閉包函數對列印對應數位,1,2,3,4,...,10, 實際不是,因為每個閉包函數存取變數i是outer執行環境下的變數i,隨著迴圈的結束,i已經變成10了,所以執行每個閉包函數,結果列印10, 10, ..., 10
怎麼解決這個問題呢?

function outer() {
      var result = [];
      for (var i = 0; i<10; i++){
        result.[i] = function (num) {
             return function() {
                   console.info(num);    // 此時存取的num,是上層函數執行環境的num,陣列有10個函數物件,每個物件的執行環境下的number都不一樣
             }
        }(i)
     }
     return result
}

第二: this指向問題

var object = {
     name: ''object",
     getName: function() {
        return function() {
             console.info(this.name)
        }
    }
}
object.getName()()    // underfined
// 因為裡面的閉包函數是在window作用域下執行的,也就是說,this指向windows

第三:記憶體洩露問題

function  showId() {
    var el = document.getElementById("app")
    el.onclick = function(){
      aler(el.id)   // 這樣會導致閉包參照外層的el,當執行完showId後,el無法釋放
    }
}

// 改成下面
function  showId() {
    var el = document.getElementById("app")
    var id  = el.id
    el.onclick = function(){
      aler(id)   // 這樣會導致閉包參照外層的el,當執行完showId後,el無法釋放
    }
    el = null    // 主動釋放el
}

相關推薦:

以上就是一起聊聊JavaScript閉包(總結分享)的詳細內容,更多請關注TW511.COM其它相關文章!