# JavaScript - Scope 作用域 & Closure 閉包 ## Scope 作用域 ### 什麼是作用域 > 「作用域就是一個變數的生存範圍,一旦出了這個範圍,就無法存取到這個變數」。 當我們把變數 `a` **宣告在 function 中**,function 之外的地方都無法取用這個變數: ```javascript // 把變數宣告在 function 中 function test1() { var a = "hello" } console.log(a) // Uncaught ReferenceError: a is not defined ``` 但當我們把變數 `b` **宣告在全域**,function 裡面也可以取用外面的變數: ```javascript // 把變數宣告在全域 var b = 123 function test2() { console.log(b) // 123 } test2() ``` 再看看這個例子: ```javascript // 宣告函式 function outer() { var x = 5 // 在函式裡面宣告另一個函式 function inner() { var y = 4 console.log(x+y) // 9 } inner() console.log(y) // Uncaught ReferenceError: y is not defined } // 呼叫函式 outer() ``` - `y` 不能被 `outer` 取用:`y` 不在 `outer` function 的作用域中因此無法被 `outer` 取用 - 但 `x` 可以被 `inner` 取用:`inner` 在自己的作用域中找不到 `x` ,便開始向外尋找,最後在 `outer` 的作用域中取用 `x` :::info 結論: - 外面存取不到裡面的,但內層可以存取到外層的 - 倘若一直向外查找到全域都找不到變數,就會出現的 `ReferenceError: variable is not defined` 錯誤訊息 ::: ### 靜態作用域 / 語法作用域 Javascript 的作用域是採用「靜態作用域(static scope)」,也稱作「語法作用域 (lexical scope)」(也有人翻成「詞法作用域」、「語彙作用域」)。「靜態作用域」是相對於「動態作用域(dynamic scope)」而來。 #### 靜態作用域 靜態作用域決定作用域的方式是根據**函式在哪裡被宣告**,與函式在哪裡被呼叫無關,查找變數的流程不會因為函式實際執行的位置不同而改變。 #### 動態作用域 動態作用域決定作用域的方式是以呼叫堆疊 (call stack) 為準,查找變數的流程是執行時才決定的。 來看看這個例子: ```javascript var name = "Peter"; // 全域變數 function init() { var name = "Amy"; // 局部變數 function displayName() { console.log(name); // "Peter" or "Amy"? } displayName(); } init(); ``` 答案是 Amy。 1. `init()` 函式在自己的作用域建立了局部變數 `name` 以及 `displayName()` 函式 2. `displayName()` 函式的作用域中沒有局部變數 `name` ,因此開始向外層尋找,先找到了 `init` 作用域的 `name` 變數 --> 因此答案是 Amy 再看看另一個例子: ```javascript var name = "Peter"; // 全域變數 function displayName() { console.log(name); // "Peter" or "Amy"? } function init() { var name = "Amy"; // 局部變數 displayName(); } init(); ``` 答案是 Peter。 - `displayName` 裡的 `name` 其實就是 global 的 `name`,跟 `init` 裡的 `name` 沒有關係。 - 這是因為 Javascript 是採用「靜態作用域」的關係, `displayName()` 是在全域中**被宣告**,即使是在 `init()` 中**被呼叫**,它的作用域仍與 `init()` 無關。(在某些採用「動態作用域」的程式語言中, `name` 確實有可能會是 `"Amy"`)。 :::info 這邊可以知道: - 作用域跟這個 function 在哪裡被「呼叫」一點關係都沒有 - 靜態作用域是在 function 被「宣告」的時候就決定了,而不是 function 被「執行」的時候 ::: ### 如何產生作用域 作用域有三種: - 全域 Global Scope - 函式作用域 Function Scope - 塊級作用域 Block Scope (ES6) ### 全域 Global Scope Javascript 執行的一開始就會創建一個全域環境,被定義在 function-scope 或是 block-scope 以外的變數就叫做全域變數 (global variable)。 - 不在函式或區塊內宣告的變數就是全域變數 - 可以在任何地方取用的變數 - **如果在定義變數時沒有加上宣告變數,即使在函式內也會成為全域變數**(應避免這種情形) ```javascript function hello() { name = 'Jack'; } hello(); // 先執行 hello() 才宣告了 name 這個變數 console.log(name); // Jack,即使變數是在函式中被定義,還是變成了全域變數 ``` ### 函式作用域 Function Scope 每建立一個函式就會創建一個新的函式作用域。 - 在函式中宣告的變數只能在函式(該作用域中)使用 - **不論是透過 `var`, `let`, `const` 宣告的變數,當他們在函式中宣告時都屬於函式作用域** ```javascript function foo(){ var num = 10; // function scope } console.log(num); // Uncaught ReferenceError: num is not defined ``` ### 塊級作用域 Block Scope 在 ES6 中引入了 `let` 與 `const` 變數,這兩個變數的宣告方式提供了「區塊作用域」。 - 區塊作用域的範圍只存在於大括號 `{}`(例如 if 或 for) - 區塊作用域與函式作用域相同,內部宣告的變數不能從外部取用 - **`var` 宣告的變數不會有區塊作用域,透過 `let` 與 `const` 宣告才能讓變數具有區塊作用域** ```javascript // 在 {} 中用 let 或 const 宣告的變數具有區塊作用域 function foo1() { if (true) { const user = "花爸"; // block scope } console.log(user); // Uncaught ReferenceError: user is not defined } foo1(); // 在 {} 中用 var 宣告的變數不會有區塊作用域 function foo2() { if (true) { var user = "花媽"; // function scope } console.log(user); //花媽 } foo2(); ``` 在上述的例子中, - `foo1()` 使用了 `const` 宣告變數,所以 if 的 `{}` 內為區塊作用域,`const user = "花爸"` 只存在於該 `{}` 區塊中。既便是同函式中的 `console.log(user)` 也無法取用 if `{}` 內宣告的變數。 - `foo2()` 使用了 `var` 宣告變數,不會有區塊作用域,`var user = "花媽"` 存在於 `foo2` 函式中。因此同函式中的 `console.log(user)` 可以取用該變數。 ### 作用域鏈 Scope Chain Javascript 在使用一個變數的時候,會先在當層的作用域尋找該變數。若當前的作用域找不到該變數,會再往父層作用域尋找,就這樣一層一層往上找,一直到全局作用域如果還是沒找到就會報錯。這種由內而外搜尋的行為就是「作用域鏈」。 > 事實上,每個函式在執行時都會建立一個對應的作用域鏈。(延伸閱讀:[前端中階: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)) ```javascript function outer() { var a = 10 function inner() { console.log(a) // 10 } inner() } outer() ``` 上述例子中,`inner` 函式在自己的作用域中找不到 `a` 變數,就會往上一層的 `outer` 作用域找,如果還是找不到就會再往上一層找直到最上層的 `global` 作用域為止,如果最後還是找不到就會報錯。 查找方向:【 inner function scope 】 -> 【 outer function scope 】 -> 【 global scope 】,這個過程就構成了一個作用域鏈。 對 `inner` 來說,`a` 變數並不存在於它的作用域中,但它仍能存取這個變數,這樣的變數也叫做「**自由變數 (free variable)**」。 ## Closure 閉包 ### 什麼是 closure 根據 [MDN 的敘述](https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Closures): > 閉包(Closure)是函式以及該函式被宣告時所在的作用域環境(lexical environment)的組合。 ```javascript const outer = () => { const greet = 'Hi' const inner = () => { console.log(greet) // 'Hi' } inner() } outer() ``` 當我們呼叫 `outer` 時,就只是執行內部的一個 function `inner` 而已。 但如果我們不直接執行 `inner` ,而是直接把這個函式回傳: ```javascript const outer = () => { const greet = 'Hi' const inner = () => { console.log(greet) // 'Hi' } return inner } const newFunc = outer() // 建立了 newFunc 實例(instance) newFunc() ``` 原本當函式執行完成以後,裡面宣告的變數也應該要被釋放,因此當 `outer()` 執行結束時,照理來說變數 `greet` 的記憶體空間會被釋放,但緊接著在呼叫 `newFunc` 時卻仍能存取到 `greet`。 換句話說只要 `newFunc` 還在, `greet` 就依然存在。這種**在 function 裡回傳了一個 function** 導致明明函式執行完畢卻還能存取到資源的現象就是「閉包」。 我們首先在 `outer` 函式中: 1. 宣告了一個變數 `greet` 2. 宣告一個 `inner` 函式,`inner` 函式會印出 `greet` 3. 回傳 `inner` 函式 接著把 `outer` 函式指派給 `newFunc` 變數,此時: - `newFunc` 是 `inner` 在 `outer` 運行時所建立的實例 (instance) - `newFunc` 存取了 `outer` 回傳的值(也就是 `inner` 函式) - 由於 `inner` 的 instance 中保有了原本作用域的環境參照,而作用域裡含有 `greet` 變數,因此調用 `newFunc()` 時 `greet` 也能被取用 ### Closure 的特性 - 在 JavaScript 中每當函式被建立時,一個閉包就會被產生 - 當一個 function 內 return 了另一個 function,通常就是有用到閉包的概念 - 閉包是資料結構的一種,當一個函式被宣告時,函式對其語法環境 (lexical environment) 的引用在一起的組合就是閉包(可以想像成函式記住了宣告時的作用域環境) - 閉包就是可以回傳一個 function,並且使用父層 function 的變數 - 閉包可以讓函式內部的變數不受外部環境影響 - 閉包內部可以透過作用鏈獲取外部資料,內層作用域(child scope)永遠可以取得外層作用域(parent scope)的資料,反過來則不行 ### Closure 的好處 #### 節省寫入記憶體的次數 可以把程式中需要重複執行的部分透過閉包封裝起來,進一步簡化程式,或是讓變數的值給保存下來。 1. 把想要回傳的東西包到 function 裡再回傳 2. 再把 function assign 給變數 3. 讓變數去存取 function 回傳的值 假設今天這個函式需要創造一個很大的 array,並且這個函式會被呼叫很多次: ```javascript function heavyDuty(index) { const bigArray = new Array(7000).fill('something') console.log('created!') return bigArray[index] } heavyDuty(688) // created! heavyDuty(688) // created! heavyDuty(688) // created! ``` 每執行一次函式都需要重複產生一個 7000 個 index 的 array,太浪費資源了,這時就很適合使用 closure: ```javascript function heavyDutyClosure() { const bigArray = new Array(7000).fill(':)') console.log('created!') return function(index) { // 簡化寫法,直接 return function return bigArray[index] } } const getHeavyDuty = heavyDutyClosure() getHeavyDuty(688) // created! getHeavyDuty(688) getHeavyDuty(688) ``` 1. 把想要回傳的東西包到 function 裡再回傳 2. 把函式 `heavyDutyClosure` assign 給變數 `getHeavyDuty`,讓 `getHeavyDuty` 去存取 `heavyDutyClosure` 所回傳的值(也就是函式 `function(index)...`) 3. 執行 `getHeavyDuty` 時,就是在呼叫函式 `function(index)...` 值得注意的是,雖然呼叫了三次 `getHeavyDuty` 但用 closure 寫法的版本只會在第一次印出 `created!` 執行第一次 `getHeavyDuty` 時的運作流程: ``` 呼叫 getHeavyDuty -> 呼叫 heavyDutyClosure -> 創造 bigArray 並印出 created! -> 回傳函數 -> 執行回傳的函數 -> 回傳 bigArray[688] ``` 執行第一次後 `function(index)...` 就已經被存取 `getHeavyDuty` 這個變數了。後續在執行 `getHeavyDuty` 就是在執行 `function(index)...` 了。而 `heavyDutyClosure` 中沒有被 return 的部分(產生 7000 個 array、印出 created!)就只會執行一次。 接下來幾次執行 `getHeavyDuty` 的運作流程其實只剩: ``` 呼叫 getHeavyDuty -> 呼叫函數 function(index)… -> 回傳 bigArray[688] ``` #### 封裝(Encapsulation) 變數 (private variable) 封裝是物件導向程式設計(Object Oreinted Programming,簡稱 OOP) 一個非常重要的概念。 有些資料如果不想讓外部函式/方法改動它,就可以使用閉包的方式來確保只有內部函式可以改動內部資料。 ```javascript let count = 0 function increment(num) { return count += num } function decrement(num) { return count -= num } function getCount() { return count } increment(3) increment(3) increment(1) getCount() // 7 ``` 這裡直接在全域宣告 `count` 變數是錯誤的,可能會導致程式碼出現不可預期的錯誤。比如要是有人在其他地方寫了 `count = 5` 資料就亂掉了。 這種情況就可以使用閉包,寫一個 function 把 `count` 這個變數和能夠改變這個變數的 function 封裝在一起: ```javascript function createCounter (initCount) { let count = initCount return { increment: (num) => count += num, decrement: (num) => count -= num, getCount: () => count } } const counter = createCounter(0) counter.increment(3) // 3 counter.increment(3) // 6 counter.increment(1) // 7 counter.getCount() // 7 count = 5 // ReferenceError: count is not defined ``` 這樣一來除了 `.increment()` 、`.decrement()` 、`.getCount()` 之外的方法都無法改變 `count` 這個變數。 - `count` 是一個 **private variable** - `count` 只存在於 `createCounter` 作用域中 - `count` 只能被內部的函式改動資料,不會被外部環境所影響 - 能夠存取 private variable 的方法被稱作 **privileged method** #### 記憶體回收 (Garbage Collection) ## 作用域陷阱&閉包應用 ```javascript var btns = document.querySelectorAll('button') for(var i=0; i<btns.length; i++){ btns[i].addEventListener('click', function(){ alert(i+1) }) } ``` 假設頁面上有 3 個按鈕,預期的行為是點擊第一個按鈕跳出 1 、點擊第二個按鈕跳出 2 ⋯以此類推。但實際操作後會發現,無論點擊哪一個按鈕都會印出 3。 原本想像中的迴圈應該是要這樣跑的: ```javascript btn[0].addEventListener('click', function(){ alert(1) }) btn[1].addEventListener('click', function(){ alert(2) }) btn[2].addEventListener('click', function(){ alert(3) }) ``` 但實際上 JS 引擎在運作時是這樣跑的: ```javascript btn[0].addEventListener('click', function(){ alert(i+1) }) btn[1].addEventListener('click', function(){ alert(i+1) }) btn[2].addEventListener('click', function(){ alert(i+1) }) ``` 在跑迴圈的時候只是加上它的 callback function 而已還沒有執行,是要等到使用者按按鈕的時候才會去尋找 `i` 變數。 也就是說,事件發生時(使用者點擊按鈕)所引發的函式 (callback function) 是在迴圈跑完之後才執行。 加上這幾個 callback function 本身並沒有 `i` 這個變數,因此會向作用域的外層開始尋找,一直找到在迴圈中定義的 `i` (定義在全域中)作為其值。 而此時的 `i` 早就已經變成了 2 了,因此無論點擊哪一個按鈕,取用的都是同一個全域變數中的 `i`,所以都只會輸出 3。 這個問題可以透過閉包來解決: **建立一個新的函式並傳入參數:** ```javascript function getAlert(num) { return function() { alert(num) } } for(var i=0; i<btn.length ; i++) { btn[i].addEventListener('click', getAlert(i)) } ``` 透過高階函式 (Higher Order Function) 產生三個新的 function,並且因為傳入了一個參數 `i`,利用閉包的特性將 `i` 個別鎖進 `getAlert` 中。 **或者也可以利用 IIFE(Immediately Invoked Function Expression):** ```javascript for(var i=0; i<btn.length ; i++) { (function(num) { btn[i].addEventListener('click', function(){ alert(num) }) })(i) } ``` 透過 IIFE 把 function 包起來並傳入參數 `i` 立即執行。 迴圈每跑一次就會產生一個新的 function 並且立刻呼叫它,因此會就地產生新的作用域,並且利用了閉包的特性將參數 `i` 鎖住。 **也可以直接把 `var` 改成 `let` (ES6)**: ```javascript for(let i=0; i<btn.length ; i++){ btn[i].addEventListener('click', function() { alert(i) }) } ``` ES6 裡有了塊級作用域 (block scope) 之後就可以直接利用 `let` 的特性,等於每跑一次迴圈都會產生一個新的作用域,因此 `i` 就會存在在該作用域裡。可以看成這樣: ```javascript { // 塊級作用域 let i=0 btn[i].addEventListener('click', function() { alert(i) }) } { // 塊級作用域 let i=1 btn[i].addEventListener('click', function() { alert(i) }) } ... ``` ## Ref - [Javascript 的作用域 (Scope) 與作用域鏈 (Scope Chain) 是什麼?](https://www.explainthis.io/zh-hant/interview-guides/javascript/what-is-scope-and-scope-chain) - [[筆記]-JavaScript 作用域與作用域鏈是什麼?關於作用域的4個觀念](https://jianline.com/javascript-scope-and-scope-chain/#%E5%85%A8%E5%9F%9F%E4%BD%9C%E7%94%A8%E5%9F%9FGlobal_Scope) - [[JS基礎] 什麼是閉包(Closure)?](https://johnnychang25678.medium.com/js%E5%9F%BA%E7%A4%8E-%E4%BB%80%E9%BA%BC%E6%98%AF%E9%96%89%E5%8C%85-closure-61bcc1eb02ca) - [[JS] 深入淺出 JavaScript 閉包(closure)](https://pjchender.dev/javascript/js-closure/) - [所有的函式都是閉包:談 JS 中的作用域與 Closure](https://github.com/aszx87410/blog/issues/35) - [前端中階: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) - [閉包包什麼?探索JS中的作用域與Closure | Javascript鍛鍊日記](https://medium.com/%E7%8B%97%E5%A5%B4%E5%B7%A5%E7%A8%8B%E5%B8%AB/%E9%96%89%E5%8C%85%E5%8C%85%E4%BB%80%E9%BA%BC-%E6%8E%A2%E7%B4%A2js%E4%B8%AD%E7%9A%84%E4%BD%9C%E7%94%A8%E5%9F%9F%E8%88%87closure-javascript%E9%8D%9B%E9%8D%8A%E6%97%A5%E8%A8%98-f7b1a2ac1e2a)