閉包包什麼?探索JS中的作用域與Closure | Javascript鍛鍊日記

CHC1024
狗奴工程師
Published in
20 min readJun 22, 2020
Photo by chuttersnap on Unsplash

前言

繼上次的【提升到哪去?詳解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的作用域可分成:

  1. 全域 — Global level
  2. 函式產生的作用域 — Function Level

因此凡舉迴圈(loop)、條件陳述(if-else)都不會自己產生作用域,這點對於開發人員非常不友善,至於是什麼個不友善法,就留到下面的閉包再來討論。

靜態作用域 v.s. 動態作用域

事實上作用域有分成靜態與動態,其中靜態作用域也常稱為語彙範疇。

所謂的語彙(lexical)指的是編譯器將我們寫的程式碼解析時,最後切出的token。舉var x = 3來說,當中就有varx=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),都是將某個東西串接起來,讓我們方便向上查找某些物件,但實際上兩者的運作差很多,相信讀完這篇之後你也會發現他們的差異。

作用域大致就是這樣,簡短地總結一下:

  1. 作用域是變數生存的範圍,可拒絕外部存取內層的變數
  2. 除了全域,函式也能產生作用域。
  3. JS只有靜態作用域,只跟函式創造的位置有關。
  4. 於作用域之外的變數統稱為自由變數
  5. 作用域鏈使函式方便向外查找自由變數

閉包 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中,這裡的環境指的就是「作用域鏈」。

接下來我們就來談談什麼是作用域鏈吧~

前情提要

姑且不論你到底有沒有看過前一篇文章,因為我自己常常也故意不看別人前面寫的文章就硬著頭皮閱讀,總之我就做一下上一篇文最後的一些總結,也方便等下講解時,你不需要在那邊兩篇文章間切換。

  1. JS引擎有分編譯階段與執行階段。編譯負責處理語意分析與建立Variable Object的雛形;執行則是真正一行一行將你的程式碼作執行。
  2. Execution Context代表執行某個函式時的環境,包含this、function code、VO以及等一下會提到scope chain。
  3. Execution Context有分成「建立」、「執行」兩階段。最終的VO會在建立階段處理,將參數的值設定到VO的屬性上;執行階段則是跑這個EC的function code。
  4. VO中的屬性有三種來源:參數(函式才有)、變數、內部函式。其中優先權最高的是內部函式,可以覆蓋其他兩者,參數次之,最後才是變數。
圖解 execution context

作用域鏈 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被宣告,那他會:

  1. 當被創建時,func.[[Scope]] = EC.scope_chain
  2. 當被執行時,對於他的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: 我可以說所有函式都是閉包嗎?

還記得維基百科的解釋嗎?其實他後面還有提到:

閉包跟函式最大的不同在於,當捕捉閉包的時候,它的自由變數會在捕捉時被確定,這樣即便脫離了捕捉時的上下文,它也能照常執行。捕捉時對於值的處理可以是值拷貝,也可以是名稱參照,這通常由語言設計者決定…

這段有幾個重點:

  1. 閉包會捕捉自由變數
  2. 捕捉的方式有拷貝與參照兩種
  3. 脫離上下文亦能正確執行

首先第一點,捕捉自由變數指的就是給予函式物件[[Scope]]屬性,使他可以向上尋找外層的變數(剛好再次回應上一題)。而第二點,因為JS引擎會給AO/VO物件,所以我們知道JS的閉包是使用參照。

最後是我覺得比較難懂的第三點。

任何函式在被創建時,都會建立他的[[Scope]],並且JS引擎在編譯時期也已經完成每個函式的VO/AO。所以即便不是在宣告他的地方執行這個函式,在進入EC時需要建立的作用域鏈也能夠透過這兩者合成出來。

因此,綜合以上的論述,我們其實能說所有的函式都可以是閉包。

但請記得,閉包定義是函式與作用域鏈,並非指函式本身,而且這裡說的作用域鏈是指[[Scope]]屬性中儲存的。

總結

把今天的文章做個總整理:

  1. JS只有靜態作用域,只跟函式建立的位置有關,跟執行位置無關。
  2. 在ES6以前,作用域是由函式產生,內層可看到外層的變數,反之無法。
  3. 閉包是函式與作用域鏈的結合體。
  4. 閉包可以用來製作私有變數、函式,或是解決事件callback function存取自由變數會有的不一致問題。
  5. 函式被建立的時候會給[[Scope]],其值是當前EC的作用域鏈。
  6. 任何函式都可以是閉包。

我想今天最大的收穫是知道閉包到底是如何運作的,而且原來所有函式都可以是閉包,一開始我以為閉包是指IIFE而已。

而且讀完規格書這方面的內容後,你會發現原來事情是這麼有趣,像是為何回傳函式並在之後執行他仍然能抓到外層的變數,一切都變得更加明朗。

如果覺得這篇很有用,請分享給其他人知道,希望更多人能知道閉包到底是什麼。或者如果文中有任何錯誤的地方,也請跟我說,歡迎大家一起來討論。

感謝所有看到最後的你們,可以的話也請幫我點個讚吧!謝謝。

參考資料

維基百科 — 何謂閉包

ES3 規格書 — Internal Properties

ES3 規格書 — Entering Execution Context

ES3 規格書 — Creating Function Objects

所有的函式都是閉包:談 JS 中的作用域與 Closure

--

--