You are currently viewing [筆記]-JavaScript 閉包(Closure)是什麼?關於閉包的3件事

[筆記]-JavaScript 閉包(Closure)是什麼?關於閉包的3件事

閉包是什麼?在實作中我們常常會碰到閉包,或是不經意的使用閉包。至於閉包的特性與背後的機制相信許多人跟ㄚ建一樣沒有特別去了解,今天跟著我一起從下面的幾個問題來探討閉包吧~

  • 閉包是什麼?
  • 為何要用閉包?
  • 如何使用閉包?

JavaScript的作用域與Closure(閉包)的關係密不可分,如果對於作用域(Scope)還不熟悉可以先看到這篇文章複習一下。

閉包是什麼?

先看看MDN的描述:

MDN-Closure

A closure is the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment). In other words, a closure gives you access to an outer function’s scope from an inner function. In JavaScript, closures are created every time a function is created, at function creation time.

閉包(Closure)是資料結構的一種。當一個函式被宣告時,函式對其周圍狀態(詞法環境,lexical environment)的引用在一起(也可以想成函式記住了宣告時的作用域環境),這樣的組合就是閉包。
在JavaScript中,每當你建立一個函式,閉包就會在函式建立時被創建,也就是說JavaScript的函式都是閉包

讓我們用例子來看閉包:

這邊設計了一個手槍的函式,手槍的子彈有6發,每次射擊子彈都會減少。

let bullet = 6;
function wheelGun() {
    bullet--;
    return bullet > 0 ? console.log("剩餘子彈:", bullet) : console.log("沒子彈了!");
}
wheelGun(); // 剩餘子彈: 5
wheelGun(); // 剩餘子彈: 4

如果這把手槍只有一把問題就不大,但如果今天我有很多把手槍時該怎麼辦?
這時為了需要分別記錄每把手槍的剩餘子彈,於是修改了程式:

// wheelGun_1
let bullet_1 = 6;
function wheelGun_1() {
    bullet_1--;
    return bullet_1 > 0 ? console.log("wheelGun_1剩餘子彈:", bullet_1) : console.log("wheelGun_1沒子彈了!");
}
wheelGun_1(); // 剩餘子彈: 5
wheelGun_1(); // 剩餘子彈: 4


// wheelGun_2
let bullet_2 = 10;
function wheelGun_2() {
    bullet_2--;
    return bullet_2 > 0 ? console.log("wheelGun_2剩餘子彈:", bullet_2) : console.log("wheelGun_2沒子彈了!");
}
wheelGun_2(); // 剩餘子彈: 9
wheelGun_2(); // 剩餘子彈: 8
wheelGun_2(); // 剩餘子彈: 7
// 其他手槍...

上面的作法無法有效管理各別手槍的使用情況,而且將子彈放在全域裡也可能讓子彈被覆蓋,導致出錯的情況。
這時就可以透過閉包把手槍函式包起來,結果如下:

function wheelGun() {
    let bullet = 6;
    return function shoot() {
        bullet--;
        return bullet > 0 ? console.log("剩餘子彈:", bullet) : console.log("沒子彈了!");
    }
}
// wheelGun_1
const wheelGun_1 = wheelGun();
wheelGun_1(); // 剩餘子彈: 5
wheelGun_1(); // 剩餘子彈: 4

被包起來的wheelGun函式有獨立的作用域,當中的子彈被限制在當前的函式中,只能在函式裡取用。如此一來子彈(bullet)的變數只能在wheelGun裡使用,不會受到外部的干擾。

到這邊我們對閉包有了初步的概念,如果覺得不夠也可以看這部影片對閉包概念的說法。

閉包是什麼?

接下來進一步看為什麼閉包可以做到這件事。

為何閉包可以這麼做?

這部份可以從以下2點來看

  • 詞法作用域(Lexical Scope)
  • 作用域鏈(Scope Chain)

詞法作用域(Lexical Scope)

這邊引用MDN的描述:

MDN-Closures

The word lexical refers to the fact that lexical scoping uses the location where a variable is declared within the source code to determine where that variable is available. 

詞法作用域簡單來說: 變數的作用域是依據程式碼編寫時的位置決定,並不會在程式執行時動態決定。

由於詞法作用域不是這篇文章的主旨,如果想深入了解可以參考這篇的內容

作用域鏈(Scope Chain)

還記得在作用域一文中提到的作用域鏈嗎?
因為這個特性讓閉包可以獲取外部的變數,而作用域鏈會因為詞法作用域的機制,使作用域鏈是在函式被定義時決定,不是在被呼叫時決定。

讓我們由下面的例子了解這2點的影響:

var a = "outside";

function foo1() {
    console.log(a);
}
function foo2() {
    var a = "inside";
    foo1();
}
foo2(); // outside

