JavaScript Closure

JavaScript 閉包 Closure 與 Scope 作用域

Jim 2023/09/04 12:49:52
1291

前言

在解釋閉包(Closure)之前,要先知道作用域範圍鍊是甚麼,才能更好的解釋閉包。在 ES6 以前,作用域只有 global 全域以及 function 裡的作用域,定義變數則都是使用 var 來宣告。在 ES6 時出現了 constlet,同時也增加了新的作用域 blockvar 漸漸的被取代不再被使用,下面先來了解一下作用域是甚麼。

 

Scope(作用域)

對於作用域我自己的解釋是:

把作用域當成一個只進不出國家,外面的人可以順利地進來,但是只要是在國家內出生的人民,一輩子都無法出去...

轉換成實際上的解釋就是,在作用域範圍內生成的變數,無法被外面的區域做使用,但外面的區域變數,是可以被裡面的作用域做使用的。

而作用域的範圍可以分為三種:

1. 全域 (global scope)

2. 函式作用域 (function scope)

3. 區塊作用域 (block scope)

全域(global scope)

顧名思義,就是一個最外層的區域,沒有比全域再更外層的了,而在全域宣告的變數或是函式,就稱全域變數,而全域變數可以在任何地方被使用。

範例:

const global = 'global variable'
function str(){
    console.log(global); 
}
str(); //output: 'global variable'

特別注意的是如果沒有宣告變數而直接賦值,不管在哪個作用域,則該變數都會變成全域變數,這樣就很容易出問題,所以盡量一定要宣告變數。

範例:

//Ex1: function
function test() {
    //函式內未宣告直接賦值
    a = 3; 
}
test(); 
console.log(a); //output: 3 

//Ex2: block
for (let i = 0; i < 5; i++) {
    //區塊內未宣告直接賦值
    b = 5; 
}
console.log(b); //output: 5

函式作用域(function scope)

一樣顧名思義,可以馬上理解就是在函式(Function) 內的區域,也就是執行區域 {} 內就是函式的作用域,對應函式作用域的宣告就是 var

這時候就有一個問題了,剛剛提到如果沒有宣告變數,則該變數會變全域變數,那在 Function 的參數區域 () 裡面的變數也不用宣告,那裡面的參數會變全域變數嗎?

這邊就要提一下函式的生成流程,不過因為有點複雜,所以只先大概提一下以供解釋上面的問題。

可以先想像在函式生成時,會產生該函式的執行環境(Execution Context),以下簡稱 EC,EC 裡面儲存了跟函式有關的資訊,順帶一提除了函式,全域執行時也有一個全域執行環境(global EC),每個 EC 生成時都會有相對應的變數物件(Variable Object),以下簡稱 VO ,宣告的變數及函式都會被儲存在 VO 裡面。

而函式的 VO 另外稱作執行物件(Activation Object),以下簡稱 AO ,AO 跟 VO 一樣,差別在於 AO 除了儲存 {} 內的變數還儲存了 () 內的參數,因為 AO 是對應函式的 EC 而存在,所以不用宣告參數也會在函式的作用域裡面。

範例:

function age(number) {
    var str = 'My age is:';
    console.log(str,number);
}
age(5);               //output: My age is: 5
console.log(number);  //ReferenceError: number is not defined
console.log(str);    //ReferenceError: str is not defined

區塊作用域(block scope)

在 ES6 新增的作用域,那區塊(block)是指甚麼呢?區塊指的是 {} 大括號範圍內的區域都稱之區塊,像是 if 及 switch 判斷式、while 及 for 迴圈,包括 function 的大括號範圍都是區塊。不過不是指 var 宣告變數變成區塊變數,他還是只能在 function 裡才有作用域的效果(可憐的 var)。

而對應的區塊變數宣告就是新增的 constlet ,在大括號內宣告(當然包含 function)都會有作用域的效果。

那在迴圈的小括號 () 裡宣告的變數呢?

範例:

//Ex1:
for (var j = 0; j < 5; j++) {
    console.log(j); //output: 0 1 2 3 4
}
console.log(j);     // output: 5

