JavaScript 筆記:Scope 與 This
在學習 JavaScript 時,兩個最常引起混淆的概念分別是 Scope (範疇) 與 this 關鍵字。Scope 決定了變數在程式中可以被存取的位置,而 this 則代表函式在執行時的呼叫者。理解這兩者能大幅減少開發中遇到的 ReferenceError 與邏輯錯誤。
一、Scope (範疇) 與 Scope Chain
在學習 JavaScript 的初期,會時常出現的情況: 「甚麼!,我明明有宣告的變數為什麼無法透過 console.log() 印出來呢?」後來才知道 Scope (範疇 or 作用域)的觀念有多重要,深深影響我們在哪裡可以 & 不可以存取變數,有了這個觀念就可以大幅減少 console 中出現 Reference error 的悲劇。
Scoping 基本觀念
Scope 定義了變數在程式中的可見範圍。JavaScript 採用 Lexical Scoping (語法作用域),也就是變數能否被存取取決於它被宣告的位置,而非函式被呼叫的時機。
在 JavaScript 的三種範疇種類
1. Global Scope
簡單來說,就是在 function 及 block 以外所宣告的變數。
2. Function Scope
在函式內部所宣告的變數,僅能在該函式內部被存取,此規則在函式宣告 (function declaration) 、函式表示式 (function expression) 、函式箭頭式 (arrow function) 都成立。
3. Block Scope (ES6)
Block Scope 是從 ES2015 開始才有的新規則,意思是在大括號(像是 if 或 for 的大括號)裡用 let 或 const 宣告的變數,只能在那個區塊裡用,外面就看不到。不過用 var 宣告的還是屬於函式範圍。另外,因為有了 Block Scope,在嚴格模式下,函式宣告也會被限制在區塊裡。
NOTE: 物件實字的
{}不屬於 Block Scope。
Scope Chain
什麼是 Scope Chain 呢?簡單來說,我們可以把它想像成一條單行道:當我們在某個 Scope 裡找不到變數時,JavaScript 會一路往外層 Scope 找下去(只能往外找,不能回頭!)。這就是 Scope Chain 的魔法~像下面的範例,雖然有很多 Function Scope 和 Block Scope,只要沒有重複宣告,內層 Scope 都能順利拿到外層的變數。