函式foo2的執行結果是outside

因為詞法作用域的緣故,foo1foo2是分別獨立的作用域,並且作用域在程式編寫時就決定好了。
所以foo1a會透過作用域鏈向外找到全域的a,並不受到在foo2函式內部執行的影響。

由於詞法作用域的緣故,讓閉包內部的函式不受外部影響,當外部函式被引用時,其內部的變數可以正常在內部函式執行。
而內部函式則可以透過作用域鏈引用外部的變數。

接下來看看閉包在實作中閉包的應用。

閉包的實際應用

避免重複的運算

例如先前未使用閉包的手槍函式,如果要計算各種手槍的剩餘子彈,會讓程式變的十分冗長且可讀性也不好,這時就可以用閉包來優化。
因此使用閉包來解決需要大量計算且重複的執行的程式很有效!

封裝

我們可以透過閉包把函式封裝起來藉此模擬私有方法。這樣一來該函式具有一個獨立的作用域,讓函式中不想外露的變數被限制在閉包內部使用,避免影響全域環境,而且還可以只露出想要公開(public)的部分。

如下面的例子:

function walletCalculator() {
    let total = 1000;
    function calculate(val) {
        total += val;
    }
    return {
        income: (money) => {
            calculate(money);
        },
        expenditure: (money) => {
            calculate(-money);
        },
        balances: () => {
            return total;
        }
    }
};
const counter1 = walletCalculator();
counter1.expenditure(150);
counter1.expenditure(200);
counter1.income(30);

console.log(counter1.balances()) // 680

例子中錢包的總金額totalwalletCalculator的私有變數,不能隨意在外部環境取得及修改。
如果要變更錢包的金額,則可以透過walletCalculator公開的incomeexpenditure方法修改,需要知道錢包還是多少錢時可用balances取得錢包餘額。

另外,我們也可以讓閉包傳入外面的參數,稍微修改whellGun的例子,從函式外部傳入user的變數,這樣可以方便識別使用者手搶的剩餘子彈。

function wheelGun(user) {
    let bullet = 6;
    return function shoot() {
        bullet--;
        return bullet > 0 ? console.log(user, "剩餘子彈:", bullet) : console.log("沒子彈了!");
    }
}
const user1_shoot = wheelGun("小明");
const user2_shoot = wheelGun("ㄚ建");
user1_shoot(); // 小明 剩餘子彈: 5
user1_shoot(); // 小明 剩餘子彈: 4
user1_shoot(); // 小明 剩餘子彈: 3
user2_shoot(); // ㄚ建 剩餘子彈: 5

記憶體回收(Garbage Collection)

當我們在程式中宣告變數時,這個變數會被分配到記憶體中儲存它的數值。當這個變數不再被程式使用時,將變數從占用的記憶體中釋放的機制便是記憶體回收。

在使用閉包時,我們會在閉包中建立各種運用,這些運用會占用記憶體空間。
當閉包的規模越大,其中包含的變數也愈多,其中也可能包含一些未使用的變數,這樣造成閉包使用大量記憶體,便可能引起效能問題。大多數使用情況,我們不需要過於擔心這方面的問題,因為JavaScript引擎有很好的記憶體回收機制來優化這部分的問題。

總結

總結一下這篇文章中我們獲得了什麼?

閉包是什麼?

  • 閉包(Closure)是資料結構的一種。當一個函式被宣告時,函式對其周圍狀態(詞法環境,lexical environment)的引用在一起(也可以想成函式記住了宣告時的作用域環境),這樣的組合就是閉包。
  • 閉包可以讓函式內部的變數不受外部環境的影響。
  • 而閉包內部可以透過作用域鏈獲取外部資料。

閉包的特性

  • 當一個函式內部回傳了另一個函式,便是有用到閉包的概念。
  • 閉包可以讓函式內部的變數不受外部環境的影響。
  • 而閉包內部可以透過作用域鏈獲取外部資料。

要如何用閉包?

  • 可以把程式中需要重複執行的部分透過閉包封裝起來,進一步簡化程式。
  • 可以將變數私有化,減少全域環境的命名衝突
  • 閉包也可以將內部函式開放給外部使用。
  • 閉包的結構屬於高消費的語法,使用不當會影響程式效能,大多數情況,不需要擔心這方面的問題,因為JavaScript引擎有很好的記憶體回收機制。

參考資料

JavaScript – The Complete Guide
MDN-Closures
深入淺出瞭解 JavaScript 閉包(closure)
从static/dynamic scope来谈JS的作用域
所有的函式都是閉包:談 JS 中的作用域與 Closure

如果對文章內容有任何問題或是錯誤,歡迎在底下留言讓我知道: )

發佈留言