Try   HackMD
tags: JavaScript Scope Closure

[week 16] JavaScript 進階 - 什麼是閉包?探討 Closure & Scope Chain

本篇為 [JS201] 進階 JavaScript:那些你一直搞不懂的地方 這門課程的學習筆記。如有錯誤歡迎指正!

在上一篇筆記 [week 16] JavaScript 進階 - 初探 Hoisting & Execution Context 中,我們談到 Hoisting(提升)、Execution Context(執行環境)等相關概念。

瞭解到每一個 function 會有一個對應的執行環境,裡面負責存放該環境需要用到的各種資料,由這些參數組成 Variable Object(變數物件)。

包括之前談過的 VO,每個執行環境會有下列三個屬性:

  • 作用域鏈(Scope Chain)
  • 變數物件(Variable Object)
  • ‘this’ 變數(‘this’ Variable)

而當中的作用域鏈(Scope Chain)其實就和本篇所要探討的 Closure(閉包)有關,因此下面會先從 Scope(作用域)開始講起。

學習目標:

 P1 你知道什麼是作用域(Scope)
 P1 你知道 Closure(閉包)是什麼
 P1 你能夠舉出一個運用 Closure 的例子

Scope 作用域

什麼是 Scope(作用域)?簡言之,就是「一個變數的生存範圍」,一旦出了這個範圍,就會無法存取到這個變數。

舉個簡單的例子,如果在 test() 中以 var 宣告變數 a,在 function 作用域之外會無法存取該變數:

function test(){ var a = 10 } console.log(a) // Uncaught ReferenceError: a is not defined

在 ES6 以前,唯一產生作用域的方法就是宣告 function,每一個 function 都有自己的作用域,在作用域外就存取不到這個 function 內部所定義的變數。

在 ES6 出現以後,作用域的概念有些改變,也就是引入 let 跟 const 的宣告,可用大括號 {...} 來定義 block(區塊)作用域。

因此 JavaScript 的作用域其實可分為三個層級:

  • Global Level Scope:全域作用域
  • Function Level Scope:函式作用域
  • Block Level Scope(ES6):區塊作用域

也就是說,變數作用域的範圍,其實就取決於這個變數的宣告方式,以及在哪進行宣告。

而根據變數是在哪宣告,又可分為全域變數和區域變數:

  • 全域變數(Global Variable)
    • 在 function 外宣告的變數
    • 任何地方皆能存取到
  • 區域變數(Local Variable)
    • 在 function 內宣告的變數
    • 只在該作用域內有效,也就是 function 本身及其內部

Function Scope 可能發生的問題

接著要來談談 function 作用域中可能遇到的狀況,這可能會導致結果和想像的不同。

狀況一:變數的值被覆蓋

若 var 變數不是宣告在 function 作用域內,而是在迴圈或是判斷式,這個變數可能就會覆蓋到外面的全域函數,造成變數汙染。

在下面的程式碼中,if 判斷式裡面的變數 str,會覆蓋外面的變數 str,因此結果是印出 Local:

var str = 'Global'; if (true) { var str = 'Local'; } console.log(str); // Local

狀況二:迴圈變數可能會向外覆蓋全域變數

當 for 迴圈中的變數 i 循環結束時,會蓋過外面的全域變數 i,因此 function 外面的 i 會被重新賦值為 3:

