深入了解:Hoisting 和「執行環境、詞彙環境」的關係

如果只是想了解或還沒了解基本 Hoisting 概念,請前往:六角學院 JS 核心筆記 (六)【執行環境與作用域】- 提升 (Hoisting)

前言

Hoisting 基本概念就是:將非函式和函式宣告的動作,抽象地提升到程式碼最前面的動作,讓程式進入執行階段前,就預先完成宣告。

提升 (Hoisting)、執行環境 (EC)、詞彙環境 (LE)

說明

以如下程式碼為例:

1
2
console.log(a); // 輸出結果:undefined
var a = 1;

我在「六角學院 JS 核心筆記」前面幾章有提到,console.log(a) 要查找的變數不在指定動作 = 的左邊,也就是執行 RHS 查找動作 (執行階段才會報錯)。如果有錯誤會丟出 Uncaught ReferrenceError 的訊息。既然沒丟出錯誤訊息,就表示它是正常運行,查找變數時,確實有在詞彙環境 Lexical Environment (LE) 裡面找到變數 a,而且內容是 undefined

可以想像成 Lexical Environment 長這樣 (為了節省篇幅,把全域的內建 identifiers 拿掉):

1
2
3
4
5
6
7
8
9
10
globalEnvironment = { 
environmentRecord: {
// built-ins 內建 etc ...

// our bindings 自定義:
a: undefined
},

outer: null // no parent environment 沒有上層函式
};

而 JS 有編譯時期和執行時期。既然執行的時候有在 LE 中找到變數,就表示 Hoisting 是發生在執行前的編譯階段。

運行原理

編譯的時候編譯器和 JavaScript 引擎會先掃過整個程式碼一遍,產生語法作用域 (Lexical scope) 的範圍,將範圍以「執行環境 (Execution Context,EC) 資料」呈現,如下:

1
2
3
4
5
ExecutionContext = {
ThisBinding: <this value>,
VariableEnvironment: { ... },
LexicalEnvironment: { ... }
}

裡面有產生一個叫做詞彙環境 (Lexical Environment) 的數據結構,裡面的 identifiers 會儲存程式碼中開頭是 functionvar 的變數名稱,而 identifiers 的值會是函式物件或 undefined,這個行為就是 Hoisting。這邊先不用理解 ThisBindingVariableEnvironment

舉例一

編譯時期

1
2
console.log(a); // 輸出結果:undefined
var a = 1;

程式編譯一開始會直接先產生一個全域執行環境 (Global Execution Context,Global EC),裡面有一個全域詞彙環境 (Global Lexical Environment,Global LE),長得如下:

1
2
3
4
5
6
7
8
9
10
11
12
// 全域執行環境
GlobalExecutionContext = {
ThisBinding: <this value>,
GlobalVariableEnvironment: { ... },

GlobalLexicalEnvironment: { // 全域詞彙環境
environmentRecord: {
// built-ins 內建 etc ...
},
outer: null
}
}

接著編譯器從第一行程式碼開始:

1
2
3
Line 1:Global Scope,沒有 function 或 var,不做事。
Line 2:Global Scope,有 var,我想宣告一個非函式變數 a,先去 Global LE 找找看有沒有一樣的變數。
沒有就產生新的 identifier,賦予 undefined;有找到一樣的變數,就不做事。

於是 Global LE 變成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 全域執行環境
GlobalExecutionContext = {
ThisBinding: <this value>,
GlobalVariableEnvironment: { ... },

GlobalLexicalEnvironment: { // 全域詞彙環境
environmentRecord: {
// built-ins 內建 etc ...

// 自訂
a: undefined
},
outer: null
}
}

執行時期

再看一次程式碼:

1
2
console.log(a); // 輸出結果:undefined
var a = 1;

JavaScript 引擎從第一行程式碼開始:

1
2
3
4
Line 1:Global Scope,我要執行 RHS 查找,去 Global LE 裡面找找有沒有變數 a,
結果在第 11 行找到,輸出 a 的值 - undefined。
Line 2:Global Scope,我要執行 LHS 查找去 Global LE 裡面找找有沒有變數 a,
結果在第 11 行找到,賦予 a 新的值 - 1。

