# 第五章 大師級函式:閉包與範圍 ###### tags: `好想工作室`、`忍者讀書會` ## 5.1 瞭解閉包 #### ▌Scope(範圍、範疇、作用域) **Scope就是變數可以被看見、被使用的範圍。** ![](https://i.imgur.com/NYaWr5W.jpg) * **Global Scope** 全域作用域,變數不在function或block裡面,而是在全域中宣告,因此任何地方都能使用該變數。 ```javascript! var totalEggs = 6 function collectEggs(){ console.log(totalEggs) //6 } collectEggs() ``` * **Local Scope** 又分為兩種區域作用域:Function Scope、Block Scope(ES6) * **Function Scope** function有自己的作用域,所以在function裡面宣告變數,在function之外的地方都沒辦法使用。 ```javascript! function collectEggs(){ var totalEggs = 6 } console.log(totalEggs) //totalEggs is not defined ``` 即便先執行了這個function,外面仍然無法取得function內部宣告的變數。 ```javascript! function collectEggs(){ var totalEggs = 6 } collectEggs() console.log(totalEggs) //totalEggs is not defined ``` 這是因為function執行時會進入call stack,這裡的變數totalEggs會存到stack memory中(stack memory是用來保存由函式所產生的暫時性變數,或稱區域變數),stack memory使用的是暫存性的記憶體空間,所以當function執行結束之後,這些記憶體就會跟著消失。 (如下圖流程所示) ![](https://i.imgur.com/HZWAVEK.jpg) ![](https://i.imgur.com/4BBKABQ.jpg) ![](https://i.imgur.com/GBPza2H.jpg) ![](https://i.imgur.com/TuHxpIC.jpg) * **Block Scope(ES6)** ES6之前,只有function可以建立scope。 ES6之後,出現了let、const宣告,使得我們可以用「大括號」建立scope。(大括號範圍就是block,例如if判斷式、while或for迴圈) #### ▌程式列表5.1 一個簡單的閉包 ```javascript! var outerValue = "ninja"; function outerFunction() { assert(outerValue === "ninja","I can see the ninja."); } outerFunction(); ``` 如果用上面scope的概念來看,會覺得`outerValue === "ninja"`為true,這是再正常不過的事情,因為`outerValue`是在全域中宣告,所以任何地方都可以使用到這個變數。 但事實上`outerFunction()`能看到、使用自己function scope之外的變數,其實就是在建立一個閉包。 我們在看下個複雜點的例子。 #### ▌程式列表5.2 另一個閉包的例子 ```javascript! var outerValue = "samurai"; var later; function outerFunction() { var innerValue = "ninja"; function innerFunction() { assert(outerValue === "samurai", "I can see the samurai."); assert(innerValue === "ninja", "I can see the ninja."); } later = innerFunction; } outerFunction(); later(); ``` **(我們所想的)** 就像前面提到的,當執行一個function,變數若是在function scope裡面建立的,會被存放到stack memory,一個暫存性的記憶體空間。 所以說當`outerfunction()`一執行結束,照理說`innerValue = "ninja"`也會被消失才對,因此我們會預期第二個檢查失敗。 ![](https://i.imgur.com/1AU52rK.jpg) 不過...... ![](https://i.imgur.com/t5SFHPu.png) **(事實上)** 我們執行`innerFunction()`的時候,還是可以抓到`innerValue`這個變數。所以到底是什麼原因讓`innerValue`這個變數仍然是活的?答案就是閉包。 當我們在`outerfunction()`裡宣告`innerFunction()`時,做了兩件事情: 1. 定義了一個函式宣告`innerFunction()` 2. 建立了一個閉包: * 閉包包含了函式定義`innerFunction()`以及在建立函式時存在於作用範圍內的所有參數。 * 如下圖,閉包就像一個保護用的氣泡,只要函式仍然存在,`innerFunction()`的閉包就會讓函式範圍內的變數一直保持在有效狀態下。 ![](https://i.imgur.com/CHabfzV.jpg) 如果用程式碼來看他們之間的關係,就會如下圖。因此如果要用一句話來形容什麼是閉包?可以套用[techsith教學影片](https://youtu.be/71AtaJpJHw0?t=705)的一段話來形容:"Closures are nothing but FUNCTIONS WITH PRESERVED DATA",閉包就是函式包含該函式所保留的資料。 可以想像就是,當一個function使用到了自己function scope之外的變數,這個function包含使用到了的變數,就會形成一個閉包。 ![](https://i.imgur.com/9ic0mem.jpg) 那這裡再稍微補充一下! **(以下觀念來自[胡立的文章](https://blog.huli.tw/2018/12/08/javascript-closure/))** 上面提到「function使用到了自己function scope之外的變數」,其實這樣的變數有一個特殊的名字。 對於`innerFunction()`這個function來說,`innerValue`或者`outerValue`都不是它自己的變數,而這種不在自己作用域中,也不是被當成參數傳進來的變數,就可以被稱作「free variable(自由變數)」。 所以對`innerFunction()`來說,`innerValue`以及`outerValue`都是自由變數。 另外,一個function在自己的作用域中找不到變數,就會往外面一層的作用域尋找,如果還是找不到,就會再往上一層直到找到為止(如果到全域還是找不到就會拋出錯誤),這個過程就會構成一個「Scope Chain(作用域鏈)」。 **(以下觀念來自[Fireship的影片](https://www.youtube.com/watch?v=vKJpN5FAeF4&list=PLJ1djWumFFcno2X_dXvfjupsdpHxzc55k&index=2&t=122s))** 上面有提到說「閉包就會讓函式範圍內的變數一直保持在有效狀態下」,這是因為當closure建立,會將那些包含在閉包內的變數,給存放到heap memory。 ![](https://i.imgur.com/x7uJJGa.jpg) 與stack memory不同,stack memory是function執行結束就會把記憶體空間給釋放,而heap memory則是function執行結束後,裏頭存放的東西仍然存在,直到我們去做清除的動作。(至於如何清除,不同語言有不同的方法,詳情可見[Stack vs. Heap](https://medium.com/joe-tsai/stack-vs-heap-b4bd500667cd)) 所以說closure的缺點就在這邊,它會相對地比較佔記憶體空間。 **** ## 5.2 開始使用閉包 在5.2章節中,我們會來看看如何在JavaScript中使用閉包。 (分別為模擬私有變數、處理回呼) ### 5.2.1 模擬私有變數 JavaScript本身不支援私有變數,但可以藉由閉包來做出類似的功能。 #### ▌程式列表5.3 使用閉包來模擬私有變數 ```javascript! function Ninja() { var feints = 0; this.getFeints = function(){ return feints; }; this.feint = function(){ feints++; }; } var ninja1 = new Ninja(); ninja1.feint(); assert(ninja1.feints === undefined, "And the private data is inaccessible to us."); assert(ninja1.getFeints() === 1, "We're able to access the internal feint count."); var ninja2 = new Ninja(); assert(ninja2.getFeints() === 0, "The second ninja object gets it’s own feints variable."); ``` **分析** 這裡建立了一個建構器函式:Ninja。 ```javascript! function Ninja() { var feints = 0; this.getFeints = function(){ return feints; }; this.feint = function(){ feints++; }; } ``` * `feints` 變數:,用來紀錄狀態。 * `getFeints` 方法:因為變數`feints`在`Ninja()`function裡面被宣告,所以只能在這個constructor function裡面被使用、存取, `getFeints` 方法便是為了允許範圍之外的程式碼可以存取到這個變數的值,而有了`getFeints`讀值器的方法。 * `feint` 方法:用來控制變數的值,在本例中,此方法可以增加`feints`的值。 接著我們便可以新建立ninja1物件,並且在此物件上呼叫`feint`方法,呼叫`feint`方法時,它會增加ninja1的`feints`值。 ```javascript! var ninja1 = new Ninja(); ninja1.feint(); assert(ninja1.feints === undefined, "And the private data is inaccessible to us."); assert(ninja1.getFeints() === 1, "We're able to access the internal feint count."); ``` 接著從測試中的結果可知: 1. `ninja1.feints === undefined`為true,代表我們無法從外部存取函式內部的變數。 2. `ninja1.getFeints() === 1`為true,代表雖然我們無法從外部取值,但還是有辦法更改`feints`這個私有變數的值。 而最後,我們再使用Ninja建構器建立一個ninja2物件。 ```javascript! var ninja2 = new Ninja(); assert(ninja2.getFeints() === 0, "The second ninja object gets it’s own feints variable."); ``` 可以從測試中得知新建立的ninja2物件,它有屬於自己的feints變數。 **結論** 利用閉包可以讓忍者的狀態被保持在一個方法中,而無法被使用者直接存取。因為閉包讓內部方法可以存取變數,但是位於建構器函式外部的程式碼則不行。 這個方法讓`feints`變數,就像是一個真正的私有變數一樣。 ![](https://i.imgur.com/FBbDybx.jpg) ### 5.2.2 伴隨回呼來使用閉包 閉包的另一個常見用途式處理回呼,也就是函式在稍後的某個時間點被呼叫。通常在這些函式裡,我們需要時常存取外部資料。 #### ▌Callback function 回呼函式 回呼函式就是把B函式當作A函式的參數,透過A函式來呼叫B函式,而這個被當作參數帶入的B函式將在「未來的某個時間點」被呼叫與執行(是一種非同步事件的一種方式) 舉例來說,要進入霍格華茲的巫師「被叫到名字」後要「上前戴上分類帽」,「戴上分類帽」後就會被「分學院」。 ![](https://i.imgur.com/aeFuSqH.jpg) 如果拿程式碼來說明就會像下面那樣: ```javascript= wizard.addEventListener("被叫到名字" ,function(){ 戴上分類帽; wizard.addEventListener("戴上分類帽" ,function(){ 分學院; }) }) ``` 我們對巫師做事件監聽,當巫師被叫到名字後,才會執行事件監聽第二個參數中的function。所以`戴上分類帽`這個動作只會在滿足了`被叫到名字`這個條件才會被動地去執行,我們就可以說這是一個 Callback function。 Callback function常見例子有setTimeout()、setInterval()、DOM 的事件監聽、從資料庫或遠端伺服器請求資料等等。 接著忍者裡面的範例會使用setTimeout()來做說明。 #### ▌程式列表5.4 在回呼計時器裡使用閉包 ```javascript! function animateIt(elementId) { var elem = document.getElementById(elementId); var tick = 0; var timer = setInterval(function(){ if (tick < 100) { elem.style.left = elem.style.top = tick + "px"; tick++; } else { clearInterval(timer); } }, 10); } animateIt("box1"); ``` 上面範例利用了closure的方式達到製作動畫的效果,它使用了一個匿名函式做為setInterval的參數,來完成目標div元素的動畫效果。這個匿名函式藉由閉包來存取三個變數:elem、tick、timer。 * elem:ID為"box1"的DOM元素參照 * tick:片刻計數器 * timer:計時器setInterval的參照 接著,來修改一下上述範例: * [第一個codepen範例](https://codepen.io/fgfjgror/pen/GRGjvyM?editors=1011): 現在如果將這三個變數都移出`animateIt()`函式外面,讓這三個變數變成全域變數,動畫仍然可以順利執行的。 代表說我們可以不必利用closure,就能做到一模一樣的效果,所以為什麼還要建立閉包?這就要看到第二個範例了。 * [第二個codepen範例](https://codepen.io/fgfjgror/pen/poKErBX) 如果我們將變數儲存在全域範圍中,我們每增加一個動畫,就要為該動畫設置三個變數,因此如果我們藉著函式內部定義變數,在計時器進行回呼時透過閉包讓那些變數可以被使用,每當我們呼叫`animateIt()`,每個動畫都可以獲得私有變數「氣泡」。 ![](https://i.imgur.com/DwQy2QV.jpg) ![](https://i.imgur.com/wIq7DEe.jpg) **** ## 5.3 使用執行背景空間追蹤程式執行 * 在JS中,函式是最基本的執行單元 * JS程式: 1. **全域程式**:放置在所有函式之外 2. **函式程式**:被包含在函式之中 ![](https://i.imgur.com/oHgcVVf.jpg) * 當我們的程式被JS引擎執行的時候,每個敘述句都在一個特定的++執行背景空間++中執行。 * ++執行背景空間++: 1. **全域執行背景空間**:只會有一個全域執行背景空間,它是在我們的JS程式開始執行時建立的。 2. **函式執行背景空間**:每次呼叫函式就會建立一個新的函式執行背景。 :::warning * 注意:函式背景空間 vs. 函式執行背景空間 | 名稱 | 解釋 | | ---------------- | ------------------------------------------------------ | | 函式背景空間 | 函式被呼叫時所屬的物件,可以使用this關鍵字來存取它 | | 函式執行背景空間 | 這是JS引擎所使用的一種內部概念,並用它來追蹤函式的執行 | ::: * JS是單執行緒的執行模型(Single Threaded),也就是說他一次只能執行一段程式碼 * 每當一個函式被呼叫時,都必須停止當前的執行背景空間,然後再建立一個新的函式執行背景空間,當函式的任務完成後,它的函式執行背景空間通常就會被丟棄,並恢復到原本呼叫者的執行背景空間。 ---> 所有的執行背景空間都需要進行追蹤(無論執行中、等待中) ---> 利用堆疊(stack)追蹤,它被稱為執行背景空間堆疊(或稱呼叫堆疊,也就是在筆記5.1提到的call stack) 直接來看看程式碼範例。 #### ▌程式列表5.5 建立執行背景空間 ```javascript! function skulk(ninja) { report(ninja + " skulking"); } function report(message) { console.log(message); } skulk("Kuma"); skulk("Yoshi"); ``` 上述範例在執行背景空間的堆疊行為如下圖,但是用看的,不如用chrome的dev tool來看看它實際的行為。(這裡直接用dev tool去操作程式列表5.5) ![](https://i.imgur.com/c3BhMUc.jpg) **** ## 5.4 使用字彙環境來追蹤識別項 * 字彙環境(Lexical Scope):JS引擎的一個內部結構,用於追蹤從++識別項++到特定變數的對應(mapping) * ++識別項++(identifier):(參考[Chidre'sTechTutorials影片](https://www.youtube.com/watch?v=UzKMBLWeQ-A)) 舉例來說:變數的名稱、常數的名稱、陣列的名稱、函式的名稱、物件的名稱等等,都可以叫做識別項。 :::warning * 注意: 字彙環境是JS作用範圍界定機制的內部實作,而人們經常會在口語上將他們稱為Scope(範圍、範疇、作用域) ::: * 字彙環境(或者就叫它scope)與JS程式碼的特定結構相關聯,這些特定結構包含:一個函式、一段程式區塊、catch區塊,他們都可以有個別的識別項對應。 :::warning * 注意: * ES6前:字彙環境只可以與函式相關聯(所以只有function scope) * ES6後:有了block scope的存在 ::: ### 5.4.1 巢狀程式(code nesting) * 字彙環境主要基於巢狀程式,也就是一個程式碼結構能夠被包含另一個程式碼結構中(如下圖) ![](https://i.imgur.com/QqxDNfa.jpg) * 每一次程式碼被執行時,每一個程式碼結構都獲得相關的字彙環境。 * 字彙環境的規則(或稱scope的規則): 「外層 Scope 無法取用內層變數,但內層 Scope 可以取用外層變數」 接著就要來看JS引擎是如何追蹤這些變數?我們又可以從哪裡存去這些變數?這都是透過字彙環境所達成的。 ### 5.4.2 巢狀程式與字彙環境 ![](https://i.imgur.com/Y6YLMwp.jpg) * report函式由skulk函式呼叫 * skulk函式由全域環境呼叫 每個執行背景空間具有與其相關聯的字彙環境,它包含了在該背景空間中所定義的所有識別項對應,例如: * 全域環境:保留了識別項ninja、skulk的對應 * skulk環境:保留了識別項action、report的對應 * report環境:保留了識別項intro的對應 ![](https://i.imgur.com/Uo68i1Y.jpg) 執行report函式時,JS引擎解析識別項的步驟: * 尋找intro: 1. 檢查report環境 -> <font color="green">有</font> * 尋找action: 1. 檢查report環境 -> <font color="red">無</font> 2. 檢查report的外部環境:skulk 檢查skulk環境 -> <font color="green">有</font> * 尋找ninja: 1. 檢查report環境 -> <font color="red">無</font> 2. 檢查report的外部環境:skulk 檢查skulk環境 -> <font color="red">無</font> 4. 檢查skulk的外部環境:global 檢查global環境 -> <font color="red">無</font> 除了存取對應的字彙環境中定義的識別項之外(例如report環境保留了識別項intro的對應),程式也常常存取在外部環境中定義的其他變數(例如report函式裡,存取了skulk的變數action、全域變數nunja) ---> 為了做到這一點,我們必須以某種方式來追蹤這些外部環境,而JS作法則是讓函式做為頭等物件。 每當建立一個函式時,指向該函式所屬字彙環境的參照,會儲存在名為[[Environment]]的內部屬性裡面(表示不能被直接存取或是操作)。 * skulk函式保持對全域環境的參照 * report函式保持對skulk環境的參照 ![](https://i.imgur.com/CFHlQtJ.jpg) **** ## 5.5 瞭解JavaScript的變數類型 * 在JS中,有三種關鍵字可用來定義變數: * var * let * const * 他們在兩個方面有所不同: * 可變性:【const】 vs. 【var、let】 * 字彙環境的關係:【var】 vs. 【const、let】 ### 5.5.1 變數可變性 * 如果用可變性來區分變數宣告的關鍵字,我們會這樣分成兩組:【const】 vs. 【var、let】 * 【const】:所有使用const定義的變數都是不可變的,意味著他們的值只能設置一次 * 【var、let】:典型的普通變數,其值可以根據需求改變許多次 這一個章節主要會來瞭解const變數的運作方式以及行為。 #### ▌const變數 * const變數類似於一般變數,但是它在宣告時就需要提供一個初始值,而在那之後,我們就不能指派新的值給它了。 * const變數常用於兩種目的: * 指定一些不應重新指派的變數(在本書中使用const,多半是這個目的) * 參照到一個固定值 例如,用名稱來表示一個浪人團最多可以由幾位浪人組成:MAX_RONIN_count #### ▌程式列表5.6 const變數的行為 ```javascript! const firstConst = "samurai"; assert(firstConst === "samurai", "firstConst is a samurai"); try { firstConst = "ninja"; fail("Shouldn't be here"); } catch (e) { pass("An exception has occured"); } assert(firstConst === "samurai", "firstConst is still a samurai!"); const secondConst = {}; secondConst.weapon = "wakizashi"; assert(secondConst.weapon === "wakizashi", "We can add new properties"); const thirdConst = []; assert(thirdConst.length === 0, "No items in our array"); thirdConst.push("Yoshi"); assert(thirdConst.length === 1, "The array has changed"); ``` **分析** 1-1. 先來定義一個const變數,並確認有指派一個值給它 ```javascript! const firstConst = "samurai"; assert(firstConst === "samurai", "firstConst is a samurai"); ``` 1-2. 若企圖指派一個新值給const變數,就會發生異常 ```javascript! try { firstConst = "ninja"; fail("Shouldn't be here"); } catch (e) { pass("An exception has occured"); } ``` / 2-1. 建立一個新的constant變數,並指派一個物件給它 ```javascript! const secondConst = {}; ``` 2-2. 我們無法指派一個全新的物件給secondConst變數,但可以修改它 ```javascript! secondConst.weapon = "wakizashi"; assert(secondConst.weapon === "wakizashi", "We can add new properties"); ``` 若在此處指派一個全新的物件給secondConst變數(如下方所示) ```javascript! const secondConst = {}; secondConst = { weapon: "wakizashi" } ``` 將得到以下錯誤訊息 ![](https://i.imgur.com/c583d47.png) / 3-1. 上述規則也適用於陣列。先建立一個新的constant變數,並指派一個陣列給它,且測試確認thirdConst為一個空陣列。 ```javascript! const thirdConst = []; assert(thirdConst.length === 0, "No items in our array"); ``` 3-2. 一樣無法指派全新的陣列給thirdConst變數,但可以修改它 ```javascript! thirdConst.push("Yoshi"); assert(thirdConst.length === 1, "The array has changed"); ``` **結論** 1. const變數的值只能在初始化時設置,不能在之後指派一個全新的值給它 2. 可以修改現有的值,只是不能完全複寫它 ### 5.5.2 用來定義變數的關鍵字和字彙環境 * 如果用根據他們與字彙環境的關係(i.e.他們的scope)來區分變數宣告的關鍵字,我們會這樣分成兩組:【var】 vs. 【const、let】 #### ▌使用var關鍵字 使用var關鍵字時,變數是定義在最鄰近的函式或全域字彙環境中(程式區塊block scope會被忽略!畢竟它是ES6後才出現的,比var還晚出生) #### ▌程式列表5.7 使用var關鍵字 ```javascript! var globalNinja = "Yoshi"; function reportActivity(){ var functionActivity = "jumping"; for(var i = 1; i < 3; i++) { var forMessage = globalNinja + " " + functionActivity; assert(forMessage === "Yoshi jumping", "Yoshi is jumping within the for block"); assert(i, "Current loop counter:" + i); } assert(i === 3 && forMessage === "Yoshi jumping", "Loop variables accessible outside of the loop"); } reportActivity(); assert(typeof functionActivity === "undefined" && typeof i === "undefined" && typeof forMessage === "undefined", "We cannot see function variables outside of a function"); ``` **分析** 1-1. 使用var來定義一個全域變數globalNinja為Yoshi ```javascript! var globalNinja = "Yoshi"; ``` 2-1. 在函式內使用var定義一個區域變數functionActivity為jumping 2-2. 在for loop裡使用var來定義兩個變數i、forMessage 2-3. 在for loop中測試我們可以存取到外面的區塊變數、函式變數、全域變數 2-4. 但在for loop外,也可以存取到for loop內的變數 ![](https://i.imgur.com/Vs0EsYt.jpg) 3-1. 在函式之外無法存取函式裡面的變數 ```javascript! assert(typeof functionActivity === "undefined" && typeof i === "undefined" && typeof forMessage === "undefined", "We cannot see function variables outside of a function"); ``` ![](https://i.imgur.com/Ce82BWm.jpg) 而上述程式碼中,有一段是JS的奇怪之處,就是在2-4測試的部分。我們竟然可以在區塊之外,繼續存取區塊內所定義的變數。 ---> 這是因為使用關鍵字var宣告的變數,總是會「註冊在最鄰近的函式或全域環境中」,而無視程式區塊的結構。 ![](https://i.imgur.com/qbPM4jr.jpg) block scope被忽視後,var定義的變數就會去找「最鄰近的函式或全域環境」,以上述程式碼為例,即reportActivity環境中(因為它是最鄰近的函式環境) ![](https://i.imgur.com/fcozIPe.jpg) 從上圖可見這裡擁有三個字彙環境: 1. 全域環境:註冊globalNinja變數的環境(因為這是最鄰近的函式或是全域字彙環境) 2. reportActivity環境: * 呼叫reportActivity函式時建立的 * 環境中包含的變數:(因為這是他們最鄰近的函式) * functionActivity * i * forMessage 3. for區塊:它是空的(因為用var定義的變數會忽略程式區塊) 由於這種行為非常奇怪,所以在ES6版本的JS提供了兩個新的變數宣告關鍵字。 #### ▌使用let和const來指定區塊範圍內的變數 使用let和const關鍵字,他們會把變數定義在最接近的字彙環境中(可以是區塊環境、迴圈環境、函式環境、全域環境)。 #### ▌程式列表5.8 使用關鍵字let和const ```javascript! const globalNinja = "Yoshi"; function reportActivity(){ const functionActivity = "jumping"; for(let i = 1; i < 3; i++) { let forMessage = globalNinja + " " + functionActivity; assert(forMessage === "Yoshi jumping", "Yoshi is jumping within the for block"); assert(i, "Current loop counter:" + i); } assert(typeof i === "undefined" && typeof forMessage === "undefined", "Loop variables not accessible outside the loop"); } reportActivity(); assert(typeof functionActivity === "undefined" && typeof i === "undefined" && typeof forMessage === "undefined", "We cannot see function variables outside of a function"); ``` ![](https://i.imgur.com/YbWoyyV.jpg) 從上圖可見這裡擁有三個字彙環境: 1. 全域環境:globalNinja變數定義在全域環境中 3. reportActivity環境:functionActivity變數定義在reportActivity環境中 4. for區塊:i變數、forMessage變數定義在for區塊中 這是因為使用let和const關鍵字,變數會被定義在最鄰近的環境中。 ### 5.5.3 在字彙環境中註冊識別項 首先我們知道JS是以滿直接的方式逐行執行的,再來看看以下例子: ```javascript! const firstRonin = "Kiyokawa"; check(firstRonin); function check(ronin) { assert(ronin === "Kiyokawa", "The ronin was checked!"); } ``` 如果說程式碼是逐行執行,那還沒執行到定義函式的地方,能夠呼叫到check函式嗎? ![](https://i.imgur.com/jE77Yt6.png) 測試結果還是可以的!不過程式碼是逐行執行的,JS引擎是怎麼知道有一個check函式的存在? #### ▌註冊識別項的過程 這是因為JS引擎使用了一點小手段,JS程式碼的執行其實分成了兩個階段: ![](https://i.imgur.com/UV25Xxv.jpg) 其確切行為取決於變數類型、環境類型: * 變數類型:let、var、函式宣告 * 環境類型:全域、函式、區塊 識別項在不同環境下的註冊過程:(JS程式碼的執行第一階段) ![](https://i.imgur.com/Sgxo0oO.jpg) 1. 第一步驟:如果我們建立一個函式環境,伴隨著的「函式參數」、「其引數值的隱含式arguments識別項」,會被建立出來。 2. 第二步驟:如果我們建立的式一個全域環境或是函式環境,會先掃描目前的程式碼,找出所有的「函式宣告」(不包含函式表達式、箭頭函式),針對發現的每個函式宣告,都建立一個新的函式,並將它綁定到該環境中的同名識別項上。 3. 第三步驟:找出「變數宣告」。 #### ▌在函式宣告前呼叫它 讓JS如此好用的一項特色,是函式定義的順序無關緊要。 #### ▌程式列表5.9 在函式宣告前對它進行存取 ```javascript! assert(typeof fun === "function", "fun is a function even though its definition isn’t reached yet!"); assert(typeof myFunExp === "undefined", "But we cannot access function expressions"); assert(typeof myLamda === "undefined", "Nor lambda functions"); function fun(){} var myFunExpr = function(){}; var myLambda = (x) => x; ``` 先別管測試,用簡單一點的方式來看上面的程式碼 ```javascript! typeof fun //function typeof myFunExp //undefined typeof myLamda //undefined function fun(){} //在執行JS程式碼之前,就會先註冊已宣告的函式 var myFunExpr = function(){}; //myFunExpr指向一個函式表達式 var myLambda = (x) => x; //myLambda指向一個箭頭函式 ``` * fun函式在執行JS程式碼之前,就已經先註冊了(已存在),因此能夠在不同地方呼叫它 * 函式表達式、箭頭函式不在此過程中,他們是在程式執行到其定義位置時才建立的,所以無法在他們建立之前存取這兩種函式。 #### ▌複寫函式 函式識別項可能會被複寫。 #### ▌程式列表5.10 複寫函式識別項 ```javascript! assert(typeof fun === "function", "We access the function"); var fun = 3; assert(typeof fun === "number", "Now we access the number"); function fun(){} assert(typeof fun == "number", "Still a number"); ``` 一樣先不管測試,比較方便觀察 ```javascript! typeof fun //function var fun = 3 //執行JS之前即註冊(註冊識別項的第三步驟:處理變數宣告) typeof fun //number function fun(){} //執行JS之前即註冊(註冊識別項的第二步驟:處理函式宣告) typeof fun //number ``` * 在此範例中,變數宣告和函式宣告都具有相同的名稱:fun * JS程式碼執行前,發生兩件事情: 1. `function fun(){}`:在註冊識別項的第二步驟,識別項fun以函式宣告被註冊。 2. `var fun = 3`:在註冊識別項的第三步驟,開始處理變數宣告,此時它會將數字3指派給識別項fun,這讓我們的識別項fun失去了對函式的參照,現在識別項fun變成了一個數字。 以上如果用簡化的觀點來看,就是常看到的一個術語「提升」(hoisting),但在技術上來說,變數和函式宣告並不會「移動」到任何地方,他們是在任何程式碼執行之前,在字彙環境中就先被取得並進行註冊。 **** ## 5.6 探索閉包的運作方式 ### 5.6.1 重新檢視如何使用閉包來模擬私有變數 #### ▌程式列表5.11 使用閉包來產生近乎私有的變數 ```javascript! function Ninja() { var feints = 0; this.getFeints = function(){ return feints; }; this.feint = function(){ feints++; }; } var ninja1 = new Ninja(); ninja1.feint(); var ninja2 = new Ninja(); ``` 回顧一下5.2章節,`feint`函式要存取`feints`變數,因此這裡就產生了閉包 ![](https://i.imgur.com/FBbDybx.jpg) 再來看看整體的部分,建構器函式是使用關鍵字new來呼叫的函式,因此每當我們呼叫一個建構器函式時,就等於建立了一個新的字彙環境,它會負責追蹤建構器函式的區域變數(以上述程式碼為例,即追蹤`feints`變數的新Ninja環境會被建立出來) 另外我們建立了兩個新函式:`getFeints`、`feint`,他們都具有對Ninja環境的參照。 ![](https://i.imgur.com/0FOu7X3.jpg) 當我們建立另一個Ninja物件:ninja2時,整個過程會再重複一遍。使用Ninja建構器所建立的每個物件都會得到屬於自己的方法(ninja1.getFeints()方法不同於ninja2.getFeints()方法) 他們把呼叫建構器函式時所定義的變數封閉起來,這些「私有」變數只能透過建構器函式中建立的物件方法(`getFeints`、`feint`方法)進行存取,而不能直接存取! ![](https://i.imgur.com/BKl0tOx.jpg) 接著看看呼叫ninja2.getFeints()時到底發生哪些事? 1. 當呼叫函式時,會建立一個新的執行背景空間。所以這裡建立了一個getFeints執行背景空間,並將其推送到執行堆疊。 ![](https://i.imgur.com/0M6OHhy.jpg) 2. 同時也建立了一個getFeints字彙環境,用來追蹤在此函式中定義的變數。 ![](https://i.imgur.com/sfAQphU.jpg) 3. getFeints字彙環境會取得建立getFeints函式時所屬的環境,也就是建立ninja2物件時有效的Ninja環境作為其外部環境。 ![](https://i.imgur.com/ZXZH8La.jpg) 4. 現在常是取得feints變數值,會先查詢當前活動中的getFeints字彙環境,由於我們沒有在getFeints函式中定義任何變數,所以這個字彙環境是空的。 ![](https://i.imgur.com/GrqA2bH.jpg) 5. 接下來會往當前字彙環境的外部環境搜尋,也就是Ninja環境,我們可以在這個字彙環境中找到feints變數,就搜尋完成了。 ![](https://i.imgur.com/ZrOnS4x.jpg) ### 5.6.2 留意私有變數 在JS中,可以把在一個物件上建立的屬性再指派給另一個物件。 #### ▌程式列表5.12 私有變數要透過函式來存取,而不是透過物件! ```javascript! function Ninja() { var feints = 0; this.getFeints = function(){ return feints; }; this.feint = function(){ feints++; }; } var ninja1 = new Ninja(); ninja1.feint(); var imposter = {}; imposter.getFeints = ninja1.getFeints; assert(imposter.getFeints () === 1, "The imposter has access to the feints variable!"); ``` 這裡利用`imposter.getFeints = ninja1.getFeints;`,將ninja1.getFeintsz方法指派給一個全新的imposter物件,而且當我們再imposter物件上呼叫getFeints函式時,是可以存取到ninja1的變數feints值。 這個例子說明了JS中沒有物件是私有變數的,但是我們透過物件方法來建立閉包,來作為替代方案(做出類似私有物件的變數)。 ![](https://i.imgur.com/BUqkjdR.jpg) ### 5.6.3 重新檢視閉包和回呼範例 #### ▌程式列表5.13 在timer回呼函式中使用閉包 ```javascript! function animateIt(elementId) { var elem = document.getElementById(elementId); var tick = 0; var timer = setInterval(function(){ if (tick < 100) { elem.style.left = elem.style.top = tick + "px"; tick++; } else { clearInterval(timer); assert(tick === 100, "Tick accessed via a closure."); assert(elem, "Element also accessed via a closure."); assert(timer, "Timer reference also obtained via a closure." ); } }, 10); } animateIt("box1"); animateIt("box2"); ``` 1. 每次呼叫animateIt函式時,都會建立一個新的函式字彙環境。(這邊有兩個animateIt函式,所以就建立了兩個字彙環境) ![](https://i.imgur.com/zr44fxJ.jpg) 2. 上述新建立的兩個字彙環境,都追蹤了動畫一組重要的變數:elementId、elem(進行動畫處理的DOM元素)、tick(目前的片刻數)、timer(執行動畫的計時器ID值) ![](https://i.imgur.com/OvRY8yn.jpg) 3. 此例中,瀏覽器會讓setInterval裡的回呼函式一直持續著,直到我們呼叫clearInterval函式。 4. 到了指定的間隔時間,瀏覽器會呼叫對應的回呼函式,並且藉著閉包來存取在建立回呼時所定義的變數 ---> 藉由建立多個閉包,便能夠一次做出許多事情 ---> 每當有計時器到期,回呼函式會喚醒建立時所在的環境 ---> 每次回呼的閉包都會自動追蹤自己所擁有的變數 ![](https://i.imgur.com/sJ8LNHZ.jpg) ## 5.8 習題 **** ## 參考資料 * [[CS] 堆疊和堆積(Stack Memory and Heap Memory)](https://pjchender.dev/computer-science/cs-stack-heap/) * ==推== [Closures Explained in 100 Seconds // Tricky JavaScript Interview Prep](https://www.youtube.com/watch?v=vKJpN5FAeF4&list=PLJ1djWumFFcno2X_dXvfjupsdpHxzc55k&index=2&t=121s) * ==推== [Huli - 所有的函式都是閉包:談 JS 中的作用域與 Closure](https://blog.huli.tw/2018/12/08/javascript-closure/) * [[JavaScript學習系列]什麼是JavaScript的Scope,弄懂Scope的規範,才能避免不必要的bug](https://rollerblade.tw/javascript-scope/) * [Javascript Closure tutorial ( Closures Explained )](https://www.youtube.com/watch?v=71AtaJpJHw0) * [ 9.6: JavaScript Closure - p5.js Tutorial](https://www.youtube.com/watch?v=-jysK0nlz7A&list=PLJ1djWumFFcno2X_dXvfjupsdpHxzc55k&index=1)