var str = 'cat'; var i = 1; for (var i = 0; i < str.length; i++) { console.log(str[i]); // 向外覆蓋全域變數 } console.log(i); // c // a // t // 3

E6 以後的作用域:Block Scope

接著再回到 ES6,新增了區塊作用域(block scope)的概念,也就是以 let 和 const 來宣告變數。

在第三週的 ES6 部份我們也曾提到,以 let、const 或 var 方式來宣告變數,最大的差別在於變數的作用域範圍不同:

  • var:作用於整個函數範圍中(function scope)
  • let 與 const:均為區塊作用域(block scope),如此可避免污染到大括號 {...} 外的變數

而 let 和 const 最大的區別,在於該變數是否能被重新賦值:

  • const(constant):常數宣告後就不能再重新賦值,並且在宣告時就必須賦值
  • let:可重新賦值,也可先進行宣告但不賦值

以下面程式碼為例,說明以 var 和 let 宣告變數會有什麼差別:

用 var 在 for 迴圈宣告變數 i

先以 var 來宣告變數 i,for 迴圈結束後,外面的 log 結果是 3:

function test() { for(var i = 0; i < 3; i++) { console.log('i:', i); } console.log('final value', i); } test() // i: 0 // i: 1 // i: 2 // final value 3

用 let 在 for 迴圈宣告變數 i

若改用 let 在 for 迴圈宣告變數,則會出現錯誤 i is not defined

function test() { for(let i = 0; i < 3; i++) { console.log('i:', i); // } console.log('final value', i); } test(); // ReferenceError: i is not defined

這是因為以 let 進行宣告,變數 i 的作用域就僅限於 for 迴圈這個 block 區塊,所以大括號外面就無法存取到變數 i。

作用域會往外層找

記住這個重點:「作用與會往外層找」。也就是說,在 function 外面會存取不到裡面,但內層可以存取到外層的東西。

舉下面幾個程式碼作為範例。

範例一:從 function 外往內存取變數

結果會出現錯誤 a is not defined

function test() { var a = 10; console.log(a); } test(); console.log(a); // ReferenceError: a is not defined`

這是因為在 function 外面沒辦法存取內部的變數 a,所以會出現錯誤。

範例二:存取 function 以及 global 變數

若分別宣告全域變數和區域變數,log 結果不會互相干擾:

var a = 20 // global variable function test() { var a = 10; // function variable console.log(a); // 10 } test(); console.log(a); // 20

範例三:直接在 function 內部賦值

結果全域和區域的兩個 a,其 log 結果會相同:

var a = 20; // global variable function test() { a = 10; console.log(a); // 10,function -> global } test(); console.log(a); // 10

原因在於,即使 function 內沒有宣告變數,仍會「往外」找到已經被宣告的全域變數 var a,然後再回到內部賦值 a = 10

變數會先在自己的作用域找,若找不到會繼續再往外找,一層一層直到找到為止。而這一連串的行為,就稱作 Scope Chain(作用域鏈),詳細內容稍後會再進行說明。

範例四:function 內外都沒有宣告變數

結果仍會和上述範例相同!

function test() { a = 10; console.log(a); // 10,test -> global } test(); console.log(a); // 10

這是因為,在 function 中如果 a 找不到值,就會往外層找,如果全域也找不到,就會自動宣告全域變數 var a

這會和前一個例子寫法產生相同結果,但這種情況其實會產生一些 bug,也就是和預期行為不同,甚至可能產生衝突。

Scope Chain 作用域鏈

在說明之前,先來看以下範例:

function test() { var a = 100 function inner() { console.log(a) // 100 } inner() } test()

在 inner() 中,a 並非該函式中的變數,而這種不在該 function 作用域中,也不是作為參數傳進來的變數,就被稱為 Free Variable(自由變數)。

對 inner() 來說,a 是一個自由變數。因為在 inner() 的作用域中找不到 a,就會往外層 test() 找,如果還是找不到會再往外直到找到為止。

這其實就構成一個 Scope Chain(作用域鏈):inner function scope -> test function scope -> global scope,如果直到全域作用域還是找不到,就會拋出錯誤。

還記得我們在開頭提到,每個執行環境物件會有下列三個屬性:

  • 作用域鏈(Scope Chain)
  • 變數物件(Variable Object)
  • ‘this’ 變數(‘this’ Variable)

而 Scope Chain 這個屬性,其實就是負責記錄「包含自己的 VO + 所有上層執行環境的 VO」的集合。藉由該屬性,函式內部就可以存取到外部的變數。

聽起來有點抽像,我們舉個簡單的例子:

function one() { var a = 1; two(); function two() { var b = 2; three(); function three() { var c = 3; console.log(a + b + c); // 6 } } } one();

從 Global Context 呼叫 one(),one() 再呼叫 two(),接著再呼叫 three(),最後在 function three 執行 console.log()。下圖在建立階段的堆疊示意圖:

當 JavaScript 要執行 console.log(a + b + c) 這行程式,會不斷往 Scope Chain 去尋找。

就像前面所說的,一開始會先在自己的 VO 找,找不到在換下一個,一直到 global 為止,如果找不到就會拋出錯誤。過程如下圖:

(圖片來源:https://andyyou.github.io/2015/04/20/understand-closures-and-scope-chain/

ECMAScript 中的作用域

每個執行環境都有一個 Scope Chain。也就是說,一旦進入該執行環境,就會建立 Scope Chain 並進行初始化。

以 global 執行環境來說,初始化後會把 VO 放進 Scope Chain 內,可表示為:

scopeChain = [globalEC.VO]

此外,每個函式都有一個 [[Scope]] 屬性,當 global 執行環境遇到函式時,會將它初始化為 global 執行環境的 Scope Chain:

function.[[Scope]] = globalEC.scopeChain

當函式被呼叫時,會建立 local 執行環境,也會建立 VO,在函式中會稱作 Activation Object(AO),並且除了 AO 之外,外面傳進來的參數也會被加到該 local 執行環境的 Scope Chain:

function.scopeChain = [function.AO, function.[[Scope]]]

模擬 JS 實際流程

舉個簡單的範例:

var a = 1; function test() { var b = 2; function inner() { var c = 3; console.log(c); console.log(b); console.log(a); } inner(); } test();

第一步:進入 Global 執行環境

首先進入 Global EC,並初始化 VO 以及 scope chain。前面提到 scope chain = activation object + [[Scope]],但因為這不是一個 function,所以沒有[[Scope]] 和 AO,會直接以 VO 來用:

global EC { VO: { a: undefined, test: function }, scopeChain: [globalEC.VO] } test.[[Scope]] = globalEC.scopeChain

此外也需設置 function 的 [[Scope]],所以 test() 的[[Scope]] 就會是 globalEC.scopeChain,也就是 globalEC.VO。

第二步:建立 test 執行環境

執行完 var a = 1 後,將 global EC 的 VO 初始為 1。

接著準備進入 test(),在進入之前會先建立 test EC 並初始化 AO 以及 scope chain :

testEC: { AO: { b: undefined, inner: function } scopeChain: [testEC.AO, test[[Scope]]] => [testEC.AO, globalEC.VO] } inner.[[Scope]] = testEC.scopeChain = [testEC.AO, test[[Scope]]] ====== global EC { VO: { a: 1, test: function }, scopeChain: [globalEC.VO] } test.[[Scope]] = globalEC.scopeChain = [globalEC.VO]

同裡,需設置 function inner() 的 [[Scope]],可表示為 testEC.scopeChain,又等同於 [testEC.AO, test[[Scope]]]。

第三步:建立 inner 執行環境

執行完 var b = 2 後,將 test EC 的 AO 初始為 2。

接著進入 inner() 時同樣會建立 inner EC 跟 AO 建立,然後執行完 var c = 3 後,將 inner EC 的 AO 初始為 3:

innerEC: { AO: { c: 3 }, scopeChain: [innerEC.AO, inner[[Scope]]] => [inner.AO, testEC.scopeChain] => [inner.AO, testEC.AO, globalEC.VO] } testEC: { AO: { b: 2, inner: function } scopeChain: [testEC.AO, test[[Scope]]] => [testEC.AO, globalEC.VO] } inner.[[Scope]] = testEC.scopeChain = [testEC.AO, test[[Scope]]] ====== global EC { VO: { a: 1, test: function }, scopeChain: [globalEC.VO] } test.[[Scope]] = globalEC.scopeChain = [globalEC.VO]

第四部:執行程式碼

  • 執行到 console.log(c)
    • innerEC.AO 裡面找到 c = 3
  • 執行到 console.log(b)
    • innerEC.AO 裡找不到
    • 沿著 scopeChain 往上找到 testEC.AO 裡面 b = 2
  • 執行到 console.log(a)
    • innerEC.AO 裡找不到
    • 沿著 scopeChain 往上找到 globalEC.AO 裡面 a = 3
var a = 1; function test() { var b = 2; function inner() { var c = 3; console.log(c); // 3 console.log(b); // 2 console.log(a); // 1 } inner(); } test();

如同前面所說,其實 Scope Chain 就是 VO/AO 的組合,是負責記錄「包含自己的 VO + 所有上層執行環境的 VO」的集合。

藉由編譯完成時的 EC 模型,我們可瞭解程式在執行時,是如何在 AO 或 VO 裡面找到宣告過的變數,若在該作用域找不到,就會沿著 scopeChain 不斷會往上一層找。

這其實能夠解釋之前提過的 Hoisting(提升),還有接下來要探討的 Closure(提升)是如何發生。

Lexical Scope vs Dynamic Scope

有了基本概念後,再來看下列範例,其中 a 的 log 值會是多少呢?

var a = 100 function echo() { console.log(a) // 100 or 200? } function test() { var a = 200 echo() } test()

結果會是 100,echo() 裡面的 a 就是 global 的 a,和 test() 裡面的 a 一點關係都沒有。

這和程式語言是如何決定「作用域」這件事有關,可分為靜態作用域和動態作用域:

  • Static Scope 靜態作用域
    • 又可稱為 Lexical Scope 語法作用域、語彙範疇
    • 變數的作用域在語法解析時,就已經確定作用域,且不會改變
  • Dynamic Scope 動態作用域
    • 變數的作用域在函式調用時才決定
    • 若是採用動態作用域的程式語言,那最後 log 出來的值就會是 200 而不是 100

而 JavaScript 採用的是靜態作用域,在分析程式碼的結構就可以知道作用域的長相。但需特別注意的是,JavaScript 中的 this,其原理和動態作用域非常類似,this 的值會在程式執行時才被動態決定。

建立一些有關作用域的觀念後,再來我們要來談談本篇核心:Closure(閉包)。


Closure 閉包

在 JavaScript 中,Closure(閉包)和作用域的關係密不可分,透過 Scope Chain 的機制,我們能夠進一步理解 Closure 產生的原因。

可先來看看下方這個例子:

function test() { var a = 10; function inner() { a++; console.log(a) } return inner // 不加括號,只 return 這個 function } var func = test() func() // 11 => 等同於 inner() func() // 12 => 等同於 inner() func() // 13 => 等同於 inner()

透過在 function 中回傳另一個 function 的寫法,就可以把 a 這個變數鎖在這個 function 裡面,隨時能夠拿出來使用。

一般而言,當 function 被執行完之後,資源就會被釋放掉,但是通過這種寫法,我們就可以把 function 內部變數的值給保存起來。

再根據 MDN 說明,其實閉包就是一個特殊的物件,具有下列兩個含義:

  • 它是一個 function
  • 它產生了一個 context 執行環境,負責記錄上層 VO

再舉個有關 Closure 的例子:

function factory() { var brand = "BMW"; return function car() { console.log("This is a " + brand + " car"); } } var carMaker = factory(); carMaker(); // "This is a BMW car

上述程式碼建立的 EC 模型:

// Global Context global.VO = { factory: pointer to factory(), carMaker: 是 global.VO.factory 的回傳值 scopeChain: [global.VO] } // Factory 執行環境 factory.VO = { car: pointer to car(), brand: 'BMW', scopeChain: [factory.VO, global.VO] } // car 執行環境 car.VO = { scopeChain = [car.VO, factory.VO, global.VO] }

以 JS 運作過程來說, function factory 執行結束後就會從 Call Stack 移除,但是因為 VO 還會被 car VO 參考,所以不會將其移除。

這其實就是前面所提到的 Scope Chain 與 Closure 之間的關係。

操作 Closure 可能遇到的作用域陷阱

由以下範例,可發現執行 arr[0]() 時,結果會是 5:

var arr = []; for (var i = 0; i < 5; i++) { arr[i] = function() { console.log(i); } } arr[0](); // 5

當執行 arr[0]() 時,其實會長這樣:

arr[0] = function() { console.log(i); }

因為 function EC 中沒有宣告變數 i,因此會往上一層作用域找,找到 global EC 的 i,又因為 for 迴圈執行結束,此時的 i = 5,所以會印出 5。

可使用下列方法改寫上述程式碼:

1. 使用閉包:在 function 中 return function

var arr = []; for (var i = 0; i < 5; i++) { arr[i] = logN(i); } function logN(n) { return function() { console.log(n); } } arr[0](); // 0

2. IIFE:立即呼叫函式

  • IIFE:Immediately Invoked Function Expression,是一個在宣告的當下就會馬上被執行的函數。語法如下:
(function () { console.log('hello') })() // hello

因此範例程式碼可修改成,這樣就不用再另外宣告 function,但缺點是可讀性較差:

var arr = []; for (var i = 0; i < 5; i++) { arr[i] = (function(n) { return function() { console.log(n); }; })(i) } arr[0](); // 0

3. 使用 let 宣告:限定變數的作用域

var arr = []; for (let i = 0; i < 5; i++) { arr[i] = function() { console.log(i); } } arr[0](); // 0

執行 arr[0]() 時可以表示成:

var arr = []; { let i = 0; arr[0] = function() { console.log(i); } } arr[0](); // 0

Closure 實際應用

那我們通常會在什麼情況下使用 Closure 呢?像是在計算量很大的時候,或是需要隱藏一些內部資訊。方法如下:

1. 封裝

可將一些不想外露的細節封裝在執行環境中,只露出想要 public 部分。

以下方與金源有關的程式碼為例:

var money = 99 function add(num) { money += num } function deduct(num) { if (num >= 10) { money -= 10; } } add(1) deduct(100) console.log(money)

在這種情況之下,任何人都能夠變動 global 中變數 money 的值。

如果改成使用閉包,就能夠把變數給隱藏起來,無法從外部更改 function 內部資料:

function createWallet(initMoney) { var money = initMoney; return { add: function(num) { money += num; }, deduct: function(num) { if (num >= 10) { money -= 10; } else { money -= num } }, getMoney() { return money; } } } var myWallet = createWallet(99); myWallet.add(1); myWallet.deduct(100); console.log(myWallet.getMoney()); // 90

2. Callbacks 回呼

callback funtcion 就是一種常見的閉包。先前提到像 JavaScript 就是採用單執行緒與 Event Loop 機制,一次只能處理一件事情。

但以 callback 能讓我們能夠延遲函式的調用,實現非同步操作。例如呼叫一個 AJAX 的 XMLHttpRequest,通常就會使用 callback 來處理伺服器的回應,因此等待時其他程式還是能照常運作。

何時不該使用 Clousre?

雖然 Closure 提供非常便利的功能,但因為系統效能的因素,還是必須謹慎使用:

1. 過多的作用域

需注意每當需要取得一個變數時,Scope Chain 會一層一層檢索,直到找到該物件或值,因此越多層會導致需時越長,例如多個巢狀 function。

2. 記憶體回收

記憶體回收(Garbage Collection)機制,簡單來說,就是當物件不再被參考時,就會被記憶體回收處理。

但如果是在不正確使用閉包的情況下,就可能導致記憶體洩漏(Memory Leak),造成程式未能釋放已經不再使用的記憶體,並產生效能問題。

以下方的程式碼為例:

function leakyFn() { leak = 100 } leakyFn() console.log(leak) // 100

若在非 strict 模式下執行,JavaScript 會自動宣告一個全域變數 var leak,因此就算函數執行完畢,leak 還是會繼續存留在 全域環境中,因此也就不會被回收掉。


結語

在學到 Closure(閉包)時,發現到花了很多時間在瞭解有關 Scope(作用域)的概念。也是在這一單元瞭解到,原來之前在課程學到的非同步操作,當中的 callback 其實就和閉包有關,有關 callback 的觀念真的非常重要!

此外也瞭解到,閉包在框架中很常會使用到,透過閉包的方式,就能夠避免汙染全域變數或是記憶體洩漏等問題。

一開始之所以沒辦法很快理解,或許就是沒有把這些觀念融會貫通,都是一個環節接著另一個環節,和 Scope Chain 一樣,會需要往上一層去找出需要的拼圖。

參考資料: