本篇為 [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 一樣,會需要往上一層去找出需要的拼圖。
參考資料:
- 所有的函式都是閉包:談 JS 中的作用域與 Closure
- 參透Javascript閉包與Scope Chain
- 前端中階:JS令人搞不懂的地方-Closure(閉包)