技術成長 · Web 開發 · JavaScript
JavaScript 的 Hoisting 是什麼?提升會讓程式自己往上跑嗎?
上一篇我們提到了 var、let、const 的差異,但其中有一個很容易讓人困惑的現象:
console.log(age);
var age = 20;照一般從上到下的閱讀順序來看,age 明明還沒宣告,理論上應該直接發生錯誤。
但實際執行後,得到的卻是:
undefined這個現象,就和 JavaScript 的 Hoisting 有關。
---
什麼是 Hoisting?
Hoisting 通常翻譯成「提升」或「變數提升」。
它可以先理解成:
JavaScript 在正式逐行執行程式以前,會先處理目前作用域中的變數與函式宣告。
不過需要特別注意的是,JavaScript 並不是真的把程式碼拿下來,再丟到檔案的最上面去。
Hoisting 比較像是一種用來描述執行結果的說法:程式碼雖然沒有真的移動,但某些宣告會在執行到那一行以前,就先對目前作用域產生影響。
下面這段程式:
console.log(age);
var age = 20;可以暫時用下面的方式理解:
var age;
console.log(age);
age = 20;JavaScript 先知道有一個叫做 age 的變數,但執行到 console.log(age) 時,20 還沒有被放進去,因此結果是 undefined。
Tips:宣告和賦值不是同一件事
下面這一整行其實包含了兩個動作:
var age = 20;第一個動作是宣告:
var age;第二個動作是賦值:
age = 20;在這個 var 範例中,提前產生影響的是變數宣告,不包含右邊的賦值,因此 20 不會一起提前出現。
---
Tips:undefined 是什麼?
在目前這個 var 範例中,undefined 可以理解成:
這個變數已經存在,但目前還沒有被放入我們原本準備給它的值。
它和「變數完全不存在」這件事並不一樣喔。
例如:
console.log(age); // undefined
var age = 20;這裡的 age 已經存在,只是還沒有取得 20。
另外一種未定義是像這樣,下面這個變數從頭到尾都沒有被宣告:
console.log(catName);
// ReferenceError: catName is not defined所以,兩者雖然看起來都是「沒有拿到想要的值」,但背後的原因並不相同。
---
JavaScript 執行程式時,概念上做了什麼?
稍微深入一點來看,我們可以把 JavaScript 的處理過程大略想成兩個階段:
- 先建立目前程式執行需要的環境。
- 按照程式碼順序逐行執行。
在建立環境時,JavaScript 會先處理變數名稱、函式宣告和作用域關係。
等到逐行執行時,才會真的進行 console.log()、運算、函式呼叫和資料賦值。
這也是為什麼:
console.log(age);
var age = 20;JavaScript 在第一行就已經知道 age 是誰,但當下還沒有執行到 age = 20,所以只能得到 undefined。
不過這裡的「兩個階段」主要是方便理解的簡化模型,不代表 JavaScript 引擎真的會先把原始碼重新排版一次。
---
var 被提升後會發生什麼?
使用 var 宣告的變數,在正式逐行執行前就會先被建立,並且預先初始化為 undefined。
console.log(meow); // undefined
var meow = 20;
console.log(meow); // 20抽象嗎?沒關係,整理一下,它的執行概念就可以拆成:
var meow;
console.log(meow); // undefined
meow = 20;
console.log(meow); // 20。里ㄓㄚˇ所以第一個 console.log() 不會報錯,只會得到 undefined。
第二個 console.log() 就會得到 20 了。
這也是 var 容易讓人困惑的地方。
程式看起來像是可以在宣告前使用變數,但當下拿到的其實不是預期的資料。
---
let 和 const 也會 Hoisting 嗎?
會,但這個說法需要補充一下。
let 和 const 也會在程式執行前建立對應的名稱,只是它們不像 var 一樣,會先被初始化為 undefined。
console.log(dennis);
let dennis = 20;在這裡,這段程式會直接發生錯誤:
ReferenceError: Cannot access 'dennis' before initializationconst 也一樣:
console.log(siteName);
const siteName = "夜行手記";報錯:
ReferenceError: Cannot access 'siteName' before initialization為什麼會這樣?
原因是在程式執行到正式宣告以前,let 和 const 會處於一個叫做 Temporal Dead Zone 的階段。let、const 與 class 宣告的 TDZ,會從區塊開始持續到程式執行到宣告及初始化的位置。
---
什麼是 Temporal Dead Zone?
Temporal Dead Zone 簡稱 TDZ,中文可以翻譯成「暫時性死區」。
名字聽起來非常帥,而且似乎有點嚴重,但其實概念並沒有那麼可怕。
可以把它理解成:
JavaScript 已經知道這個變數存在,但在程式執行到宣告位置以前,不允許你使用它。
請看以下簡單範例:
{
// dennis 的 TDZ 開始
console.log(dennis); // 錯誤
let dennis = 20;
// 執行到宣告後,就可以正常使用了
console.log(dennis); // 20
}這裡的 TDZ 是從 { 區塊開始,而不是從 console.log(dennis) 那一行才開始。
直到 JavaScript 執行:
let dennis = 20;完成初始化後,dennis 才能被正常使用。
Tips:TDZ 是看執行順序,不是只看程式碼位置
TDZ 裡的「Temporal」指的是時間。
也就是說,重點不只是變數寫在檔案的第幾行,而是程式執行時,是否已經走到宣告及初始化的位置。
例如:
function showDennis() {
console.log(dennis);
}
let dennis = 20;
showDennis(); // 我在這裡才真正呼叫,所以拿得到20。雖然 showDennis() 函式裡使用 dennis 的程式碼,看起來寫在宣告以前,但函式真正被呼叫時,dennis 已經完成初始化,所以可以正常取得 20。
---
Tips:為什麼要有 TDZ?
TDZ 可以避免我們在變數還沒準備好以前就使用它。
相較於 var 默默回傳 undefined,let 和 const 直接報錯,反而更容易讓開發者及早發現程式寫錯的位置。
---
如何證明 let 在宣告前就已經影響作用域?
可以看下面這個例子:
const meow = "外面的 meow";
{
console.log(meow);
let meow = "裡面的 meow";
}有人可能會想:
裡面的let meow還沒執行,第一個console.log()應該會找到外面的"外面的 meow"吧?
實際上不會。
程式會直接出現 ReferenceError。
因為區塊裡面的 let meow,從這個區塊開始時就已經影響名稱查找。
JavaScript 知道目前區塊裡面有自己的 meow,因此不會跑到外層尋找另一個 meow;但內層的 meow 當下仍處於 TDZ,於是就報錯了。
這個例子也能說明,為什麼有人會認為 let 和 const 其實也存在某種形式的 Hoisting,因為即使還沒執行到宣告行,它們就已經開始影響整個區塊了。
---
typeof 遇到 TDZ 也救不了你
一般來說,對一個完全沒有宣告過的名稱使用 typeof,不會發生錯誤:
console.log(typeof ghost); // "undefined"但如果這個名稱是使用 let 或 const 宣告,而且目前仍然位於 TDZ,就不一樣了:
{
console.log(typeof dennis); // ReferenceError
let dennis = 20;
}雖然使用的是平常相對安全的 typeof,但它仍然不能突破 TDZ。
原因是 dennis 並不是一個完全不存在的名稱,而是一個已經屬於目前區塊、卻尚未完成初始化的變數。
---
var、let、const 的 Hoisting 差異
可以先整理成下面這張表:
| 宣告方式 | 是否會提前影響作用域 | 宣告前使用的結果 |
|---|---|---|
var | 會 | 得到 undefined |
let | 會 | 發生 ReferenceError |
const | 會 | 發生 ReferenceError |
所以常聽到有人說:
var會 Hoisting,但let和const不會。
這句話不完全正確,主要是看文章如何定義 Hoisting。
比較完整的說法是:
var的名稱會先建立,並預先初始化成undefined;let和const的名稱也會提前影響目前作用域,但在執行到宣告以前仍然處於尚未初始化的狀態,因此不能被存取。
順便小補充一下,簡單說 var 除了提升,還順便預先初始化為 undefined,而 let 和 const 則是只建立名稱,不會立刻完成初始化。所以囉,它們在宣告前就已經影響了目前作用域。
有些文章會因此把 let 和 const 稱為「不會 Hoisting」;也有些文章認為,它們既然會提前影響作用域,就屬於另一種 Hoisting 行為。
兩種文章對名詞的使用方式可能不同,但實際執行結果沒有不同:
console.log(dennis);
let dennis = 20;在宣告前使用 dennis,就是會發生 ReferenceError。
---
let 和 const 的初始化也有一點不同
下面這段程式是合法的:
let age;
console.log(age); // undefined當 JavaScript 執行到:
let age;age 的 TDZ 就結束了。即使我們沒有手動給值,它仍會在這時被初始化為 undefined。
但是 const 不行:
const age;
// SyntaxError在一般的 const 變數宣告中,必須在宣告當下提供初始值。
const age = 20;這不是因為 const 沒有 TDZ,而是因為 const 建立後不能重新賦值,所以必須在宣告當下就完成初始化。
---
函式也會 Hoisting 嗎?
使用一般函式宣告時,可以在函式寫出來以前先呼叫:
sayGoodNight();
function sayGoodNight() {
console.log("晚安,夜行者。");
}這段程式可以正常執行。
因為 JavaScript 在正式逐行執行前,就已經先建立了 sayGoodNight 這個函式宣告。
而且函式宣告不只是名稱先出現,連完整的函式內容也已經可以使用,所以才能在定義位置以前正常呼叫。
但如果函式是放在 const 變數裡,欸那情況就不同了:
sayGoodNight();
const sayGoodNight = function () {
console.log("晚安,夜行者。");
};
// ReferenceError: Cannot access 'sayGoodNight' before initialization這段程式就會發生錯誤了(what!??)。
原因就是 sayGoodNight 使用了 const 宣告,在執行到那一行以前仍然處於 TDZ 階段。
請看以下。
---
函式宣告與函式表達式
下面這種寫法叫做函式宣告:
function sayHello() {
console.log("Hello");
}函式宣告的名稱和完整函式內容,會在逐行執行前就先建立,因此可以提前呼叫。
下面這種寫法則是把函式當成一個值,放進變數裡:
const sayHello = function () {
console.log("Hello");
};這種寫法叫做函式表達式。
第二種寫法就變成會受到 const 宣告規則的限制。
也就是說,被提前處理的是 sayHello 這個變數名稱,不是右邊的函式內容。
---
Tips:如果函式表達式搭配 var 呢?
來看另一種寫法:
sayHello();
var sayHello = function () {
console.log("Hello");
};它同樣不能正常呼叫,但錯誤和 const 不一樣。
概念上可以拆成:
var sayHello;
sayHello();
sayHello = function () {
console.log("Hello");
};執行 sayHello() 時,sayHello 的值還是 undefined。
因此常見的錯誤會是:
TypeError: sayHello is not a function這裡不是找不到 sayHello,而是找到了它,但它目前是 undefined,並不是一個可以呼叫的函式。
函式表達式不會像函式宣告一樣,讓右側的完整函式內容提前可用。
---
ReferenceError 和 TypeError 差在哪裡?
剛才出現了兩種不同錯誤:
ReferenceError和:
TypeError可以先用簡單的方式理解:
ReferenceError
代表 JavaScript 無法取得一個目前可以使用的變數或函式。
常見原因有兩種。
第一種是這個名稱從頭到尾都沒有被宣告:
console.log(dennis);
// ReferenceError:dennis 並沒有被宣告第二種是這個名稱已經存在於目前作用域中,但仍處於 TDZ,現在還不能使用:
console.log(dennis);
let dennis = 20;
// ReferenceError:dennis 還沒有完成初始化這兩種情況都會發生 ReferenceError ,但原因不太一樣:
第一種是真的找不到 dennis。
第二種是知道 dennis 存在,但他現在還不能出來。
TypeError
TypeError 代表 JavaScript 已經找到了這個值,但你對它做了不符合目前型別的操作。
let sayHello;
sayHello();
// TypeError:sayHello 現在是 undefined,不是函式,不能呼叫在這裡,JavaScript 找得到 sayHello,所以不是 ReferenceError。
真正的問題是 sayHello 目前的值為 undefined,但程式卻使用 () 把它當成函式呼叫,因此發生 TypeError。
白話來說:
ReferenceError:找不到人,或知道這個人存在,但他現在還不能出來。TypeError:人找到了,但你叫他做一件他根本做不到的事。
---
為什麼實務上不建議依賴 Hoisting?
雖然 JavaScript 允許某些變數或函式在宣告前使用,但不代表這樣寫比較好。
console.log(total);
var total = 100;這段程式可以執行,卻需要額外思考:
total是在哪裡宣告的?- 為什麼現在是
undefined? - 是故意這樣寫,還是漏掉了什麼?
- 然後腦子就有點亂了。
所以,養成良好的習慣,讓大家都清楚明白的方式是先宣告,再使用:
const total = 100;
console.log(total);程式的閱讀順序和執行順序一致,之後除錯與維護也會比較容易。
一般函式宣告雖然可以提前呼叫,但實務上是否這樣安排,可以依程式結構決定。
例如先把主要流程放在上面、細節函式放在下面,有時反而能讓閱讀更順:
startApp();
function startApp() {
console.log("程式開始執行");
}重點不是看到任何 Hoisting 都要禁止,而是不要依賴難以閱讀、容易造成誤會的執行結果。
---
所以函式提升帶來了什麼好處?
第一個好處,是函式宣告的排列順序會比較自由。
JavaScript 在正式逐行執行目前作用域以前,就會先建立一般函式宣告,讓函式名稱直接指向對應的函式物件。因此,我們可以先在上方寫主要流程,再把負責細節的函式放在下面:
startApp();
function startApp() {
loadUserData();
showHomePage();
}
function loadUserData() {
console.log("讀取使用者資料");
}
function showHomePage() {
console.log("顯示首頁");
}這種寫法可以讓讀者先看到程式主要要做什麼,再往下查看每個函式的實作細節。
如果所有函式都必須寫在使用位置以前,主要流程可能會被大量細節推到檔案最下方。當函式越來越多時,為了找主要流程一直上下捲動,會讓你想把螢幕打穿XD。
第二個好處,是在使用函式宣告進行相互遞迴(Mutual Recursion)時,不必太在意兩個函式的排列順序。
Tips:什麼是相互遞迴?
一般遞迴,是函式呼叫自己;相互遞迴則是兩個以上的函式互相呼叫。
例如,下面使用兩個函式判斷數字是奇數還是偶數:
// 以下範例假設傳入的是大於或等於 zero 的整數
console.log(isEven(4)); // true
function isEven(number) {
if (number === 0) {
return true;
}
return isOdd(number - 1);
}
function isOdd(number) {
if (number === 0) {
return false;
}
return isEven(number - 1);
}isEven() 會呼叫 isOdd(),而 isOdd() 又會呼叫 isEven()。
由於一般函式宣告會在逐行執行前先建立,所以不論 isEven() 和 isOdd() 哪一個寫在前面,等到程式真正開始呼叫它們時,兩個函式都已經可以使用。
不過,相互遞迴並不是非得依靠 Hoisting 才能做到。
只要等所有函式都初始化完成後再呼叫,即使使用函式表達式也可以互相呼叫:
const isEven = function (number) {
if (number === 0) {
return true;
}
return isOdd(number - 1);
};
const isOdd = function (number) {
if (number === 0) {
return false;
}
return isEven(number - 1);
};
console.log(isEven(4)); // true所以更準確地說:
函式提升不是相互遞迴成立的必要條件,但它讓函式宣告的排列順序更加自由,也允許我們在宣告位置以前呼叫函式。
有意思的是,JavaScript 對不同宣告採用了不同的處理方式:
一般函式宣告會提前建立名稱綁定,並初始化為對應的函式物件。var 會提前建立名稱綁定,並初始化成 undefined。let 和 const 會提前影響作用域,但在執行宣告以前仍處於 TDZ,不能被存取。
從歷史成因來看,Brendan Eich 曾將 var 的提升描述為函式提升、缺乏區塊作用域,以及 JavaScript 早期快速設計所產生的非預期結果。
不過,在現行 ECMAScript 規範中, var 與函式宣告已經是兩套分別被明確定義的宣告處理行為。理解程式時,我們不需要把 var 想成每次都依附著函式提升發生,而應該分別理解它們的初始化時機。
函式提升主要帶來的是程式碼排列上的彈性。你可以先寫主要流程,再把細節函式放在下面,讓程式從上到下更像是在說明「接下來要做什麼」,也比較不會為了找一個函式而把螢幕打穿。
簡單整理
Hoisting 並不是把程式碼真的搬到最上方,而是 JavaScript 在正式逐行執行前,會先建立目前作用域所需的環境,並處理變數與函式宣告。
其中:
var的名稱會先被建立,並初始化為undefined。var的賦值不會一起提前。let和const也會提前影響作用域,但宣告前處於 TDZ。- TDZ 期間存取變數,會直接發生
ReferenceError。 let執行到宣告時,即使沒有給值,也會初始化為undefined。const必須在宣告當下提供初始值。- 一般函式宣告可以在定義位置以前呼叫。
- 使用
let或const保存的函式,仍受到變數宣告規則限制。 - 使用
var保存函式時,提前呼叫可能得到TypeError,因為當下的值仍是undefined。
最後濃縮成一句話:
Hoisting 讓宣告在程式逐行執行前,就先對作用域產生影響,但不代表所有變數都能在宣告前安全使用。
了解 Hoisting 並不是為了讓我們把變數寫在使用位置後面,而是為了在看到一些反直覺的舊程式碼時,知道 JavaScript 到底做了什麼。
參考連結
Brendan Eich 在 2014 年的原始發言
MDN Web Docs:Hoisting
ECMAScript Language Specification:Let、Const 與其他 Lexical Declarations
ECMAScript Language Specification:Variable Statement(var)
MDN Web Docs:Functions-Function Hoisting
MDN Web Docs:Function Expression Hoisting