前言
繼上次的【提升到哪去?詳解Hoisting | Javascript鍛鍊日記】,我們有談論到JS中的hoisting是如何出現的以及相關運作模式。其中,我有提到當JS引擎在找某個已宣告的變數或是函式會透過「scope chain」找,而當時因為為避免篇幅太長且影響文章主軸,所以我選擇先擱置他,心想反正講scope chain讀者應該都懂我的意思(被打)。
總之,今天這篇文章就是要來講講作用域(包含scope chain)以及閉包與他的關係,如果你對於這兩個名詞有相關的疑惑,也還請跟著我一起來探討其中的意義。
不過在往下閱讀之前,如果對於Execution Context或是Variable Object不是很熟的讀者,建議可以先去看一下前一篇【提升到哪去?詳解Hoisting | Javascript鍛鍊日記】,強烈建議要有相關的知識才比較能聽懂今天的文章。
如果你已經有作用域與閉包的概念,基本上可以直接跳到【作用域鏈】,那裡才是真正的討論主軸。
都閱讀完了嗎?那就Let’s GO GO GO!
作用域 Scope
一般在寫程式的時候,常常會聽到這個變數屬於XX的作用域,那作用域是什麼?
最常見的解釋是:「作用域是一個變數的生存範圍,於作用域之外將失去存取此變數的權限」。
先來看一個例子:
在這段程式中,定義outer與inner兩個函式,並且都在自已的函式中宣告一個變數,然而當我們執行到alert(y)時會出現Uncaught ReferenceError: y is not defined
。
這代表什麼?代表說因為y並不在outer的作用域中,所以他沒有存取y的能力。但反過來inner卻能夠存取到outer的x變數,這是因為在inner的作用域中找不到x,所以JS引擎試著向外尋找x,而最後在outer中找到。
用比喻的話,我們可以把作用域想成是房間,房間是層層包起來的,而你是住在裡面的房客(函式),你入住時房東會給你外面每一扇門的鑰匙(存取權限)。
因為你有外面房間鑰匙,所以當你在你的房間找不到某樣東西(變數),你可以出去找並拿回房間使用。而相對來講,因為你不是內層的住戶,不會有那些房間的鑰匙,也就自然無法進去拿東西,會被拒於門外。
如何產生作用域
在ES6出現以前,唯一能產生作用域的方式就是寫一個函式,所以不會像C或是Java一樣有所謂的「塊級作用域」。但在ES6之後,因為引進了const與let兩種新的定義變數的方式,所以JS從此之後也擁有產生塊級作用域的能力,不過因為今天的主角不是他們,所以我就先跳過他們,不然會變得相當複雜。
若排除ES6的塊級作用域,JS的作用域可分成:
- 全域 — Global level
- 函式產生的作用域 — Function Level
因此凡舉迴圈(loop)、條件陳述(if-else)都不會自己產生作用域,這點對於開發人員非常不友善,至於是什麼個不友善法,就留到下面的閉包再來討論。
靜態作用域 v.s. 動態作用域
事實上作用域有分成靜態與動態,其中靜態作用域也常稱為語彙範疇。
所謂的語彙(lexical)指的是編譯器將我們寫的程式碼解析時,最後切出的token。舉var x = 3
來說,當中就有var
、x
、=
、3
這幾個token。
因為靜態作用域決定作用域的方式是根據函式的絕對位置(在哪裡被創造),跟怎麼呼叫函式無關,所以查找變數的流程不會因函式實際執行的位置不同而改變。
動態作用域則是相反,決定作用域的方式是以呼叫堆疊(call stack)為準,所以查找變數的流程是執行時才決定的。
舉個例子:
以靜態作用域判斷的話,這題的答案是10,因為foo被創造在全域,因此查找的方向是【foo scope】→【global scope】,最後會在全域找到a
。
若以動態作用域來講,這題的答案反而是20,因為呼叫堆疊的順序是【global】→【bar】→【foo】,所以foo是待在bar的作用域中,a
的值也就會優先取bar scope的a
。
雖然作用域有分成靜態與動態,但JS僅支援靜態作用域,所以接下來若只講作用域,那都是指靜態作用域(或稱語彙範疇),因為在JS中這兩者是一樣的意思。
雖然JS不存在有動態作用域,但this的運作模式與之類似。忘記的讀者可以回去翻一下【What’s THIS | 淺談Javascript中令人困擾的this】。
自由變數與作用域鏈
還記得前面的例子中,inner可以存取到x嗎?對inner來說,x並不是被宣告在inner中的變數,但他仍然能存取這項變數。這些明明不在我的作用域,卻能夠被我存取的變數,我們會給他一個名稱叫做「free variable」,也就是自由變數,感覺有點像是以前高中時教的自由電子,意即我能隨意使用他。
接續下來,我們知道唯有內層的作用域能向外一層一層去尋找變數,外層則是無法知道內層的作用域宣告了哪些變數。
對於這種像是把作用域串成一條鏈珠的現象,一般會稱之為作用域鏈(scope chain)。每個函式在執行時都會有他對應的作用域鏈,你可以把它想成一個物件(實際上也是)。
作用域鏈有點類似之前說的原型鏈(prototype chain),都是將某個東西串接起來,讓我們方便向上查找某些物件,但實際上兩者的運作差很多,相信讀完這篇之後你也會發現他們的差異。
作用域大致就是這樣,簡短地總結一下:
- 作用域是變數生存的範圍,可拒絕外部存取內層的變數
- 除了全域,函式也能產生作用域。
- JS只有靜態作用域,只跟函式創造的位置有關。
- 於作用域之外的變數統稱為自由變數
- 作用域鏈使函式方便向外查找自由變數
閉包 Closure
這裡我先不提閉包的定義,因為我想先描出閉包的樣子,告訴你什麼樣的程式叫做閉包,這樣你才比較能夠帶入那種感覺去思考閉包到底是什麼。
閉包長什麼樣子
如果你是第一次聽到閉包這個詞,我把前面的例子稍微改一下:
首先,試著執行這段程式碼看看,你會發現最終的確有輸出值,並且也符合實際的需求,確實輸出了70。然而,照常理來說,當我們的函式執行結束,裡面宣告的變數也應該要被釋放,緊接著就算呼叫test,我們也應該存取不到x跟y才對,而顯然實際不是這樣子,我們依舊能輕鬆存取到他們。
從上面的例子,我們會發現x跟y就好像被關進在某個牢籠中,就算outer執行結束依舊無法釋放他們的記憶體空間,只要test還存在的一天,他們兩個就必須隨傳隨到。
而這個現象就是我們今天要說的「閉包」,也就是看似把外層的變數捕捉起來,使其不會跟著函式執行結束而消亡的一個特性。
閉包的應用
▶️ 當事件發生後存取變數
假設我們有三個按鈕,並且執行以下的程式碼:
原先預期的行為應該是我按下第一個按鈕會輸出1,而第二顆按鈕則是2,第三顆一樣道理。
然而,當實際按下去後,你會發現不管按了哪一顆按鈕,輸出的結果都會是4而不是每一顆按鈕代表的數字。
我想大部分的人都會踩過這個雷,我第一次碰到是在我大一升大二時,那時候才剛學到JS的皮毛,想說來結合HTML做點事件處理,結果一按下去才發現跑出來的東西都不是我要的,但當時只是隨便找個方法解決,並不清楚原來這跟閉包有關係。
回歸正題,所以4這個數字到底從哪來的?
首先,以我們開發人員的角度來說,上面的迴圈拆開的話應該要長這樣:
但實際上JS引擎在跑會呈現這樣:
還記得前面說的「只有函式可以產生作用域」這句話嗎?對於ES6之前,因為不存在區塊級作用域,所以唯一能約束變數的生存範圍的只有函式。因此,在迴圈內宣告的任何變數都是屬於他所處的函式的作用域。
也就是說,上述的變數i
事實上是定義在全域當中,而事件發生時所引發的函式是在迴圈跑完後才執行,所以此時因為內部沒有變數i
,因而取在迴圈定義的i
作為其值。
因為i
的值早就已經變成4了,所以當取值時自然會取到4,這也就是為什麼我們的每個按鈕都只會輸出4的原因。
那我們該怎麼解決這個問題呢?使用閉包來解決!
直接上解答:
直接透過高階函式(Higher Order Function)創建五個不同的函式,並且因為傳入了一個參數i,使得能利用閉包的特性將i個別鎖進createClickFunc中。
當然如果不想額外多寫一個高階函式,也可以選擇使用IIFE(immediately invoked function expression)
IIFE的道理與高階函式類似,每次迴圈執行都會直接產生一個新的函式並且立即執行他,因此會就地產生新的作用域,同樣利用了閉包的特性將參數鎖住。
當然,現今的瀏覽器大多有支援ES6的語法,所以你大可直接使用let,輕輕地敲鍵盤將var改成let即可。
▶️ 自製私有變數
如同之前所說,因為JS本身並沒有類別的概念,自然不會有所謂的private class member這類的東西,但我們可以透過閉包實現相關的功能。
試想有一個簡易的後台系統,可以加入產品項目,或是增減庫存。
為了實現這個功能,我們可以這麼寫:
我們有函式便於操作陣列,因為整個商品的資料暴露在外,所以只要有心誰都能夠直接操作裡面的資料。
要解決這項問題,我們能借助閉包的特性,將他改寫成這樣:
上面的函式回傳的是一個物件,裡面包含的就是前面的兩個函式。因為閉包的關係,所以commodities
這個陣列會被鎖在函式中,而且持續可被後面兩個函式所存取,但外界是看不到這個變數的,因為已經是作用域以外的世界。
另外,這裡我是使用IIFE,這麼做的用意是如果你只會需要造一次這樣的物件,那你可以利用IIFE的特性使整個程式只執行一次,同時也能創造一個作用域。否則,你可以把上面的IIFE換成一般的函式就好,一樣能達成我們的目的。
這時候有人也許會有個想法:「那我額外加個函式屬性給warehouse
,是不是也能存取到commodities
?」。
事實不然,不信的話我再定義一個函式,像這樣:
這裡我額外使用嚴格模式,目的是確定是否真的能存取commodities
,以免JS引擎自動幫我加變數宣告。
拿這份程式碼去跑基本上會得到存取錯誤,而且他還會跟你說commodities
並沒有被宣告,你不能直接對他進行賦值。因此很明顯地,我們就算加個屬性給已經包裝好的物件,也沒辦法存取到原先的閉包,因為閉包一旦創建完成,就具有不可侵犯性。
上述的範例是針對變數,如果你想要有私有函式,作法是差不多的,就是直接宣告一個內部函式即可。
閉包的廣義定義
目前我們知道閉包有一個很特別的功能是,只要某個函式回傳值有包含函式,那在這個作用域所宣告的變數或是函式都會被鎖住,不會被清除。而且閉包還具有不可侵犯性,可以幫我們實現private member。
但,閉包到底是何方神聖?為什麼能鎖住那些變數?
如果你去看維基百科查找什麼是閉包,通常你會看到以下的解釋:
在電腦科學中,閉包(英語:Closure),又稱詞法閉包(Lexical Closure)或函式閉包(function closures),是在支援頭等函式的程式語言中實現詞法繫結的一種技術。閉包在實現上是一個結構體,它儲存了一個函式(通常是其入口位址)和一個關聯的環境(相當於一個符號尋找表)。
首先,頭等函式是指當我的函式也能像一般的變數一樣,可作為參數傳入,也可以是一個函式的回傳值,更能夠直接賦值給一個變數作為其值。
再下一句提及閉包是一個資料結構,裡面包含兩個元素:函式本體與與之相關聯的環境。
因為不只是JS具有閉包的特性,所以每個程式語言所謂的「環境」都不盡相同,而在JS中,這裡的環境指的就是「作用域鏈」。
接下來我們就來談談什麼是作用域鏈吧~
前情提要
姑且不論你到底有沒有看過前一篇文章,因為我自己常常也故意不看別人前面寫的文章就硬著頭皮閱讀,總之我就做一下上一篇文最後的一些總結,也方便等下講解時,你不需要在那邊兩篇文章間切換。
- JS引擎有分編譯階段與執行階段。編譯負責處理語意分析與建立Variable Object的雛形;執行則是真正一行一行將你的程式碼作執行。
- Execution Context代表執行某個函式時的環境,包含this、function code、VO以及等一下會提到scope chain。
- Execution Context有分成「建立」、「執行」兩階段。最終的VO會在建立階段處理,將參數的值設定到VO的屬性上;執行階段則是跑這個EC的function code。
- VO中的屬性有三種來源:參數(函式才有)、變數、內部函式。其中優先權最高的是內部函式,可以覆蓋其他兩者,參數次之,最後才是變數。
作用域鏈 Scope Chain
因為接下來會延續上次hoisting的內容,為了維持一致性,我一樣挑ES3的規格書來介紹作用域鏈。
直接翻開規格書並跳到10.1.4 Scope Chain and Identifier Resolution
,這裡講述:
Every execution context has associated with it a scope chain. A scope chain is a list of objects that are searched when evaluating an Identifier. When control enters an execution context, a scope chain is created and populated with an initial set of objects…
意思是每個EC都配有一個scope chain,而這個東西是一個由物件組成的列表,當我們需要找某個變數或函式都是透過他。而且,scope chain建立的時間點是伴隨EC的創建。
那scope chain裡面的物件是什麼?
全域
在10.2.1
提到:
scope chain is created and initialised to contain the global object and no others.
其中的global object就是之前我們說的VO,也就是說對於全域EC而言,他的scope chain就是他的VO。
區域
在10.2.3
提及:
The scope chain is initialised to contain the activation object followed by the objects in the scope chain stored in the [[Scope]] property of the Function object.
跟全域不一樣,區域EC的scope chain是由一個叫activation object跟函式的[[Scope]]屬性所構成。
什麼是activation object(以下簡稱AO)?回頭去看10.1.6 Activation Object
你會發現他說了一段:
When control enters an execution context for function code, an object called the activation object is created and associated with the execution context. The activation object is initialised with a property with name arguments and attributes { DontDelete }. The initial value of this property is the arguments object described below.
The activation object is then used as the variable object for the purposes of variable instantiation.
整段的大意就是AO是專屬於函式的VO,只是多了一個arguments屬性,所以我們可以把AO看成VO即可,反正規格書也說差不多。
總之,我們已經知道AO是什麼了,那就來看看何謂[[Scope]]。
在8.6.2
中,我們可以直接看到一張圖表,列出物件可能會有的屬性,其中就出現了[[Scope]]。
當中的解釋是這樣:
A scope chain that defines the environment in which a Function object is executed.
所以這代表[[Scope]]的值是另外一個scope chain,而這個scope chain定義這個函式是在哪個環境被執行。
聽完這段解釋後,相信你也猜到[[Scope]]到底裝了誰的scope chain對吧?沒錯!就是當前函式被創造時所處的EC。
除此之外,在13.2 Creating Function Objects
裡面也有提到設定[[Scope]]的步驟:
…
7. Set the [[Scope]] property of F to a new scope chain (section 10.1.4) that contains the same objects as Scope.
綜合來說,假如我們有一個函式叫做func而他在EC被宣告,那他會:
- 當被創建時,func.[[Scope]] = EC.scope_chain
- 當被執行時,對於他的EC,設定其scope chain為AO + func.[[Scope]]
再看一次前面的例子:
結合上一篇,我們來一步一步看發生什麼事吧!
Step 1. 進入Global EC
依據前面說的步驟,全域的scope chain是他的VO,而他的VO長這樣:
比之前多一個步驟,就是設定outer的[[Scope]]屬性,而其值就是globalEC.scope_chain,意即globalEC.VO。
Step 2. 執行全域程式碼
需要執行的程式只有一句 var test = outer(50)
,所以遞迴執行outer。
Step 3. 進入outer EC
同樣地,我們為outer建立他的VO,並且給他自己的scope chain,所以會長成這樣:
其中會發現有趣的事,那就是scope chain是由VO/AO組成的陣列,這同時也回應了前面所說的「每個EC都配有一個scope chain,而這個東西是一個由物件組成的列表」,這裡的物件就是指VO/AO。
還有別忘記要幫inner也設定他的[[Scope]]。
Step 4. 執行outer
這裡沒什麼事要做,唯有把inner回傳出去,所以此時test就會接到outer的回傳值。
Step 5. 進入test EC (也可以說是inner EC)
一如既往,把test EC建立好:
因為沒有宣告任何變數或函式,所以AO幾乎是空的。
Step 6. 執行test
當執行到console.log(x + y)
時,JS引擎首先會去看scope_chain的第一個VO/AO找變數,而他就是inner的AO。
此時因為找不到x,所以往下一個找,也就是outer的AO。而剛好x隸屬於outer的AO,所以將他回傳給test(或說inner)。至於找y的方式也一樣,同樣會在outer找到。
常見Q&A
Q1: 什麼是閉包?
回憶一下閉包最原始的定義:「閉包在實現上是一個結構體,它儲存了一個函式(通常是其入口位址)和一個關聯的環境(相當於一個符號尋找表)」
現在我們知道【關聯的環境】指的是函式的[[Scope]],而他裝著建立函式時所在的EC的作用域鏈。
因此,如果有人問你閉包是什麼,你可以直接回答他:「閉包是一種資料結構,由函式與他的[[Scope]]所組成」。
Q2: 閉包為什麼能持續存取自由變數?
因為在建立函式的當下,JS引擎會賦予這個函式物件一個[[Scope]]屬性,這讓函式在被執行的時候,EC可以透過此屬性去尋找那些變數。
因此,即便函式被當作回傳值,就算不是在宣告他的地方執行,我們也能正確地存取自由變數。
Q3: 我可以說所有函式都是閉包嗎?
還記得維基百科的解釋嗎?其實他後面還有提到:
閉包跟函式最大的不同在於,當捕捉閉包的時候,它的自由變數會在捕捉時被確定,這樣即便脫離了捕捉時的上下文,它也能照常執行。捕捉時對於值的處理可以是值拷貝,也可以是名稱參照,這通常由語言設計者決定…
這段有幾個重點:
- 閉包會捕捉自由變數
- 捕捉的方式有拷貝與參照兩種
- 脫離上下文亦能正確執行
首先第一點,捕捉自由變數指的就是給予函式物件[[Scope]]屬性,使他可以向上尋找外層的變數(剛好再次回應上一題)。而第二點,因為JS引擎會給AO/VO物件,所以我們知道JS的閉包是使用參照。
最後是我覺得比較難懂的第三點。
任何函式在被創建時,都會建立他的[[Scope]],並且JS引擎在編譯時期也已經完成每個函式的VO/AO。所以即便不是在宣告他的地方執行這個函式,在進入EC時需要建立的作用域鏈也能夠透過這兩者合成出來。
因此,綜合以上的論述,我們其實能說所有的函式都可以是閉包。
但請記得,閉包定義是函式與作用域鏈,並非指函式本身,而且這裡說的作用域鏈是指[[Scope]]屬性中儲存的。
總結
把今天的文章做個總整理:
- JS只有靜態作用域,只跟函式建立的位置有關,跟執行位置無關。
- 在ES6以前,作用域是由函式產生,內層可看到外層的變數,反之無法。
- 閉包是函式與作用域鏈的結合體。
- 閉包可以用來製作私有變數、函式,或是解決事件callback function存取自由變數會有的不一致問題。
- 函式被建立的時候會給[[Scope]],其值是當前EC的作用域鏈。
- 任何函式都可以是閉包。
我想今天最大的收穫是知道閉包到底是如何運作的,而且原來所有函式都可以是閉包,一開始我以為閉包是指IIFE而已。
而且讀完規格書這方面的內容後,你會發現原來事情是這麼有趣,像是為何回傳函式並在之後執行他仍然能抓到外層的變數,一切都變得更加明朗。
如果覺得這篇很有用,請分享給其他人知道,希望更多人能知道閉包到底是什麼。或者如果文中有任何錯誤的地方,也請跟我說,歡迎大家一起來討論。
感謝所有看到最後的你們,可以的話也請幫我點個讚吧!謝謝。
參考資料
ES3 規格書 — Entering Execution Context