最後,程式碼全部執行完畢後 Global LE 變成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 全域執行環境
GlobalExecutionContext = {
ThisBinding: <this value>,
GlobalVariableEnvironment: { ... },

GlobalLexicalEnvironment: { // 全域詞彙環境
environmentRecord: {
// built-ins 內建 etc ...

// 自訂
a: 1
},
outer: null
}
}

舉例二

編譯時期

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var x = 1;
var y = 10;

function fn2(){
var x = 2;
y = 99;
function fn1(){
console.log(x); // 輸出結果:2
}

console.log(y); // 輸出結果:99
fn1();
}

fn2();

一開始會直接先產生一個 Global EC,跟上述一樣,為了節省篇幅就不重複寫出來。接著編譯器從第一行程式碼開始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Line 1:Global Scope,有 var,想宣告一個非函式變數 x,先去 Global LE 找找看有沒有一樣的變數。
沒有找到,於是產生新的 identifier,賦予 undefined。
Line 2:Global Scope,有 var,想宣告一個非函式變數 y,先去 Global LE 找找看有沒有一樣的變數。
沒有找到,於是產生新的 identifier,賦予 undefined。
Line 3:沒事。
Line 4:Global Scope,有 function,想宣告一個函式變數 fn2,先去 Global LE 找找看有沒有一樣的變數。
沒有找到,於是產生新的 identifier,賦予指向函式物件的 reference。
並且開始產生「fn2 執行環境資料」。
Line 5:fn2 Scope,有 var,想宣告一個非函式變數 x,先去 fn2 LE 找找看有沒有一樣的變數。
沒有找到,於是產生新的 identifier,賦予 undefined。
Line 6:沒事。
Line 7:fn2 Scope,有 function,想宣告一個函式變數 fn1,先去 fn2 LE 找找看有沒有一樣的變數。
沒有找到,於是產生新的 identifier,賦予指向函式物件的 reference。
並且開始產生「fn1 執行環境資料」。
Line 8-15:沒事。

編譯時期結束後,Global EC and LE 變成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 全域執行環境
GlobalExecutionContext = {
ThisBinding: <this value>,
GlobalVariableEnvironment: { ... },

GlobalLexicalEnvironment: { // 全域詞彙環境
environmentRecord: {
// built-ins 內建 etc ...

// 自訂
x: undefined,
y: undefined,
fn2: <ref. to fn2 function>
},
outer: null
}
}

fn2 EC and LE 變成:

1
2
3
4
5
6
7
8
9
10
11
12
13
// fn2 執行環境
fn2ExecutionContext = {
ThisBinding: <this value>,
fn2VariableEnvironment: { ... },

fn2LexicalEnvironment: { // fn2 詞彙環境
environmentRecord: {
x: undefined,
fn1: <ref. to fn1 function>
},
outer: GlobalLexicalEnvironment // 上層是全域詞彙環境
}
}

fn1 EC and LE 變成:

1
2
3
4
5
6
7
8
9
10
11
// fn1 執行環境
fn1ExecutionContext = {
ThisBinding: <this value>,
fn1VariableEnvironment: { ... },

fn1LexicalEnvironment: { // fn1 詞彙環境
environmentRecord: {
},
outer: fn2LexicalEnvironment // 上層是 fn2 詞彙環境
}
}

執行時期

再對照一次上述的程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var x = 1;
var y = 10;

function fn2(){
var x = 2;
y = 99;
function fn1(){
console.log(x); // 輸出結果:2
}

console.log(y); // 輸出結果:99
fn1();
}

fn2();

JavaScript 引擎從第一行程式碼開始執行:

1
2
3
4
5
6
7
Line 1:Global Scope,我要執行 LHS 查找,去 Global LE 裡面找找有沒有變數 x,
結果在第 11 行找到,賦予 x 新的值 - 1。
Line 2:Global Scope,我要執行 LHS 查找去 Global LE 裡面找找有沒有變數 y,
結果在第 12 行找到,賦予 y 新的值 - 10。
Line 15:Global Scope,我要執行 RHS 查找,去 Global LE 裡面找找有沒有變數 fn2,
結果在第 13 行找到,並且發現它真的是函式物件,於是成功呼叫。
並且將「fn2 執行環境資料 (fn2 EC)」複製一份,放入 stack 中。