//Ex2:
for (let i = 0; i < 5; i++) {
    console.log(i); //output: 0 1 2 3 4
}
console.log(i);     // ReferenceError: i is not defined

可以看到有兩個 for 迴圈,分別在小括號裡面用 varlet 分別做宣告,可以發現在小括號裡宣告 var 可以在 global 抓到變數,而用 let 做宣告,無法在 global 抓到變數。所以可以證實在迴圈的小括號 () 內宣告變數,是一個區塊變數

所以說不管是在迴圈的 () 裡面還是在 {} 裡面宣告變數(使用區塊宣告),都會有作用域的效果,都只能在 {} 內才能被使用,到了外層則無法抓到該變數。

至於常看到的解釋,只有在 {} 宣告區塊變數,就是區塊作用域,我想會這麼說是因為比較好解釋?畢竟變數只能在 {} 裡調用, () 也只是宣告變數而已。所以說在 {} 內就是區塊作用域其實沒錯,只是要記得在迴圈的 () 內宣告區塊變數也會在 {} 有區塊作用域的效果喔!

以上這只是我的猜測,如果有人知道為甚麼會這樣解釋可以在底下留言!

 

var、const、let 快速比較

了解了作用域後 varconstlet 就很容易理解拉!

  • var 就是在函式內的宣告,會產生 function scope
  • letconst 則是在區塊內宣告,會產生 block scope

var 大家都很熟了,那 letconst 同樣都是 block scope,有甚麼差別呢?

let:

  1. 可以宣告變數不賦值,這點跟 var 一樣。

  2. 可以更改變數的內容。

  3. 相同變數在同一層無法重新宣告。

範例:

let a;
console.log(a); // output: undefined

a = 2
console.log(a); // output: 2

let a = 3;      // SyntaxError: Identifier 'a' has already been declared

const:

  1. 不可以宣告變數不賦值。

  2. 不可以更改變數的值。

  3. 相同變數在同一層無法重新宣告。

範例:

const a;       //SyntaxError: Missing initializer in const declaration

const b = 2;
console.log(b); //output: 2
b = 3;
console.log(b); //TypeError: Assignment to constant variable.

const c = 2;
const c = 3;   //SyntaxError: Identifier 'c' has already been declared

來做一個表格整理:

特性 var let const
作用域 function block block
宣告變數是否需要賦值
宣告後是否可以更改內容
宣告後是否可以重新宣告
是否有 hoisting 效果

*Hoisting 的部分可以參考在 JavaScript 中 Hoisting 可以幹嘛?

Scope chain(範圍鍊)

在進入到 Closure(閉包)之前,還需要了解的一個概念,就是範圍鍊(Scope Chain),先來看一道經典題目:

var number = 1;

function b() {
  console.log(number);
}

function a() {
  var number = 100;
  b();
}
a();

宣告一個全域變數 number = 1 ,建立 funA 裡面重新宣告 number = 100 ,並包了一個 funB ,則 funB 裡面 console.log(number) 會跑出甚麼答案呢?


答案是:1
有沒有答對呢~如果沒答對是正常的,我一開始也沒答對 XD,大部分的人一開始一定都會覺得,欸~~ funB 不是在 funA 裡面呼叫,那 funB 抓外面的變數 number 不就是 100 嗎?

而這個就要牽扯到前面提到的執行環境 EC 了,當函式建立執行環境時,同時也建立了外部環境參考 (Reference to Outer Environment),也就是誰才是 function 外的環境,而 JavaScript 的外部環境參考,是依照靜態作用域(Static Scope)又稱詞彙作用域(lexical Scope) 為準則。

甚麼是靜態作用域呢?簡單講就是物理上的程式碼範圍,雖然 funB 是在 funA 裡面被呼叫,但是 funB 在建立執行環境時(也就是 funB 被建立的位置),外層的環境就是 global,所以 number 才會是抓到 global 的 number = 1

而既然有靜態作用域,當然也有動態作用域(Dynamic Scope)啦,在某些語言的情況下剛剛那個問題,答案就會是 100 沒錯喔,是不是很酷!

