本篇為 [JS201] 進階 JavaScript:那些你一直搞不懂的地方 這門課程的學習筆記。如有錯誤歡迎指正!
學習目標:
你知道 Hoisting(提升)是什麼
你知道 Hoisting 的原理為何
你知道 Hoisting 只會提升宣告而非賦值
你知道 function 宣告、function 的參數以及一般變數宣告同時出現時的提升優先順序
你知道 let 跟 const 其實也有 Hoisting,只是表現形式不太相同
什麼是 Hoisting?
如果我們試圖在 JavaScript 中,對一個尚未宣告的變數取值,會出現 a is not defined
的錯誤訊息:
console.log(a)
// ReferenceError: a is not defined
但如果在下面加上一行 var a = 10
,也就是宣告變數 a,神奇的事就發生了:
console.log(a);
var a = 10;
// undefined
=== 經過 Hoisting ===
var a;
console.log(a);
a = 10;
// undefined
我們知道,在 JavaScript 中程式是一行一行執行的。但是在上方程式碼的情況下,console.log() 的變數 a 卻能夠先被宣告,然後輸出 undefined,可以想成下方的程式碼。
這種現象就叫做 Hoisting(提升),會發生在變數宣告。也就是說,當我們宣告一個變數時,宣告本身會被提升至程式碼最上面,而賦值則留在原處,因此也就不會出現 a is not defined
的錯誤。
或是以我們熟悉的宣告 function 為例,就算在宣告以前就先呼叫它,還是能夠順利執行:
test();
function test() {
console.log(123);
}
// 123
=== 經過 Hoisting ===
function test() {
console.log(123);
}
test();
// 123
因為整段 function 會被提升到上面,所以不管在哪呼叫 function 都能夠執行。
這在有些程式語言其實是做不到的,但在 JavaScript 進行宣告能夠做到提升,也使程式碼撰寫更加方便。
只有宣告會提升,賦值不會提升
但需注意的是,只有「宣告」會被提升到最上方,但「賦值」不會。
若宣告一個變數,並賦值為 function 時,以下方程式碼為例:
- 若把呼叫改寫在 function 後面,能夠順利執行:
var test = function() {
console.log(123);
}
test();
// 123
- 若把呼叫改寫在 function 前面,則會出現「test 不是 function」的錯誤:
test();
var test = function() {
console.log(123);
}
// TypeError: test is not a function
=== 經過 Hoisting ===
var test;
test();
test = function() {
console.log(123);
}
之所以會出現錯誤,是因為即使經過提升先宣告變數 var = test
,卻無法先賦值。因此呼叫的 test()
其實是 undefined 而非 function,所以執行就會顯示錯誤。
Hoisting 的順序
情境一:同時宣告函式與變數
那如果同時宣告一個 function 和變數呢?
以下列程式碼為例:
console.log(a); // [Function: a]
var a = 'global';
function a() {
var a = 'local';
}
會發現答案既不是 global 也非 local,而是輸出 function。
這是因為除了宣告變數以外,function 的宣告也會提升,而且「function 的提升會優先於變數的提升」。
情境二:重複的函式
如果有兩個相同的 function,則會依照順序,提升比較後面宣告的 function:
function test() {
a();
function a() {
console.log(1);
};
function a() {
console.log(2);
};
}
test();
// 2
=== 經過 Hoisting ===
function test() {
/* 會優先提升後面的函式
function a() {
console.log(1);
};
*/
function a() {
console.log(2);
}
a();
}
test();
// 2
情境三:傳入參數到 function
在探討參數之前,先來複習參數和引數的區別:
- 參數(Parameter):是方法的宣告
- 引數(Argument):用於呼叫函式
可參考:引數(Argument) vs. 參數(Parameter) - NotFalse 技術客
因此,當我們傳入參數到 function 時,其實指的就是引數(argument),而 argument 的宣告會優先於被提升的變數宣告。
以下方傳入參數的程式碼為例:
function test(a) {
console.log(a)
var a = 456
}
test(123)
// 123
=== 經過 Hoisting ===
function test(a) {
var a; // 宣告變數就沒用了QQ
console.log(a);
a = 456;
}
test(123) // argument 的宣告優先度較高
// 123
那如果同時宣告 function 和 argument 呢?宣告 function 會優先於 argument 的宣告:
function test(a) {
console.log(a);
function a() {
};
}
test(123);
// [Function a]
=== 經過 Hoisting ===
function test(a) {
function a () { // 函式的宣告優先度較高
};
console.log(a);
}
test(123); // 參數寫多少都沒用QQ
// [Function a]
情境四:宣告變數並賦值
但如果是在函式內「宣告變數並賦值」的話,結果就不相同了:
function test(a) {
console.log(a);
var a = 'hello'
console.log(a);
function a () {
console.log(2);
};
}
test(123)
=== 經過 Hoisting ===
function test(a) {
function a () { // 函式宣告提升
console.log(2);
}
console.log(a); // [Function: a]
var a = 'hello'; // 賦值
console.log(a); // hello
}
test(123); // 參數還是沒用QQ
綜合上述幾個例子,可整理出有關 Hoisting 的幾個重點:
- Hoisting 的優先順序為:
- 函式宣告(function)
- 傳進函式的參數(argument)
- 變數宣告(variable)
- 只有宣告會提升,賦值不會提升
小試身手
最後再來進行這個小測驗,判斷 log 值依序是什麼:
var a = 1;
function test(){
console.log('1.', a);
var a = 7;
console.log('2.', a);
a++;
var a;
inner();
console.log('4.', a);
function inner(){
console.log('3.', a);
a = 30;
b = 200;
}
}
test();
console.log('5.', a);
a = 70;
console.log('6.', a);
console.log('7.', b);
思考方式:
console.log('1.', a)
先看執行函式 test(),因為函式內有宣告變數,所以會提升,得到 a = undefined。
console.log('2.', a);
在函式 test(),賦值 a = 7。
console.log('3.', a);
進入函式 inner(),函式內沒有宣告 a,所以會往上層 test() 宣告過 var a = 7
,經過 a++ 得到 a = 8。
又因為外層沒有宣告過 b ,在呼叫函式 inner() 的同時,會宣告一個全域變數 b 並賦值為 200。
console.log('4.', a);
前面呼叫 inner() 時,重新賦值變數 a 為 30,因此這時外層的 a = 30。
console.log('5.', a);
看全域變數的 var a = 1
,因此 a = 1。
console.log('6.', a);
經過賦值 a = 70。
console.log('7.', a);
前面提到在函式中,宣告了一個全域變數 b 並賦值為 200,因此 b = 200
所以為什麼需要 Hoisting?
至於我們為什麼會需要 Hoisting 呢?這個問題其實可以倒過來思考:「假如沒有 Hoisting 會怎麼樣?」
- 一定要先宣告變數才能使用
養成宣告的好習慣!這樣就不會汙染變數了,感覺還不賴?
- 一定要先宣告函示才能使用
這點其實會造成很大的不便,因為這樣在每個檔案都必須把 function 宣告放在最上面,才能保證底下的程式碼都可以呼叫這些 function。
- 無法達成 function 互相呼叫
什麼意思?舉個下面的例子:
function loop(n){
if (n > 1) {
logEvenOrOdd(--n);
}
}
function logEvenOrOdd(n) {
console.log(n, n % 2 ? 'Odd' : 'Even');
loop(n);
}
loop(10);
因為有 Hoisting,我們才能在 loop() 函式中呼叫 logEvenOrOdd(),然後也在 logEvenOrOdd() 函式中呼叫 loop()。
如果沒有 Hoisting,那以上的程式碼就不可能達成,因為這牽涉到順序問題,不可能同時做到 A 在 B 上面而 B 又在 A 上面。
為了解決上述問題,所以我們需要 Hoisting。
延伸閱讀:
- Note 4. Two words about “hoisting”.
- JavaScript系列文章:变量提升和函数提升
Hoisting 的運作原理
在瞭解什麼是 Hoisting,還有我們為什麼需要 Hoisting 之後,接著就來探討:「Hoisting 究竟是如何運作的?」
這部分可參考 JavaScript 所遵循的標準 ECMAScript 規格書,因為後期版本規格眾多,這裡以 ES3 規格書當作範例。
其實在 ES3 的規則中,並出現沒有 Hoisting 這個詞,與此現象有關的段落出現在第十章:Execution Contexts(執行環境,以下部分內容簡稱 EC)。
什麼是 Execution Context?
在 JavaScript 中,可執行的 JS 程式碼可分成三種型別:
- Global Code:全域性的,不在任何函式裡面的程式碼
- Function Code:在自定義函式中的程式碼
- Eval Code:使用 eval() 函式動態執行的程式碼,但因為有兼容性與安全問題,JavaScript 並不推薦使用,這裡先不討論
當 JavaScript 開始執行時,根據執行的程式碼型別,程式碼必須被執行在這三種環境之一,也就是 Execution Context(執行環境)。
以下要來探討的是全域執行環境和函式執行環境:
- 全域執行環境(Global Execution Context)
- 當執行 JavaScript 時,會先建立的第一層執行環境
- 位在最外圍的執行環境,且只會有一個
- 其他執行環境都可存取全域的資料
- 函式執行環境(Function Execution Context)
- 又稱為區域性執行環境(Local Execution Context)
- 每個 function 都有自己的執行環境
- 裡面儲存該 function 的相關資料,例如變數和函式定義
- 每當呼叫一個 function 時,都會建立一個新的 local EC,並且被放到執行堆疊(Call Stack)最上面
(參考來源:https://medium.com/%E9%AD%94%E9%AC%BC%E8%97%8F%E5%9C%A8%E7%A8%8B%E5%BC%8F%E7%B4%B0%E7%AF%80%E8%A3%A1/%E6%B7%BA%E8%AB%87-javascript-%E5%9F%B7%E8%A1%8C%E7%92%B0%E5%A2%83-2976b3eaf248)
呼叫 & 執行堆疊(Call Stack)
因為 JavaScript 是單執行緒的程式語言,用白話文解釋就是「JavaScript 一次只能做一件事情」,因此 JS 中等待執行的任務會被放入 Call Stack。
JavaScript 在調用一個執行環境時,其實會經過兩個階段:
- 建立階段:呼叫堆疊(Call Stack)
前面有提到,當開始執行 JavaScript 時,會先進入 Global EC。直到我們呼叫一個 function 時,才會建立一個新的執行環境繼續往 Global EC 的上層依序堆疊。
這些過程都發生在開始執行內部程式碼之前,也就是建立階段。對 JS 引擎來說,屬於執行前的編譯階段,而 hoisting 就是在此階段進行處理。
- 執行階段:執行堆疊(Execution Stack)
接著 JavaScript 會優先處理執行堆疊中最上面的執行環境。一旦執行完該 function 後,該執行環境就會從最上面被移除(pop off),同時儲存在該 function 中的資訊也會被銷毀,然後再回到之前的執行環境,直到回到 Global EC 為止。
可參考下方的執行環境流程示意圖:
(參考來源:https://dev.to/ahmedtahir/what-is-the-execution-context-execution-stack-scope-chain-in-js-26nc)
最後整理關於執行環境的幾個重點:
- JavavaScript 是單執行緒的語言
- 且為同步執行:一次一個指令,而且有一定的順序
- Global Context 只會有一個
- Function Context 則沒有限制
- 就算是自己呼叫自己,只要有呼叫 function 就會建立執行環境
Execution Context Object 執行環境物件
前面也曾提到,執行環境負責存放該環境需要用到的各種資料,我們可以把執行環境想像成一個物件,又被稱作執行環境物件(Execution Context Object)。
而每個執行環境物件會有下列三個屬性:
- 作用域鏈(Scope Chain)
- 變數物件(Variable Object)
- ‘this’ 變數(’this’ Variable)
若以 Object 型態表示會如下:
executionContextObject = {
scopeChain: {
// 變數物件 + 所有父代執行環境物件的變數物件
},
variableObject: {
// 函式的參數、內部的變數和函式
},
this: {
//
}
}
Variable Object 變數物件(VO)
以下是 ECMA 10.1.3 Variable Instantiation 對於 Variable Object 的解釋:
Every execution context has associated with it a variable object. Variables and functions declared in the source text are added as properties of the variable object. For function, anonymous, and implementation-supplied code, parameters are added as properties of the variable object.
意思是說,每一個執行環境都會有一個相對應的變數物件(Variable Object),這個物件負責記錄執行環境中定義的變數和函式。
而執行 function 所創造的執行環境中,則會把參數(parameters)也加進去變數物件中,也就是活化成執行物件(Activation Object),這部分我們後面章節會再提到。
那麼,Variable Object 實際上是如何運作的呢?我們可從 JavaScript 執行流程談起。
從呼叫 function 到執行程式碼,這段過程中其實發生很多事情,流程大致如下:
- 尋找呼叫 function
- 在執行 function 之前先建立執行環境
- 進入建立階段
- 初始化 Scope Chain
- 建立 Variable Object
- 判斷決定 this 的值
- 執行階段
- 一行一行執行程式碼,賦值
其中,在建立 Variable Object 時,則會進行下列三件事:
- 建立 Argument Object:檢查執行環境的參數,初始化參數的名稱與值,若沒有值就初始為undefined
- 掃描 Function 宣告:為每一個 function 建立一個新屬性,其值指向該 function 在記憶體中的位置。如果已經有同名的就取代之
- 掃描變數宣告:為每一個變數宣告建立一個新屬性,並將該屬性的值初始為 undefined。如果已經有同名的就略過
以下舉簡單的範例進行說明。
範例:宣告變數 var a = 123
這句會分成兩部分:
var a
:如果 VO 裡沒有屬性 a,就會新增 a 並初始化成 undefineda = 10
:在 VO 裡找到叫做 a 的屬性,並設定為 10
VO 模型:
VO: {
a: 123, // 把變數放進 VO,初始化成 123
}
至於 VO 是如何找到屬性 a,則是會透過透 Scope chain 不斷往上層找,如果每一層都找不到就會出現錯誤訊息。這過程中涉及的部分較廣,因此我們先不進行討論。
範例:在 function 傳入一個參數 123
程式碼如下:
function test(a, b) {
console.log(a, b);
}
test(123);
// 123, undefined
- 把參數放到 VO,並初始化成 123
- 若參數沒有值,就會被初始化成 undefined
VO 模型如下:
VO: {
a: 123, // 把參數放進 VO,初始化成 123
b: undefined, // 把變數放進 VO,初始化成 undefined
}
範例: 在 function 中宣告一個 function
程式碼如下:
function test(a, b){
function b(){
}
var c = 30;
}
test(123);
- 宣告 function 一樣會在 VO 裡面新增一個屬性
- 但如果 VO 裡面已經有同名的屬性,就會取代之。因此 function b 會取代傳入的參數 b
VO 模型如下:
VO: {
a: 123, // 把參數放進 VO,初始化成 123
b: function b, // 把 funcion b 放進 VO
c: undefined // 把變數放進 VO,初始化成 undefined
}
對於宣告變數,則會在 VO 裡面新增一個屬性並且把值設為 undefined,如上述範例中的 var c
但如果 VO 已經有這個屬性時,值不會被改變。
小結
由上方幾個範例可知,當我們進入一個 EC,但還沒開始執行 function 以前,會先建立 Variable Object,並按照順序進行以下三件事:
- 把參數放到 VO 裡面並設定傳進來的值,沒有值的設成 undefined
- 把 function 宣告放到 VO 裡,如果已經有同名的就取代之
- 把變數宣告放到 VO 裡,如果已經有同名的則忽略
最後再來個牛刀小試
function test(v){
console.log(v); // 10
var v = 3;
console.log(v); // 3
}
test(10);
思考方式:
- 在執行 function 前,因為 test() 有傳參數進去,所以把 v 放到 VO 並設定為 10
- 接著開始執行 functon 內容程式碼,第二行印出 10
- 執行到第三行,把變數 v 的值換成 3
- 執行到第四行,印出 3
TDZ:Temporal Dend Zone 暫時死區
接著再回到 let 和 const,其實以 let 和 const 宣告變數同樣會有 Hoisting 的情形,只是執行方式不太相同。
先以下列程式碼為例,和文章最一開始的 var a
不同,結果出現 a is not defined
的錯誤:
console.log(a); // ReferenceError: a is not defined
let a;
這樣是不是代表 let 和 const 沒有變數提升呢?否則宣告經過提升後應該不會出現 Error 才對?
事實上,let 與 const 確實有 Hoisting,與 var 的差別在於提升之後,var 宣告的變數會被初始化為 undefined;而 let 與 const 的宣告則不會被初始化為 undefined,如果在「賦值之前」就存取它,會拋出錯誤訊息。
簡單來說,如果是在「提升之後」以及「賦值之前」這段期間存取變數就會拋出錯誤,而這段期間就稱作 TDZ(Temporal Dend Zone),中文為「暫行性死去」或「暫時死區」,是為了解釋 let 與 const 的 Hoisting 行為所提出的一個名詞。
再來看以下範例:
var a = 10;
function test() {
console.log(a);
let a = 30;
}
test();
// ReferenceError: Cannot access 'a' before initialization
=== 經過 Hoisting ===
var a = 10;
function test() {
let a; // 宣告變數 a 經過提升之後
console.log(a); // 在賦值之前不能存取 a
a = 30;
}
test();
同理可證,假如 let 沒有 Hoisting,結果應該會印出 10 才會,因為 log 那一行會存取全域變數的 var a = 10
,但是卻印出 錯誤:無法在初始化之前存取變數 a
。這是因為 let 的確有提升,只是沒有初始為 undefined。
總結上述例子:
- let 與 const 宣告確實也有 Hoisting 行為,但沒有像 var 宣告初始化為 undefined
- 如果在提升之後,賦值之前這段期間,存取該值會發生錯誤
- 這段期間稱為 Temporal Dend Zone(暫時死區)
結語
從理解什麼是 Hoisting(提升),瞭解我們為什麼需要提升,再延伸到運作原理。過程中建立的 Execution Contexts(執行環境)、與之對應的 Variable Object(變數物件)等等,其實涉及到有關 JavaScript 的知識範圍非常廣。
除了課堂影片提到的內容,自己也上網查了許多有關執行環境、執行堆疊的資料,雖然花費不少時間,卻也藉由瞭解 JavaScript 的編譯與執行過程,從建立到執行階段,加深對整個架構的理解。
參考資料:
- 我知道你懂 hoisting,可是你了解到多深?
- JavaScript: Scope & Hoisting
- 理解 Javascript 執行環境
- javascript執行環境及作用域詳解
- 秒懂!JavaSript 執行環境與堆疊
- JavaScript 深入淺出 Variable Object & Activation Object