# 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)