補充說明:
執行時期,每次呼叫函式都會有「該函式執行環境資料」被複製一份,放入 stack 中。當函式執行完畢,就被從 stack 中刪除,將記憶體釋放。如此一來,如果我要再一次呼叫一樣的函式,會是複製最初始狀態的「該函式執行環境資料」。因此,前一次呼叫的執行環境裡面變數的值,才不會沿用到下一次新的執行環境中的相同變數。這就是之前提過的:新的執行環境和舊的執行環境,是不同的東西。請參考:六角學院 JS 核心筆記 (四)【執行環境與作用域】- 執行環境與執行堆疊:執行環境 Execution Context

到目前,Global LE 變成:

1
2
3
4
5
6
7
8
9
10
11
Global LE: { // 全域詞彙環境
environmentRecord: {
// built-ins 內建 etc ...

// 自訂
x: 1,
y: 10,
fn2: <ref. to fn2 function>
},
outer: null
}

再複製一次程式碼,方便對照:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var x = 1;
var y = 10;

function fn2(){
var x = 2;
y = 99;
function fn1(){
console.log(x); // 輸出結果:2
}

console.log(y); // 輸出結果:99
fn1();
}

fn2();

開始執行 fn2

1
2
3
4
Line 5:fn2 Scope,我要執行 LHS 查找,去 fn2 LE 裡面找找有沒有變數 x,
結果在 fn2 LE 第 8 行找到,賦予 x 新的值 - 2。
Line 6:fn2 Scope,我要執行 LHS 查找,去 fn2 LE 裡面找找有沒有變數 y,沒有找到,
去上層 Global LE 找,結果找到了,賦予 Global LE 中的 y 新的值 - 99。

到目前,Global LE 變成:

1
2
3
4
5
6
7
8
9
10
11
Global LE: { // 全域詞彙環境
environmentRecord: {
// built-ins 內建 etc ...

// 自訂
x: 1,
y: 99,
fn2: <ref. to fn2 function>
},
outer: null
}

fn2 LE 變成:

1
2
3
4
5
6
7
fn2 LE: { // fn2 詞彙環境
environmentRecord: {
x: 2,
fn1: <ref. to fn1 function>
},
outer: GlobalLexicalEnvironment // 上層是全域詞彙環境
}

繼續執行 fn2

1
2
3
4
5
Line 11:fn2 Scope,我要執行 RHS 查找,去 fn2 LE 裡面找找有沒有變數 y,沒有找到,
在上層 Global LE 找到 y,輸出它的值 99。
Line 12:fn2 Scope,我要執行 RHS 查找,去 fn2 LE 裡面找找有沒有變數 fn1,
結果找到了,並且發現它真的是函式物件,於是成功呼叫。
並且將「fn1 執行環境資料 (fn1 EC)」複製一份,放入 stack 中。

再複製一次程式碼,方便對照:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var x = 1;
var y = 10;

function fn2(){
var x = 2;
y = 99;
function fn1(){
console.log(x); // 輸出結果:2
}

console.log(y); // 輸出結果:99
fn1();
}

fn2();

複製一次 fn1 LE,方便對照:

1
2
3
4
5
fn1LexicalEnvironment: { // fn1 詞彙環境
environmentRecord: {
},
outer: fn2LexicalEnvironment // 上層是 fn2 詞彙環境
}

開始執行 fn1

1
2
Line 8:fn1 Scope,我要執行 RHS 查找,去 fn1 LE 裡面找找有沒有變數 x,沒有找到,
在上層 fn2 LE 找到 x,輸出它的值 2。

fn1 執行完畢後,fn1 EC 會被丟出 stack,從記憶體中清除。接著執行完畢 fn2,fn2 EC 會被丟出 stack,從記憶體中清除。剩下 Global EC 還在 stack 中,因為 JS 程式 (網頁) 還沒有關閉。

參考資料:
我知道你懂 hoisting,可是你了解到多深?
Lexical Environment — The hidden part to understand Closures