當 Scope 內外有同名變數時,例如在 first() 內用 const 宣告 firstName="Rick",而在 if 區塊內又用 const 宣告 firstName="John",這時在 if 區塊中印出 firstName,結果會是 "John" 而不是 "Rick"。這是因為 JavaScript 會沿著 Scope Chain 由內而外尋找變數,當在最內層(if 區塊)就找到 firstName,就不會再往外層尋找,直接使用該值。
Scope Chain VS. Execution Stack
Scope Chain 跟 Execution Context 堆疊其實是兩回事,變數能不能被存取,重點在於我們在哪裡宣告它,而不是函式是怎麼被呼叫的。至於 Execution Context 堆疊,則是根據函式呼叫的順序來排隊。下面這段程式碼可以幫我們更直觀地理解兩者的差別~
執行這段程式碼時,Execution Context 會依序堆疊為 first() EC → second() EC → third() EC。然而,在 third() 內部卻無法存取 a 和 b,這是因為變數的可見範圍(Scope)取決於宣告的位置,而非函式的呼叫順序。即使 third() 是由 second() 進而由 first() 呼叫,third() 也只能存取其自身及外層(全域)作用域中宣告的變數,這正是 Lexical Scoping 的核心:變數能否被存取,關鍵在於它們被宣告在哪個作用域。
經典題目:var vs let 在 setTimeout 中的差異
差異原因:
-
var版本: 由於var沒有 Block Scope,變數a屬於函式範疇。當setTimeout的 callback function 執行時,迴圈已經結束,此時a的值為 5,所以所有 callback 都印出 5。 -
let版本:let具有 Block Scope,每次迴圈都會創造一個新的a變數,setTimeout的 callback function 會記住各自迴圈當下的a值。
var 的解決方案:
IIFE 方案
在這段程式中,每次迴圈都會立即呼叫一次 IIFE,將當前的 a 值以參數形式傳入,使得 IIFE 內部建立了一個新的區域變數 i,與外層的 a 相互獨立。接著,setTimeout 的箭頭函式作為閉包,會捕捉並保留對該區域變數 i 的存取權,即使 IIFE 已經執行完畢,callback 仍能正確使用當時的 i 值,因此最終能依序輸出 0 到 4。
二、This
this 這個主題在 JavaScript 中大概是最熱門及被討論的主題之一,在使用上有它的實用性,但也是最容易被誤解的觀念,這篇就針對 JavaScript 的 this 記錄學習筆記
基本觀念
如下方程式碼,當物件呼叫自己裡面的函式時,this 會指向這個物件本身。也就是說,當我們用 obj 來呼叫 test 函式時,this 就代表 obj 這個物件。
Regular Function 的 this
只有在物件方法中,this 才會指向該物件(owner)。如果函式不是物件的方法,this 在 strict mode 下為 undefined,否則為 window。
看下面的程式碼:如果我們把 objA 裡的函式指定給 objB,然後用 objB 來呼叫這個函式,這時 this 會指向 objB,也就是呼叫它的物件。
如果把 objA 的函式存到外面的變數 v,然後直接用 v() 呼叫,這時在 strict mode 會報錯,因為 this 是 undefined。不是 strict mode 時,this 會變成 window,但 window 沒有 firstName 和 favorite,所以結果會是 undefined。
Arrow Function 的 this
箭頭函式 沒有自己的 this,它會繼承外層函式的 this。
上面的程式碼中,在 test 函式裡面又宣告了一個箭頭函式 test2,它印出來的 this 會是 obj。因為箭頭函式的 this 會跟外層的 test 一樣,所以這裡就是 obj。
Regular Function vs Arrow Function
簡單來說,一般函式裡的 this 會指向呼叫它的那個物件。箭頭函式的 this 則不會自己決定,而是直接用外層的 this。像下面的例子,regularObj 用一般函式,this 就是 regularObj;而 arrowObj 用箭頭函式,this 不是 arrowObj,而是外層(在這裡是 window 或 undefined)。
巢狀函式中的困境與解決辦法
下方程式碼中, this 無法在 isAdult 中顯示,因為 isAdult 被宣告在 calcAge 內部,其內部的 this 會是 undefined(strict mode) ,然而有兩種辦法可以解決這樣的困境。
Solution 1
在 isAdult 外部先將 this 傳至另一個變數 self ,再從 isAdult 內部呼叫 self 即可解決。
Solution 2
將 isAdult 改為箭頭函式,因為在內部的 this 會指向 calcAge 的 this ,也就是 obj 本身。
JavaScript 中的內建函式 (Call, Bind, Apply)
物件之間,可以透過 call, bind, apply 來調用自身沒有的函式,並綁定this keyword。
如下方程式碼,call 和 apply 都會馬上執行函式,第一個參數是我們想指定的 this。差別在於 call 直接傳入參數,而 apply 則是把參數放在一個陣列裡。
bind 跟 call/apply 的差別:
bind 不會立即執行函式,而是回傳一個新的函式,讓我們可以存到變數裡之後再呼叫。
三、Scope 與 This 的交互關係
- Scope 決定變數的可見性。
- this 代表函式的呼叫上下文。
- Arrow Function 的 this 取決於外層 Scope,而 Scope Chain 決定變數是否可存取。
重點回顧
- JavaScript 有三種 Scope:Global、Function、Block。
- Scope Chain 由內而外尋找變數,與函式呼叫順序無關。
- Regular Function 的 this 取決於呼叫方式;Arrow Function 的 this 取決於外層 Scope。
- 嚴格模式下,獨立呼叫 Regular Function 的 this 為 undefined。
- 巢狀函式可透過
self = this或 Arrow Function 解決 this 綁定問題。