前言
如果你還不清楚 EC, VO 等名詞以及其運作,建議先看:JavaScript 核心 - 來講講提升(hoisting),不然可能會看不懂這篇所要講的東西。
話不多說,直接先看個程式碼
function complex(num) {
console.log('calculate')
return num * num * num
}
function cache(func) {
var ans = {}
return function(num) {
if(ans[num]) {
return ans[num]
}
ans[num] = func(num)
return ans[num]
}
}
const cachedComplex = cache(complex)
console.log(cachedComplex(20))
console.log(cachedComplex(20))
console.log(cachedComplex(20))
我們先來看一下變數 cachedComplex
因為他會 call cache 這個 function,而 cache 最後會 return 裡面一個 function,所以我們可以看成
const cachedComplex = return function(num) {
if(ans[num]) {
return ans[num]
}
ans[num] = func(num)
return ans[num]
}
所以 call cachedComplex 這個 function,並帶入參數 20,這個參數就會是 num
而 const cachedComplex = cache(complex)
除了 call cache 這個 function 外,也帶入參數 complex(也就是最上面這個 function complex)
所以 function cache(func) 的 func 就是 function complex
OK,搞懂所有變數之後,我們就來實際跑一遍
-
第一次 console.log
call cachedComplex 帶參數 20 給 num
call cache 這個參數並帶 complex 這個 function 給 func
進入 cache 裡,此時 ans 是一個空物件
進到匿名函式中,因為 ans[num] 是 false,所以 if 不成立,因此繼續往下執行
ans[num] = func(num)
,帶入參數後 ans[20] = complex(20)
進入 complex 後,先印出 calculate
接著回傳 8000
ans[20] = 8000
所以 ans = {
20: 8000
}
並且將 ans[20] 的值回傳
剛剛我們說到 const cachedComplex = cache(complex) 可以看成
const cachedComplex = return function(num) {
if(ans[num]) {
return ans[num]
}
ans[num] = func(num)
return ans[num]
}
所以 const cachedComplex = ans[20],會印出剛剛回傳的 8000
第二次 console.log 一樣帶參數 20,中間過程一樣
進到匿名函式中,因為 ans[num] 剛剛已經有值了,所以是 true
if(ans[num]) {
return ans[num]
}
回傳 ans[20],最後印出 8000
*注意這邊是直接回傳,並沒有經過 function complex,所以不會回傳 calculate!
最後全部跑完會印出
calculate
8000
8000
8000
而以上這種在 function 裡 return function,又可以把值記住就是閉包的基本應用。
運作的規則
在講閉包前你該知道的事
根據 ECMAScript 的規範,每一個 EC 都有 scope chain,當你進入一個新的 function EC 時,scope chain 就會被建立。
Scope chain 裡包含 AO(Activation Object),還有 [[Scope]] 這個東西(只有 function 的 EC 才有)
scope chain: [AO, [[Scope]] ]
那
AO
是什麼?
其實 AO 跟我們之前說的 VO 很像
現階段就把他看成,在 global EC 裡的是 VO,在其他 function 裡的就是 AO
那什麼是
[[Scope]]
?
當我們宣告 function 時,就會產生一個隱藏屬性
function名字.[[Scope]] = 這一層的 scope chain
這邊先知道這樣就好,直接看範例走一遍就知道了!
var a = 1
function test() {
var b = 2
function inner(){
var c = 3
console.log(b)
console.log(a)
}
inner()
}
test()
-
第一步先初始化
剛說到因為 global 不是 function 所以會建立 VO
然後進入一個新的 EC,scope chain 就會被建立
因為 global 有 function,所以也會建立一個隱藏的屬性 [[Scope]]
globalEC: {
VO: {
a: undefined,
test: func
},
scopeChain: [globalEC.VO]
}
test.[[Scope]] = globalEC.scopeChain
-
初始化完成,開始逐行執行賦值等
最後也執行到 test(),進入 testEC 的初始化
因為是 function 所以建立 AO
function test 內有 function,所以也建立 [[Scope]]
testEC: {
AO:{
b: undefined,
inner: func
},
scopeChain: [testEC.AO, test.[[Scope]]]
// 其實[testEC.AO, test.[[Scope]]] = [testEC.AO, globalEC.scopeChain] = [testEC.AO, globalEC.VO]
}
inner.[[Scope]] = testEC.scopeChain
// 這邊也可以看成 = [testEC.AO, globalEC.VO]
globalEC: {
VO: {
a: 1,
test: func
},
scopeChain: [globalEC.VO]
}
test.[[Scope]] = globalEC.scopeChain
-
接著我們一樣繼續逐行執行,然後 call inner
innerEC: {
AO:{
c: undefined
},
scopeChain: [innerEC.AO, inner.[[Scope]]] = [innerEC.AO, testEC.AO, globalEC.VO]
}
testEC: {
AO:{
b: 2,
inner: func
},
scopeChain: [testEC.AO, test.[[Scope]]] = [testEC.AO, globalEC.VO]
}
inner.[[Scope]] = testEC.scopeChain = [testEC.AO, globalEC.VO]
globalEC: {
VO: {
a: 1,
test: func
},
scopeChain: [globalEC.VO]
}
test.[[Scope]] = globalEC.scopeChain
-
逐行執行(因為這邊只有要幫 c 賦值,所以就不寫了)
接著 console.log(b),我們透過 scope chain 來找
scopeChain: [innerEC.AO, inner.[[Scope]]] = [innerEC.AO, testEC.AO, globalEC.VO]
在 innerEC 的 AO 沒找到,再去 testEC.AO 找
找到了 b,所以印出 2
再來 console.log(a),一樣用 scope chain 來找
在 innerEC 的 AO 沒找到,再去 testEC.AO 找也沒找到,再去 globalEC.VO 找
找到了 a,所以印出 1
所以我說那個閉包呢
那實際上閉包的怎麼運作的呢?
有了前面這些基礎知識之後,我們再實際跑一次以下程式碼:
var v1 = 10
function test() {
var vTest = 20
function inner() {
console.log(v1, vTest)
}
return inner
}
var inner = test()
inner()
-
首先一樣初始化 global EC
globalEC: {
VO: {
v1: undefined,
test: func,
inner: undefined
},
scopeChain: [globalEC.VO]
}
test.[[Scope]] = globalEC.scopeChain
-
執行程式碼,並且在執行到 var inner = test()
時,進入 test EC
globalEC: {
VO: {
v1: 10,
test: func,
inner: undefined
},
scopeChain: [globalEC.VO]
}
test.[[Scope]] = globalEC.scopeChain
-
進入 test EC
testEC = {
AO: {
vTest: undefined,
inner: func
},
scopeChain: [testEC.AO, test.[[Scope]]] = [testEC.AO, globalEC.VO]
}
inner.[[Scope]] = testEC.scopeChain
globalEC = {
VO: {
v1: 10,
inner: undefined,
test: func
},
scopeChain: globalEC.VO
}
test.[[Scope]] = globalEC.scopeChain
-
執行程式碼,並且把 inner 回傳回去
testEC = {
AO: {
vTest: 20,
inner: func
},
scopeChain: [testEC.AO, globalEC.VO]
}
inner.[[Scope]] = [testEC.AO, globalEC.VO]
globalEC = {
VO: {
v1: 10,
inner: undefined,
test: func
},
scopeChain: globalEC.VO
}
test.[[Scope]] = globalEC.scopeChain
執行完後,照理說 test 這個 function 結束了,所以 testEC 應該就要釋放掉。
BUT! 因為我們把 inner 這個 function 回傳了,而 inner.[[Scope]] 裡面還記著 testEC.AO,所以導致 testEC.AO 還存在記憶體裡無法釋放。
testEC 執行完了仍困在記憶體裡,這也是為什麼我們可以把閉包裡的值記住,因為他並沒有被釋放。
接下來只剩下執行 inner(),就不再贅述。
其實你用過閉包了
在我們學到 closure 之前,相信很多人其實已經跟他打過照面了
我們用一個常見的例子來說明
var arr = []
for(var i=0; i<5; i++) {
arr[i] = function() {
console.log(i)
}
}
arr[0]()
這邊我們用迴圈,依序在 arr 裡面放入可以 log 出位置 0~4 的 function
所以我們執行 arr[0]()
時,預期會印出 0
但最後結果竟然印出 5
-
這是因為當我們宣告 var i=0
時,其實是將 i 宣告在 global
而我們跑迴圈時
第一圈 i=0
arr[0] = function(){
console.log(i)
}
第二圈 i=1
arr[1] = function(){
console.log(i)
}
...後面以此類推
當 i=5
時,因為 i 必須 < 5,所以跳出迴圈
所以當後面執行 arr[0]()
時,就會 call
arr[0] = function(){
console.log(i)
}
此時的 i 就是 5,所以才會印出 5
那我們要怎麼改寫成我們想要的呢?
(1) 用一個新的 function 代替
var arr = []
for(var i=0; i<5; i++){
arr[i] = logN(i)
}
function logN(n){
return function(){
console.log(n)
}
}
arr[0]()
當 i=0
時,會執行 logN 並帶入 i
這時候會產生一個作用域並且回傳 i
由於用到閉包的緣故,這些值會被保留
因此之後 call 數字時,就會印出相同的數字
-
(2) 立即呼叫函式(IIFE, Immediately Invoked Function Expression)
其實跟上面一樣只是用 IIFE 的方式讓程式碼較簡潔,但可讀性會變差
IIFE 的形式其實就是 (func)(參數)
所以我們要把上面那個 logN 改成立即呼叫函式,只需要幾個步驟
arr[i] = logN(i)
第一,將 logN 換成原本另外寫的 function,並且前後加上()
arr[i] = (function logN(n){
return function(){
console.log(n)
}
})(i)
第二,將原本的 function 名刪掉
arr[i] = (function (n){
return function(){
console.log(n)
}
})(i)
就完成了!
所以就是以下的程式碼:
var arr = []
for(var i=0; i<5; i++){
arr[i] = (function(n){
return function(){
console.log(n)
}
})(i)
}
arr[0]()
-
(3) 把 var 換成 let
因為 let 的 scope 是大括號內,因此迴圈跑 5 圈就會有 5 個 block
{
let i = 0
arr[0] = function(){
console.log(i)
}
}
{
let i = 1
arr[1] = function(){
console.log(i)
}
}
...以此類推,所以就可以印出想要的數字
所以閉包可以用在哪?
通常你會用 closure 是當你想隱藏東西的時候
什麼意思呢?我們用一個簡單的加減錢的算式來看
var money = 99
function add(num){
money += num
}
function deduct(num){
if(num >= 10){
money -= 10
}else{
money -= num
}
}
add(1)
deduct(100)
console.log(money)
答案便會是 90
但這樣做其實會有風險,如果我在 log 前面再加一行 money = -1
,答案就會變了
要怎麼避免這件事呢,利用 closure
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())
這樣的寫法會讓我們無法從外部直接改寫 money 的值
只能用這個函式提供的 add
& deduct
自由度降低,但相對較安全
以上就是 closure 的簡單介紹,可以參考這篇:所有的函式都是閉包:談 JS 中的作用域與 Closure,會有更詳細的說明!