動態的意思就是,外部參考環境是根據被呼叫的當下,所在的環境就是外部執行環境,恰好跟靜態作用域相反。

那看回原本的那個範例,funB 要 console.log(number) ,可是 funB 裡面沒有 number 那怎麼辦呢?這時候他就會往外找看有沒有人定義 number 是甚麼?直到找到最外層的 global

而這個往外找的機制就稱作 Scope Chain

 

Closure(閉包)

終於可以進入到閉包了,有了上面的觀念,就可以比較好解釋閉包了。
先來看一個範例:

function count() {
    var number = 5;
    function addTen() {
        console.log(number + 10);
    }
    addTen();
}
count(); //output: 15

這是一個普通的例子,呼叫一個 count 的 function ,裡面定義一個變數 number 的值為 5 ,然後建立一個 addTen 的 function,印出 number + 10 ,然後執行 addTen

執行後就會印出 15 。

這時候問題來了,那如果是在 count function 裡面回傳 addTen 的 function 呢?

function count() {
    var number = 5;
    function addTen() {
        console.log(number + 10);
    }
    return addTen;
}
var answer = count();
answer(); //output: 15

跟剛剛不同的是,這次不執行 addTen ,而是回傳它,並且把它傳入一個新的變數 answer ,然後執行 answer,神奇的事情發生了,按照之前講的外部參考環境的規則,應該會跑出 ReferenceError: number is not defined 才對。

var answer = count(); 
// count() 回傳 addTen,而 addTen = function(){console.log(number)}

//等同於:
var answer = function(){
    console.log(number)
}
answer();

怎麼還是跑出 15 呢?就好像變數被存在 function 裡面一樣,沒錯!這就是閉包常見的解釋,明明已經執行完 count 了,但裡面的變數卻能被 answer 存取到!而造成這樣的原因有兩個

第一個原因就要提到剛剛講的 範圍鍊(Scope Chain) 了,已經知道 Scope Chain 就是當抓不到變數時,就會往外層找,直到 global 層,而前面也提到 funciton 的執行環境 EC 會產生對應的執行物件 AO。但其實 EC 還會產生對應的 Scope Chain,裡面裝了 AO 以及 function 的 [[Scope]] 屬性,可以簡化成以下的式子:

scope chain = activation object + [[Scope]]

AO 我們已經知道是 function 裡的參數以及變數,那 [[Scope]] 應該可以大致猜到是甚麼了吧?沒錯!!就是往外找的所有 AO + VO 的物件。

所以看回剛剛的例子:

function count() {
    var number = 5;
    function addTen() {
        console.log(number + 10);
    }
    return addTen;
}

當建立了 addTen 的 function 時,也建立了一個 [[Scope]] 的屬性,裡面裝了外層的所有 AO + VO,就把 { number: 5 } 裝進了 [[Scope]] 裡面,所以只要 addTen 這個 function 存在,永遠可以透過它去抓到 { number: 5 }

那除此之外,第二個原因當然就是因為在 function 裡面回傳一個 function,如果只是單純的在 count 裡面放一個 addTen,怎麼在 count 的外面抓到 addTen[[Scope]] 呢?

所以說閉包(Closure) 並不是一個像作用域阿,範圍鍊等有明確定義的東西,它比較像一個現象,因為 Scope Chain 以及回傳 function 所產生的效果。

那閉包有甚麼好處呢?我們可以把一些不想被更動的變數藏在 function 裡面,這樣就無法因為外面的程式碼而改變數值,但是需要的時候,還是可以得到藏在 function 裡的變數。

 

結論

在實際寫程式時,並不會特別的想說要怎麼把 Closure 應用在程式碼,它比較像一個 JavaSctipt 自動產生的一個現象,我們不能透過 Closure 去更改函式裡的變數,但是是可以得到函式裡的變數值,除非特殊情況才會特別使用它。

所以下次在看到 Closure 時,就要馬上想到是因為 function[[Scope]] 的屬性且回傳 function,所造成的現象!

 

參考資料

所有的函式都是閉包:談 JS 中的作用域與 Closure
[JS] Scope 作用域
JavaScript 深入淺出 Variable Object & Activation Object

Jim