JavaScript: 圖解JavaScript規則 - 作用域(鏈)

21.05.01

Photo by Mae Mu on Unsplash

執行環境 Execution Context

JavaScript底層要執行的時候,第一步會先建立一個「執行環境」(Execution Context)。

執行環境內部有許多物件,其中最重要的是「詞彙環境」Lexical Environment(也有翻譯成「詞法環境」)。 詞彙環境主要儲存了:

  1. 全域物件,在有瀏覽器的環境下,指的是window內的物件
  2. 登記這個環境所宣告的變數和function(函式)
  3. 外部環境,也就是Scope Chain,台灣多半翻譯成「作用域(鏈)」,中國則翻譯成「範疇」
  4. 這個環境的”this”是什麼

執行環境解析程式碼的過程中,主要分成「建立階段(Creation Phase)」和「執行階段(Execution Phase)」。

  • 建立階段:建立「詞彙環境」。
  • 執行階段:由上到下、一行一行地執行程式,只會先跳過函式「裡面」的程式碼。畢竟你宣告了函式,不代表你要立刻執行它,可以等到別的事情先做完了,再執行這個函式。

第一個執行環境就是全域執行環境,當全域執行環境建立完,進入執行階段後,一行一行往下跑著,當遇到呼叫A函式時,這個全域執行環境就會為A函式建立一個A函式執行環境,再進入A函式中逐行執行。

若在A函式中又呼叫另一個B函式,就會再建立新的B函式執行環境,並堆疊在A函式執行環境的上方。如果不斷地在函式中呼叫另一個函式,所有執行環境就會不斷往上堆疊,如下面的動畫:

JS會優先處理堆疊(stack)中最上面執行環境。一旦最上面的環境執行完畢,該執行環境就會被移除,以此類推。

前一篇圖解了執行環境與拉升(Hoisting)的關係,這一篇則會著重在執行環境與作用域的關係。

作用域

設置作用域的目的是:若要尋找特定的變數,我可以去哪些地方找?

對執行環境來說,它的工作除了登記這個環境有哪些變數被宣告之外,還會設定「需要存取變數時,我可以在哪裏找到這個變數」。

var或是let/const來宣告的變數,其作用域也會不同。 以下先以var/function來解釋作用域,之後再說明let/const的作用域有什麼不同。

第一個範例:

var a = 'Hello!';
f1();

function f1() {
  var b = 'Kon-nichiwa';
  f2();

  function f2() {
    var c = '你好!';
    f3();
  }
}

function f3() {
  var d = 'Annyeonghaseyo';
  console.log(c);
}

全域執行環境

先從第一個執行環境,也就是全域執行環境說起。 執行環境在登記變數的時候,會把變數登記在「環境變數宣告記錄表」裡面。這個記錄表,在此先簡寫為DeclEnvRecord(Declarative Environment Record)。

在這個範例全域執行環境宣告記錄表中,登記了af1f3三個變數。 外部環境outerEnv,則標記「需要存取變數時,除了自己的宣告記錄表,我還可以在哪裡找到變數?」 由於全域執行環境是第一個執行環境,因此外部環境null

f1執行環境

再來看f1()執行環境

f1()執行環境宣告記錄表中,登記了bf2兩個變數。

變數的尋找範圍,第一個當然是自己的宣告記錄表(f1EC.lexicalEnv.DeclEnvRec)。如果在自己的DeclEnvRec找不到,則「向外尋找(outerEnv)」,去globalEC.lexicalEnv.DeclEnvRec裡面找。如果globalEC.lexicalEnv找不到,JS就會拋出ReferenceError

f2執行環境

接著來看f2()執行環境

f2()執行環境僅登記了變數c外部環境則註記了它的上一層f1()執行環境

若在f2()執行環境當中搜尋變數,js會:

  1. 在自己的宣告記錄表裡面找,找不到看下一步
  2. 透過自己的outerEnv,去f1EC.lexicalEnv.DeclEnvRec裡面找,找不到看下一步
  3. 透過f1.lexicalEnv.outerEnv,去globalEC.lexicalEnv.DeclEnvRec裡面找。由於這是最後的lexicalEnv了,找不到的話會拋出ReferenceError

f2函式裡,呼叫了f3()。那麼f2是如何找到f3呢?自然是透過作用域鏈往外找,最後在globalEC.lexicalEnv.DeclEnvRec找到了f3

f3執行環境

最後是f3()函式。

f3()執行環境僅登記了變數d

在這裡想用console印出變數c,但是循著作用域鏈,不論是自己的f3EC.lexicalEnv還是globalEC.lexicalEnv,都找不到c,因此log印出來為ReferenceError

到這裡第一個範例結束。

在這裡可以總結什麼是作用域?就程式碼文本的上下文來看,「作用域」可以視為一個變數的生存範圍。例如globalEC的變數,其生存範圍當然是最大的。

而作用域「鏈」,指的是「除了當下的的Decl Env Rec,還可以去哪裏找變數?」 外部環境(Outer Envirnment)會標記可以查訪的Lexical Environment。假設用一條線將所有可查訪的Lexical Environment串起來,這條線可視為「尋找變數的搜索鏈」,即作用域「鏈」。

