我們已經知道,變數依賴著作用域存活,也就是遵循著一系列規則被存取。
而在變數的宣告和賦值上,有一個微妙的細節。
先前已經提過,進行變數查詢時如果找不到識別字的參考,就會回報 ReferenceError
:
console.log(a);
// ReferenceError: a is not defined
加上宣告與賦值後才能正常取得
let a;
a = 1;
console.log(a); // 1
甚至不賦值的話,JS 也會給予預設值 undefined
let a;
console.log(a); // undefined
但如果在宣告之前存取:
console.log(a);
// ReferenceError: Cannot access 'a' before initialization
let a;
可以看到同樣出現了 ReferenceError
,但奇怪,這裡不是寫 a is not defined
,而是出現 Cannot access 'a' before initialization
。
照理來說,在宣告之前使用,不是應該跟 a is not defined
一樣,找不到識別字對應的參考嗎?
為什麼顯示的錯誤卻表示 a
尚未初始化?這兩者到底有什麼不同?
解開這個答案之前,來看看更神奇的事情:
console.log(a); // undefined
var a;
在上面的程式碼中,程式打印了一個在此之前未宣告過的 a
變數,隨後才在下一行宣告它,結果程式......毫無錯誤地印出了 undefined
!?
要解答這兩個謎題,就需要來探討,JS 實際上都做了些什麼。
所以說,為什麼以下的結果會是 undefined
?
console.log(a); // undefined
var a;
問題的解答,藏在 JS 的編譯時期,在程式執行之前就已經發生了某些事,因此等執行到 console.log(a)
的時候,才會出現上面那樣的情況。
同一段的程式碼,對人類來說會直覺式地從上往下讀,並對其賦予「時間」的概念,上面的程式碼先執行,下面的程式碼後執行。
這個概念對電腦基本上來說也是一樣的,只是每個階段處理的內容不同,所以最後的結果有時會在人腦的預期之外。
先前已經提過,JS 包含編譯這個過程,在這個時期,JS 編譯器就已經完成全部的「宣告」作業,並將識別字存入記憶體當中,同時處理好所有作用域存取規則。
因此在執行 console.log(a);
之前,var a;
早在編譯時期便處理完畢,並給予預設值 undefined
,因此來到 console.log(a);
時,就能毫無障礙地進行 RHS,然後順利把值傳給 console.log
執行。
以上整個過程所產生的結果,就像把 var a;
這行程式碼搬移到最前面一樣,因此這樣的現象被稱為「提升(Hoisting)」。
// var a; 早在編譯期處理完畢,像是被放到了最上面一行
console.log(a); // undefined
var a; // 被提升到第一行了
與此同時,由於變數的存取遵守作用域規則,因此所有宣告「看起來」都被提升到了所屬作用域的最前面,畢竟再上去,也就是更外層的作用域並無法存取這個變數。
// 這裡無法取得 a
function foo(){
// var a; 被提升到此處,是 a 最早能夠被取得的地方
console.log(a) // undefined
var a;
}
foo();
但賦值的部分則是在執行時期處理,並沒有提升的行為:
// var a 宣告被提升到最頂端
function foo() {
// var b 被提升到 foo 作用域的最頂端
console.log(b); // undefined
b = "B"; // 於執行時期才處理,留在原地
var b; // 於編譯時期就處理完畢,因此已經被提升到上面了
console.log(b); // B
// foo 執行的時候,a 完成宣告但尚未賦值
console.log("a in foo:", a); // a in foo: undefined
}
foo();
var a = "A"; // 直到這裡才賦值給 a
console.log("a in global:", a); // a in global: A
總結來說,由於 JS 會在編譯時期處理所有宣告,結果就是程式內部的宣告「看上去」被往前移到了所屬作用域的最頂端,這就是所謂的「提升(Hoisting)」。
理解提升的原理後,回頭來看看 JS 在 ES6 新增的宣告補丁,也就是 let
和 const
這兩個關鍵字。
前面已經說過,let
和 const
兩者唯一的差別,在於 const
僅能在宣告時被賦值,而 let
則能夠重複賦值無數次。
並且 let
和 const
能夠創造區塊作用域(block scope),但 var
不能。除此之外,它們和 var
在提升時的表現其實也有所不同。
讓我們回到這段程式碼:
console.log(a);
let a;
前面已經提過,程式在這裡會回報 ReferenceError
。但並不是顯示 a is not defined
,而是 Cannot access 'a' before initialization
,而這就是 JS 對 var
和 let
做出的區別。
由 let
宣告的變數同樣在編譯時期就處理完變數宣告,所以在遇到 console.log(a)
之前,記憶體當中就存在這個變數了。
但是 JS 處理 var
宣告的變數時,會同時進行初始化並賦予預設值 undefined
,let
和 const
在編譯時期卻沒有這個步驟。
一直要等到程式開始執行,並跑到進行宣告的那行程式碼,才會對 let
和 const
宣告的變數進行初始化。
如果在這之前存取這個變數,就會出現 Cannot access 'a' before initialization
這樣的錯誤訊息。
JS 對此有個專有名詞,叫做「暫時性死區(TDZ, Temporal Dead Zone)」。也就是 let
或 const
被提升後,尚未進行初始化、還無法存取的時期。
function foo() {
// a 被提升到這裡,但處於 TDZ 狀態中
bar(); // 在 a 的 TDZ 期間執行
let a = "Hello"; // 遇到 a 的宣告,TDZ 結束
function bar(){
console.log(a)
}
}
foo()
// ReferenceError: Cannot access 'a' before initialization
在上面的程式碼中,調用 bar()
時,記憶體中只是保留了 a
的位置,卻尚未初始化並給予預設值,因此在執行 console.log(a)
時遇到錯誤:「無法取用尚未初始化的變數 a
」。
JS 之所以會在 ES6 新增 let
和 const
這兩個宣告補丁,也是由於「提升」這個特性,讓 var
宣告的變數能夠在進行宣告的該行程式碼之前讀取,這樣的特性容易引起困惑。
比方說,你在某個地方取用了變數 a
,卻沒注意到你把程式碼寫在 var a = true;
的前面,按照 JS 的邏輯,此時的 a
已經被初始化,並擁有 undefined
這個預設值,所以程式完全能夠正常運行,執行結果卻和預想的相反:
if(a){
console.log("Let's start!") // 永遠不會跑到這行來
}
var a = true;
但如果改成 let
,至少程式會告訴你,似乎有哪裡不對勁:
if(a){
console.log("Let's start!")
}
let a = true;
// ReferenceError: Cannot access 'a' before initialization
經過上面對變數提升的討論,這裡能夠得出一個結論:「在 JS 中,不論程式碼撰寫順序為何,宣告永遠早於賦值。」因為宣告的處理,早在程式編譯時期就已經完成了。
除此之外,JS 在 ES6 新增的 let
和 const
所擁有的特性,顯然能夠降低程式碼的錯誤率。因此這兩個關鍵字出現後,便有不少人提倡全面廢止 var
,而僅僅使用 let
和 const
進行宣告。
那麼,關於 JS 的提升終於在本系列最長的一篇文章中結束......這怎麼可能呢!作為 JS 的魔王關卡之一,提升還有另一段故事要講。
在進入下一章之前先給你一段程式碼,猜猜看會印出什麼結果?
console.log(a);
var a;
function a(){
console.log("JavaScript!")
}
如果一切如你所料,那這裡給你一個掌聲。若是你對答案感到困惑,就來下一篇文章找找解答吧!