# [JavaScript] 粗略整理閉包(Closure) ###### tags: `前端筆記` ## 目標 1. Explain what Closure is and how it impacts private functions & variables. 2. Describe how private functions & variables are useful. ## 回答 1. 因為範疇(scope)的緣故,所以要符合範疇的規則才可以拿到特定的變數 / 函式,但是透過 `return` 可以讓我們在範疇之外還可以拿到不屬於同個範疇的資料。此時在公開的函式 / 變數就屬於 **public scope** 無法拿到的則是 **private scope** 2. 避免命名的衝突(因為最後很容易沒梗)+ 確保資料不會不小心被覆蓋 ## 總結 1. 所有函式在執行完就會釋放記憶體(也就是裡面的東西全部都被刪掉),但是透過閉包可以有效地保存特定的東西(但我們不想把那些東西直接丟到全域範疇裡) 2. 所有函式(不管是匿名函式、函式宣告 function declaration、函式運算式 function expression)都是閉包,因為函式在建立的當下就會創造閉包環境,只是看要不要使用而已 > In JavaScript, closures are created every time a function is created, at function creation time. ## 閉包(Closure)是什麼東東? > A closure is a function having access to the parent scope, even after the parent function has closed. *- [w3school](https://www.w3schools.com/js/js_function_closures.asp)* > 閉包(closure)就是一個可以讀取它外層函式的資料,即便外層的函式已經被叫用(一般叫用之後該記憶體就會被回收,且內部變數便被銷毀)。但是閉包會持續保存該記憶體。 ~~閉包(closure)是語意範疇(Lexical Scope)的延伸概念,語意範疇(Lexical Scope)讓內層函式可以讀取到外層函式的資料,而閉包(closure)則多做一點事情,當外層已經被叫用,讓馬上就該被 JavaScript 刪除的資料鎖在內層函式裡等我們使用。~~ **2022/05/29 更新** 閉包即是函式在語彙範疇之外被叫用,也可以讀取該函式擁有的語彙範疇的能力。(範疇:scope) > 引擎在執行程式碼之前會先編譯,編譯大致可以分成三個步驟: > 1. Tokenizing(語法基本單化元)與 Lexing(語彙分析):將程式碼區分成模組塊,比方來說 `var a = 2`,就會被拆成 `var`、`a`、`=` 及 `2`。 > 2. Parsing(語法分析):依照第一步的模組塊建立 AST(abstract syntax tree,抽象語法樹) > 3. Code-Generation(產生目的程式碼):將上一步的 AST 轉為可以執行的程式碼,會依照不同的引擎而有所變化。 ### 什麼是語彙範疇? 語彙範疇則是編譯步驟一(Tokenizing 與 Lexing)時定義的範疇。 寫程式碼 -> 編譯 -> 執行,所以我們如何撰寫程式碼就決定語彙範疇的範圍及變數的取用的範圍。 ```javascript= function foo () { let a = 123; console.log('hello mom!'); // hello mom! console.log(a); // 123 } // 撰寫時已經決定 a 存在於 foo 內 console.log(a); // ReferenceError! a is not defined ``` ==閉包即是函式在語彙範疇之外被叫用,也可以讀取該函式擁有的語彙範疇的能力。== ```javascript= function foo () { const secret = 'ABCDE'; function bar () { console.log(secret); } return bar; } // 我並不是在 foo 內被呼叫,而是透過 return 得到 bar,在 foo 的外層呼叫 const test = foo(); test(); // 'ABCDE' console.log(secret); // ReferenceError! secret is not defined ``` 因為寫程式碼的時候就把 `bar` 寫在 `foo` 內,所以即便在 `foo` 的範圍外透過 `return` 得到 `bar` 函式,依然可以讀取 `foo` 中的 `secret` 變數。 同理,因為寫程式碼的時候就定義函式內的 `x` 得往外找,所以就不會變成 `20`: ```javascript= // ref. http://dmitrysoshnikov.com/ecmascript/chapter-6-closures/ let x = 10; function foo() { console.log(x); } (function (funArg) { let x = 20; // variable "x" for funArg is saved statically // from the (lexical) context, in which it was created // therefore: funArg(); // 10, but not 20 })(foo); ``` ### 範例 1[*(ref.)*](https://ultimatecourses.com/blog/everything-you-wanted-to-know-about-javascript-scope#what-is-scope) ```javascript= var sayHello = function (name) { var text = 'Hello, ' + name; return function () { console.log(text); }; }; sayHello('Todd'); // 不會有東西,因為此時回傳的另一個函式 var helloTodd = sayHello('Todd'); helloTodd(); // Hello Todd // 也可以這樣子搞 sayHello('Bob')(); // Hello, Bob ``` 照理說當 `sayHello` 結束時 JavaScript 就會清掉函式的記憶體空間,不過範例中有 `return` `sayHello` 內的函式,因為範疇(scope)的原理,裡面可以拿外面的資料,所以接下來只要將變數名稱與 `return` 回來的函式記憶體連結,就可以完整讀取到 `return` 回來的函式的所有資料。 我們必須要透過閉包才可以在範疇外拿到特定範疇的資料,也代表著特定的範疇的資料沒辦法不透過閉包讀取,所以閉包就會產生 **private scope** 及 **public scope**。 ### 範例:如果要數水果的話 #### 沒使用閉包 缺點: 1. 會建立一個全域變數,如果專案大時,如果不小心更新很麻煩 2. 如果是多人專案的話,變數命名空間到最後會越來越少.... ```javascript= let appleNum = 20; let bananaNum = 20; function buyAnApple () { appleNum ++; } function eatAnApple () { appleNum --; } function buyABanana () { bananaNum ++; } function eatABanana () { bananaNum --; } buyAnApple(); buyAnApple(); eatAnApple(); buyBanana(); console.log(appleNum); appleNum = 300; // Oh no! ``` #### 使用閉包 **private scope** 保護了 `_numOfFruit`,**public scope** 則有三個方法 `add`, `eat`, `getFruitQty`。但因為閉包的緣故,所以我們可以透過 **public scope** 讀取 **private scope** 中被保護的 `_numOfFruit`。 除此之外,也可以一個函式多用,套用在其他水果的計算上。 ```javascript= const fruitCounterModule = (fruitName, startNum) => { // *************** Start: private scope *************** let _numOfFruit = startNum; console.log(`目前水果是 ${fruitName},有 ${_numOfFruit} 喔!`); // *************** End: private scope *************** // *************** Start: public scope *************** const add = function () { _numOfFruit ++; console.log(`${fruitName} 再加一份,目前總數:${_numOfFruit}`); } const eat = function () { _numOfFruit --; console.log(`${fruitName} 吃了一份,目前總數:${_numOfFruit}`); } const getFruitQty = function () { return _numOfFruit; } return {add, eat, getFruitQty}; // *************** End: public scope *************** }; const bananaCounter = fruitCounterModule('香蕉', 20); bananaCounter.add(); // 香蕉 再加一份,目前總數:21 bananaCounter.add(); // 香蕉 再加一份,目前總數:22 bananaCounter.add(); // 香蕉 再加一份,目前總數:23 bananaCounter.add(); // 香蕉 再加一份,目前總數:24 console.log(bananaCounter.getFruitQty()); // 24 const appleCounter = fruitCounterModule('蘋果', 10); // 目前水果是 蘋果,有 10 喔! appleCounter.add(); // 蘋果 再加一份,目前總數:11 console.log(appleCounter.getFruitQty()); // 11 ``` ### 也可以透過立即函式(IIFE)來直接創造模組(Module) **private scope** 習慣在變數的開頭加上 `_`。 *範例參考 ([ref.](https://ultimatecourses.com/blog/everything-you-wanted-to-know-about-javascript-scope#what-is-scope))* ```javascript= var Module = (function () { var myModule = {}; var _privateMethod = function () { }; myModule.publicMethod = function () { }; myModule.anotherPublicMethod = function () { }; return myModule; // returns the Object with public methods })(); // usage Module.publicMethod(); ``` ### 只要記得「撰寫時就決定」函式範疇的範圍就不會錯 函式在自己的範圍內找不到就會往外找,當從外層找到的值被更新,closure 也會更新: ```javascript= // ref. http://dmitrysoshnikov.com/ecmascript/chapter-6-closures/ let firstClosure; let secondClosure; function foo() { let x = 1; firstClosure = function () { return ++x; }; secondClosure = function () { return --x; }; x = 2; // affection on AO["x"], which is in [[Scope]] of both closures console.log(firstClosure()); // 3, via firstClosure.[[Scope]] } foo(); console.log(firstClosure()); // 4 console.log(secondClosure()); // 3 ``` ## 迴圈與閉包 (錯誤版)實作一個依照秒數印出數字的函式: ```javascript= for (var i = 0; i < 5; i ++) { setTimeout(() => { console.log(i); }, i * 1000); } // 5(其實是跑 5 次半) ``` ### 為什麼會只出現 5 次 5 呢? 因為 `var` 只有 function scope 沒有 `let` 及 `const` 擁有的 block scope(`if`, `while loop`, `for loop`...)。每一次迴圈都只有一個 `i`,只是因為執行 `for loop` 的最後一部更改 `i` 連結的記憶體而已。所以到 `setTimeout` 的執行時間就會找到唯一一個 `i(5)`。 ![](https://i.imgur.com/TOeSsTK.png) ### 那要怎麼解決呢? #### 1. 使用 `let` 創造 block scope ```javascript= for (let i = 0; i < 5; i ++) { setTimeout(() => { console.log(i); }, i * 1000); } // 0 // 1 // 2 // 3 // 4 ``` `let` 有 block scope,所以 `for` 裡面每次跑迴圈都會是一個「新的」`i`(不像 `var` 是只有唯一一個「舊的」`i`)。每次迴圈都會執行 `setTimeout`,因為迴圈後 `i` 就沒了,所以 `setTimeout` 中確實可以保存好每次的 `i`,所以就會依序出現 0, 1, 2, 3, 4。 ![](https://i.imgur.com/MV8rmIn.png) #### 2. 如果執意使用 `var` 就要自己創造 function scope 保存 `var i` 的值 ```javascript= for (var i = 0; i < 5; i ++) { (function(i) { setTimeout(() => { console.log(i); }, i * 1000); })(i); } // 0 // 1 // 2 // 3 // 4 // arrow function for (var i = 0; i < 5; i ++) { ((i) => { setTimeout(() => { console.log(i); }, i * 1000); })(i) } // 0 // 1 // 2 // 3 // 4 ``` 每次 `for` 執行迴圈時使用立即函式建立一個 function scope 讓每次迴圈的 `var i` 能夠成功保存在立即函式中的 setTimeout 中。(`var` 有 function scope) *以上的觀念在 [Closures in JavaScript | Inside a loop, inner function and setTimeoout](https://www.youtube.com/watch?v=-xqJo5VRP4A) 中 09:00 處開始* ## 參考資料 1. [Everything you wanted to know about JavaScript scope](https://ultimatecourses.com/blog/everything-you-wanted-to-know-about-javascript-scope#what-is-scope) => 裡面還有講 `this` 但目前我先跳過 2. [所有的函式都是閉包:談 JS 中的作用域與 Closure](https://blog.techbridge.cc/2018/12/08/javascript-closure/) => 有講更底層的東西,之後變強務必回來瞭解 3. [[第十七週] JavaScript 進階:什麼是閉包 Closure 與實際應用](https://yakimhsu.com/project/project_w17_advancedJS_03_Clousure.html) => 內有講其他範例,變強後務必回來瞭解 4. [[JS] 深入淺出 JavaScript 閉包(closure)](https://pjchender.dev/javascript/js-closure/) 5. [Javascript Closure Tutorial | Closures Explained](https://www.youtube.com/watch?v=1S8SBDhA7HA) 6. [Closure 閉包](https://eyesofkids.gitbooks.io/javascript-start-from-es6/content/part4/closure.html) => 內有講其他範例,變強後務必回來瞭解 7. [Closures in JavaScript | Inside a loop, inner function and setTimeoout](https://www.youtube.com/watch?v=-xqJo5VRP4A) 8. 你所不知道的 JS -範疇與 closures this 與 物件原型 9. [ECMA-262-3 in detail. Chapter 6. Closures.](http://dmitrysoshnikov.com/ecmascript/chapter-6-closures/)