###### tags: `JavaScript` `Scope` `Closure` # [week 16] JavaScript 進階 - 什麼是閉包?探討 Closure & Scope Chain > 本篇為 [[JS201] 進階 JavaScript:那些你一直搞不懂的地方](https://lidemy.com/p/js201-javascript) 這門課程的學習筆記。如有錯誤歡迎指正! 在上一篇筆記 [[week 16] JavaScript 進階 - 初探 Hoisting & Execution Context](https://hackmd.io/@Heidi-Liu/note-js201-scope-hoisting) 中,我們談到 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 作用域之外會無法存取該變數: ```javascript= 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: ```javascript= var str = 'Global'; if (true) { var str = 'Local'; } console.log(str); // Local ``` #### 狀況二:迴圈變數可能會向外覆蓋全域變數 當 for 迴圈中的變數 i 循環結束時,會蓋過外面的全域變數 i,因此 function 外面的 i 會被重新賦值為 3: ```javascript= 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 部份](https://github.com/heidiliu2020/this-is-codediary/blob/master/week3_ES6%20npm%20Jest.md#es6-%E6%96%B0%E8%AA%9E%E6%B3%95)我們也曾提到,以 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: ```javascript= 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`: ```javascript= 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`: ```javascript= function test() { var a = 10; console.log(a); } test(); console.log(a); // ReferenceError: a is not defined` ``` 這是因為在 function 外面沒辦法存取內部的變數 a,所以會出現錯誤。 #### 範例二:存取 function 以及 global 變數 若分別宣告全域變數和區域變數,log 結果不會互相干擾: ```javascript= var a = 20 // global variable function test() { var a = 10; // function variable console.log(a); // 10 } test(); console.log(a); // 20 ``` #### 範例三:直接在 function 內部賦值 結果全域和區域的兩個 a,其 log 結果會相同: ```javascript= 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 內外都沒有宣告變數 結果仍會和上述範例相同! ```javascript= function test() { a = 10; console.log(a); // 10,test -> global } test(); console.log(a); // 10 ``` 這是因為,在 function 中如果 a 找不到值,就會往外層找,如果全域也找不到,就會自動宣告全域變數 `var a`。 這會和前一個例子寫法產生相同結果,但這種情況其實會產生一些 bug,也就是和預期行為不同,甚至可能產生衝突。 ## Scope Chain 作用域鏈 在說明之前,先來看以下範例: ```javascript= 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」的集合。藉由該屬性,函式內部就可以存取到外部的變數。  聽起來有點抽像,我們舉個簡單的例子: ```javascript= 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()。下圖在建立階段的堆疊示意圖: ![](https://i.imgur.com/RtkT8LP.png) 當 JavaScript 要執行 `console.log(a + b + c)` 這行程式,會不斷往 Scope Chain 去尋找。 就像前面所說的,一開始會先在自己的 VO 找,找不到在換下一個,一直到 global 為止,如果找不到就會拋出錯誤。過程如下圖: ![](https://i.imgur.com/9ROutJb.png) (圖片來源:https://andyyou.github.io/2015/04/20/understand-closures-and-scope-chain/ ) ### ECMAScript 中的作用域 每個執行環境都有一個 Scope Chain。也就是說,一旦進入該執行環境,就會建立 Scope Chain 並進行初始化。 以 global 執行環境來說,初始化後會把 VO 放進 Scope Chain 內,可表示為: ```javascript= scopeChain = [globalEC.VO] ``` 此外,每個函式都有一個 [[Scope]] 屬性,當 global 執行環境遇到函式時,會將它初始化為 global 執行環境的 Scope Chain: ```javascript= function.[[Scope]] = globalEC.scopeChain ``` 當函式被呼叫時,會建立 local 執行環境,也會建立 VO,在函式中會稱作 Activation Object(AO),並且除了 AO 之外,外面傳進來的參數也會被加到該 local 執行環境的 Scope Chain: ```javascript= function.scopeChain = [function.AO, function.[[Scope]]] ``` ### 模擬 JS 實際流程 舉個簡單的範例: ```javascript= 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 來用: ```javascript= 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 : ```javascript= 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: ```javascript= 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 ```javascript= 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 值會是多少呢? ```javascript= 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 產生的原因。 可先來看看下方這個例子: ```javascript= 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](https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Closures) 說明,其實閉包就是一個特殊的物件,具有下列兩個含義: - 它是一個 function - 它產生了一個 context 執行環境,負責記錄上層 VO 再舉個有關 Closure 的例子: ```javascript= 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 模型: ```javascript= // 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: ```javascript= var arr = []; for (var i = 0; i < 5; i++) { arr[i] = function() { console.log(i); } } arr[0](); // 5 ``` 當執行 `arr[0]()` 時,其實會長這樣: ```javascript= arr[0] = function() { console.log(i); } ``` 因為 function EC 中沒有宣告變數 i,因此會往上一層作用域找,找到 global EC 的 i,又因為 for 迴圈執行結束,此時的 i = 5,所以會印出 5。 可使用下列方法改寫上述程式碼: #### 1. 使用閉包:在 function 中 return function ```javascript= 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,是一個在宣告的當下就會馬上被執行的函數。語法如下: ```javascript= (function () { console.log('hello') })() // hello ``` 因此範例程式碼可修改成,這樣就不用再另外宣告 function,但缺點是可讀性較差: ```javascript= var arr = []; for (var i = 0; i < 5; i++) { arr[i] = (function(n) { return function() { console.log(n); }; })(i) } arr[0](); // 0 ``` #### 3. 使用 let 宣告:限定變數的作用域 ```javascript= var arr = []; for (let i = 0; i < 5; i++) { arr[i] = function() { console.log(i); } } arr[0](); // 0 ``` 執行 `arr[0]()` 時可以表示成: ```javascript= var arr = []; { let i = 0; arr[0] = function() { console.log(i); } } arr[0](); // 0 ``` ## Closure 實際應用 那我們通常會在什麼情況下使用 Closure 呢?像是在計算量很大的時候,或是需要隱藏一些內部資訊。方法如下: ### 1. 封裝 可將一些不想外露的細節封裝在執行環境中,只露出想要 public 部分。 以下方與金源有關的程式碼為例: ```javascript= 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 內部資料: ```javascript= 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),造成程式未能釋放已經不再使用的記憶體,並產生效能問題。 以下方的程式碼為例: ```javascript= function leakyFn() { leak = 100 } leakyFn() console.log(leak) // 100 ``` 若在非 strict 模式下執行,JavaScript 會自動宣告一個全域變數 `var leak`,因此就算函數執行完畢,leak 還是會繼續存留在 全域環境中,因此也就不會被回收掉。 --- ## 結語 在學到 Closure(閉包)時,發現到花了很多時間在瞭解有關 Scope(作用域)的概念。也是在這一單元瞭解到,原來之前在課程學到的非同步操作,當中的 callback 其實就和閉包有關,有關 callback 的觀念真的非常重要! 此外也瞭解到,閉包在框架中很常會使用到,透過閉包的方式,就能夠避免汙染全域變數或是記憶體洩漏等問題。 一開始之所以沒辦法很快理解,或許就是沒有把這些觀念融會貫通,都是一個環節接著另一個環節,和 Scope Chain 一樣,會需要往上一層去找出需要的拼圖。 參考資料: - [所有的函式都是閉包:談 JS 中的作用域與 Closure](https://blog.huli.tw/2018/12/08/javascript-closure/) - [參透Javascript閉包與Scope Chain](https://andyyou.github.io/2015/04/20/understand-closures-and-scope-chain/) - [前端中階:JS令人搞不懂的地方-Closure(閉包)](https://hugh-program-learning-diary-js.medium.com/%E5%89%8D%E7%AB%AF%E4%B8%AD%E9%9A%8E-js%E4%BB%A4%E4%BA%BA%E6%90%9E%E4%B8%8D%E6%87%82%E7%9A%84%E5%9C%B0%E6%96%B9-closure-%E9%96%89%E5%8C%85-cbb9c6a4185c)