執行環境堆疊與作用域鏈的關係 Execution Stack vs Scope Chain

執行環境與作用域的劃分確實息息相關,因為每個執行環境都會建立Lexical Environment及標註外部環境。

但是,執行環境堆疊的「順序」和作用域鏈沒有任何關係。 在Execution Stack最上面的執行環境,其作用域鏈,跟它下方的執行環境無關。 例如,f2()函式裡面呼叫了f3(),但f3()的作用域鏈與f2()沒有關聯。

JS這個語言在決定作用域時,是在程式運行「之前」,程式碼「語法上下文解析」的階段,就已經決定的。 這種決定作用域的方式,有人稱為「靜態作用域」(Static Scope),也有人稱為「詞法作用域」(Lexical Scope)。

所以JS的作用域,只要了解JS的語法結構,從「肉眼」就可看出。一份程式碼,不論函式執行的順序為何,作用域都是不變的。

以function作為分界,可能產生的問題

接下來要來談談,以function作為作用域的分界,在撰寫程式碼時容易產生哪些問題。

與Function無關的括號 - 影響作用域的判斷

前面說到,JS的作用域是靜態的,取決於程式碼的上下文,本意是方便工程師閱讀程式碼就可以判斷作用域。

for迴圈的條件式宣告var,在閱讀上會直觀地以為,在for迴圈裡面宣告的var,其作用域僅限於for迴圈裡面,忘記function才是作用域的分界。也忘記這種宣告,會讓迴圈內的i++污染了整個run()函式的var i

function run() {
  var i = 0;

   // do something else...

  for (var i = 1; i <= 5; i++) {    console.log(i);  }
  // do something else...

  console.log(i); // 6
}

run();
/**
 * 1
 * 2
 * 3
 * 4
 * 5
 * 6
 * /

下面則是另一個重複宣告的例子。

function doSomething(condition) {
  var flag = 1;  
  if (condition) {
    var flag = 2;    console.log(flag); // 2
  }
  
  console.log(flag); // 2
}

var condition = true;

doSomething(condition);

變數flag預設是1,但在某些條件下,我需要宣告另一個flag預設是2。 這個例子一樣忽略了function才是作用域的分界,導致後面宣告的flag覆蓋了前面的flag。而且這樣寫,只要condition = true,整個doSomething函式的flag都是2,if條件形同虛設。

這樣看來,以function作為作用域的唯一分界,很容易造成程式碼撰寫上的誤判,變數的宣告也不夠彈性。有時候我們只需要變數的作用域僅限於if條件、for迴圈以內,而不是又宣告一個新的變數像是flag2flag3,增加維護上的困難。

es6的letconst決定補足這個缺陷。

let、const的作用域

上一段提到,有時候我們只需要變數的作用域僅限於if條件、for迴圈以內。let/const以大括號{}作為分界,正好解決了這個問題。

上面的flag範例以let改寫,就可以完全避免變數污染的情況。

function doSomething(condition) {
  let myVar = 1;  
  if (condition) {
    let myVar = 2;    console.log(myVar); // 2
  }
  
  console.log(myVar); // 1
}

var condition = true;

doSomething(condition);

除了大括號自成一個作用域之外,「尋找變數」的作用域鏈仍保持不變。 將上面的例子簡化,if作用域裡面沒有宣告任何變數,但想印出myVar的值,JS依然可以透過作用域鏈向外尋找變數。

function doSomething() {
  let myVar = 1;  
  if (true) {
    console.log(myVar);
  }
}

doSomething();

另外,「拉升」的行為也是保持不變的。上一章末尾提到,let/const並不是沒有拉升,而是沒有初始化為undefined,直到賦值才可以存取。因此,別期待「宣告前往作用域鏈去找,宣告後在自己的作用域裡面找」這種神奇的事情發生。一個變數在一個作用域裡面只能有相同的行為,才有利於維護。

function doSomething() {
  let myVar = 1;  
  if (true) {
    console.log(myVar); // ReferenceError
    
    let myVar = 2;    
    console.log(myVar);
  }
}

doSomething();

Summary

  • 設置作用域的目的是:若要尋找特定的變數,我可以去哪些地方找?
  • 就程式碼文本的上下文來看,「作用域」可以視為一個變數的生存範圍。例如全域執行環境的變數,其生存範圍當然是最大的。
  • 作用域「鏈」,指的是「除了當下的的變數宣告記錄表,還可以去哪裏找變數?」
  • 外部環境(Outer Envirnment)會標記可以查訪的Lexical Environment。假設用一條線將所有可查訪的Lexical Environment串起來,這條線可視為「尋找變數的搜索鏈」,即作用域「鏈」。
  • JS這個語言在決定作用域時,是在程式運行「之前」,程式碼「語法上下文解析」的階段,就已經決定的。所以JS的作用域,只要了解JS的語法結構,從「肉眼」就可看出。一份程式碼,不論函式執行的順序為何,作用域都是不變的。
  • 僅以function作為作用域的唯一分界,很容易造成程式碼撰寫上的誤判,變數的宣告也不夠彈性。
  • let/const以大括號{}作為分界,彌補了作用域不夠有彈性的缺陷。

Reference