夜行手札

技術成長 · Web 開發 · JavaScript

JavaScript 的 Hoisting 是什麼?提升會讓程式自己往上跑嗎?

提升提升提升。
12 分鐘閱讀

上一篇我們提到了 varletconst 的差異,但其中有一個很容易讓人困惑的現象:

JavaScript
console.log(age);

var age = 20;

照一般從上到下的閱讀順序來看,age 明明還沒宣告,理論上應該直接發生錯誤。

但實際執行後,得到的卻是:

text
undefined

這個現象,就和 JavaScript 的 Hoisting 有關。

---

什麼是 Hoisting?

Hoisting 通常翻譯成「提升」或「變數提升」。

它可以先理解成:

JavaScript 在正式逐行執行程式以前,會先處理目前作用域中的變數與函式宣告。

不過需要特別注意的是,JavaScript 並不是真的把程式碼拿下來,再丟到檔案的最上面去。

Hoisting 比較像是一種用來描述執行結果的說法:程式碼雖然沒有真的移動,但某些宣告會在執行到那一行以前,就先對目前作用域產生影響。

下面這段程式:

JavaScript
console.log(age);

var age = 20;

可以暫時用下面的方式理解:

JavaScript
var age;

console.log(age);

age = 20;

JavaScript 先知道有一個叫做 age 的變數,但執行到 console.log(age) 時,20 還沒有被放進去,因此結果是 undefined

Tips:宣告和賦值不是同一件事

下面這一整行其實包含了兩個動作:

JavaScript
var age = 20;

第一個動作是宣告:

JavaScript
var age;

第二個動作是賦值:

JavaScript
age = 20;

在這個 var 範例中,提前產生影響的是變數宣告,不包含右邊的賦值,因此 20 不會一起提前出現。

---

Tips:undefined 是什麼?

在目前這個 var 範例中,undefined 可以理解成:

這個變數已經存在,但目前還沒有被放入我們原本準備給它的值。

它和「變數完全不存在」這件事並不一樣喔。

例如:

JavaScript
console.log(age); // undefined

var age = 20;

這裡的 age 已經存在,只是還沒有取得 20

另外一種未定義是像這樣,下面這個變數從頭到尾都沒有被宣告:

JavaScript
console.log(catName);
// ReferenceError: catName is not defined

所以,兩者雖然看起來都是「沒有拿到想要的值」,但背後的原因並不相同。

---

JavaScript 執行程式時,概念上做了什麼?

稍微深入一點來看,我們可以把 JavaScript 的處理過程大略想成兩個階段:

  1. 先建立目前程式執行需要的環境。
  2. 按照程式碼順序逐行執行。

在建立環境時,JavaScript 會先處理變數名稱、函式宣告和作用域關係。

等到逐行執行時,才會真的進行 console.log()、運算、函式呼叫和資料賦值。

這也是為什麼:

JavaScript
console.log(age);

var age = 20;

JavaScript 在第一行就已經知道 age 是誰,但當下還沒有執行到 age = 20,所以只能得到 undefined

不過這裡的「兩個階段」主要是方便理解的簡化模型,不代表 JavaScript 引擎真的會先把原始碼重新排版一次。

---

var 被提升後會發生什麼?

使用 var 宣告的變數,在正式逐行執行前就會先被建立,並且預先初始化為 undefined

JavaScript
console.log(meow); // undefined

var meow = 20;

console.log(meow); // 20

抽象嗎?沒關係,整理一下,它的執行概念就可以拆成:

JavaScript
var meow;

console.log(meow); // undefined

meow = 20;

console.log(meow); // 20。里ㄓㄚˇ

所以第一個 console.log() 不會報錯,只會得到 undefined
第二個 console.log() 就會得到 20 了。

這也是 var 容易讓人困惑的地方。

程式看起來像是可以在宣告前使用變數,但當下拿到的其實不是預期的資料。

---

letconst 也會 Hoisting 嗎?

會,但這個說法需要補充一下。

letconst 也會在程式執行前建立對應的名稱,只是它們不像 var 一樣,會先被初始化為 undefined

JavaScript
console.log(dennis);

let dennis = 20;

在這裡,這段程式會直接發生錯誤:

text
ReferenceError: Cannot access 'dennis' before initialization

const 也一樣:

JavaScript
console.log(siteName);

const siteName = "夜行手記";

報錯:

text
ReferenceError: Cannot access 'siteName' before initialization

為什麼會這樣?

原因是在程式執行到正式宣告以前,letconst 會處於一個叫做 Temporal Dead Zone 的階段。
letconstclass 宣告的 TDZ,會從區塊開始持續到程式執行到宣告及初始化的位置。

---

什麼是 Temporal Dead Zone?

Temporal Dead Zone 簡稱 TDZ,中文可以翻譯成「暫時性死區」。

名字聽起來非常帥,而且似乎有點嚴重,但其實概念並沒有那麼可怕。

可以把它理解成:

JavaScript 已經知道這個變數存在,但在程式執行到宣告位置以前,不允許你使用它。

請看以下簡單範例:

JavaScript
{
    // dennis 的 TDZ 開始

    console.log(dennis); // 錯誤

    let dennis = 20;

    // 執行到宣告後,就可以正常使用了
    console.log(dennis); // 20
}

這裡的 TDZ 是從 { 區塊開始,而不是從 console.log(dennis) 那一行才開始。

直到 JavaScript 執行:

JavaScript
let dennis = 20;

完成初始化後,dennis 才能被正常使用。

Tips:TDZ 是看執行順序,不是只看程式碼位置

TDZ 裡的「Temporal」指的是時間。

也就是說,重點不只是變數寫在檔案的第幾行,而是程式執行時,是否已經走到宣告及初始化的位置。

例如:

JavaScript
function showDennis() {
    console.log(dennis);
}

let dennis = 20;

showDennis(); // 我在這裡才真正呼叫,所以拿得到20。

雖然 showDennis() 函式裡使用 dennis 的程式碼,看起來寫在宣告以前,但函式真正被呼叫時,dennis 已經完成初始化,所以可以正常取得 20

---

Tips:為什麼要有 TDZ?

TDZ 可以避免我們在變數還沒準備好以前就使用它。

相較於 var 默默回傳 undefinedletconst 直接報錯,反而更容易讓開發者及早發現程式寫錯的位置。

---

如何證明 let 在宣告前就已經影響作用域?

可以看下面這個例子:

JavaScript
const meow = "外面的 meow";

{
    console.log(meow);

    let meow = "裡面的 meow";
}

有人可能會想:

裡面的 let meow 還沒執行,第一個 console.log() 應該會找到外面的 "外面的 meow" 吧?

實際上不會。

程式會直接出現 ReferenceError

因為區塊裡面的 let meow,從這個區塊開始時就已經影響名稱查找。

JavaScript 知道目前區塊裡面有自己的 meow,因此不會跑到外層尋找另一個 meow;但內層的 meow 當下仍處於 TDZ,於是就報錯了。

這個例子也能說明,為什麼有人會認為 letconst 其實也存在某種形式的 Hoisting,因為即使還沒執行到宣告行,它們就已經開始影響整個區塊了。

---

typeof 遇到 TDZ 也救不了你

一般來說,對一個完全沒有宣告過的名稱使用 typeof,不會發生錯誤:

JavaScript
console.log(typeof ghost); // "undefined"

但如果這個名稱是使用 letconst 宣告,而且目前仍然位於 TDZ,就不一樣了:

JavaScript
{
    console.log(typeof dennis); // ReferenceError

    let dennis = 20;
}

雖然使用的是平常相對安全的 typeof,但它仍然不能突破 TDZ。

原因是 dennis 並不是一個完全不存在的名稱,而是一個已經屬於目前區塊、卻尚未完成初始化的變數。

---

varletconst 的 Hoisting 差異

可以先整理成下面這張表:

宣告方式是否會提前影響作用域宣告前使用的結果
var得到 undefined
let發生 ReferenceError
const發生 ReferenceError

所以常聽到有人說:

var 會 Hoisting,但 letconst 不會。

這句話不完全正確,主要是看文章如何定義 Hoisting。

比較完整的說法是:

var 的名稱會先建立,並預先初始化成 undefinedletconst 的名稱也會提前影響目前作用域,但在執行到宣告以前仍然處於尚未初始化的狀態,因此不能被存取。

順便小補充一下,簡單說 var 除了提升,還順便預先初始化為 undefined,而 letconst 則是只建立名稱,不會立刻完成初始化。所以囉,它們在宣告前就已經影響了目前作用域。

有些文章會因此把 letconst 稱為「不會 Hoisting」;也有些文章認為,它們既然會提前影響作用域,就屬於另一種 Hoisting 行為。

兩種文章對名詞的使用方式可能不同,但實際執行結果沒有不同:

JavaScript
console.log(dennis);

let dennis = 20;

在宣告前使用 dennis,就是會發生 ReferenceError

---

letconst 的初始化也有一點不同

下面這段程式是合法的:

JavaScript
let age;

console.log(age); // undefined

當 JavaScript 執行到:

JavaScript
let age;

age 的 TDZ 就結束了。即使我們沒有手動給值,它仍會在這時被初始化為 undefined

但是 const 不行:

JavaScript
const age;
// SyntaxError

在一般的 const 變數宣告中,必須在宣告當下提供初始值。

JavaScript
const age = 20;

這不是因為 const 沒有 TDZ,而是因為 const 建立後不能重新賦值,所以必須在宣告當下就完成初始化。

---

函式也會 Hoisting 嗎?

使用一般函式宣告時,可以在函式寫出來以前先呼叫:

JavaScript
sayGoodNight();

function sayGoodNight() {
    console.log("晚安,夜行者。");
}

這段程式可以正常執行。

因為 JavaScript 在正式逐行執行前,就已經先建立了 sayGoodNight 這個函式宣告。

而且函式宣告不只是名稱先出現,連完整的函式內容也已經可以使用,所以才能在定義位置以前正常呼叫。

但如果函式是放在 const 變數裡,欸那情況就不同了:

JavaScript
sayGoodNight();

const sayGoodNight = function () {
    console.log("晚安,夜行者。");
};

// ReferenceError: Cannot access 'sayGoodNight' before initialization

這段程式就會發生錯誤了(what!??)。

原因就是 sayGoodNight 使用了 const 宣告,在執行到那一行以前仍然處於 TDZ 階段。

請看以下。

---

函式宣告與函式表達式

下面這種寫法叫做函式宣告:

JavaScript
function sayHello() {
    console.log("Hello");
}

函式宣告的名稱和完整函式內容,會在逐行執行前就先建立,因此可以提前呼叫。

下面這種寫法則是把函式當成一個值,放進變數裡:

JavaScript
const sayHello = function () {
    console.log("Hello");
};

這種寫法叫做函式表達式。

第二種寫法就變成會受到 const 宣告規則的限制。

也就是說,被提前處理的是 sayHello 這個變數名稱,不是右邊的函式內容。

---

Tips:如果函式表達式搭配 var 呢?

來看另一種寫法:

JavaScript
sayHello();

var sayHello = function () {
    console.log("Hello");
};

它同樣不能正常呼叫,但錯誤和 const 不一樣。

概念上可以拆成:

JavaScript
var sayHello;

sayHello();

sayHello = function () {
    console.log("Hello");
};

執行 sayHello() 時,sayHello 的值還是 undefined

因此常見的錯誤會是:

text
TypeError: sayHello is not a function

這裡不是找不到 sayHello,而是找到了它,但它目前是 undefined,並不是一個可以呼叫的函式。

函式表達式不會像函式宣告一樣,讓右側的完整函式內容提前可用。

---

ReferenceErrorTypeError 差在哪裡?

剛才出現了兩種不同錯誤:

text
ReferenceError

和:

text
TypeError

可以先用簡單的方式理解:

ReferenceError

代表 JavaScript 無法取得一個目前可以使用的變數或函式。

常見原因有兩種。

第一種是這個名稱從頭到尾都沒有被宣告:

JavaScript
console.log(dennis);
// ReferenceError:dennis 並沒有被宣告

第二種是這個名稱已經存在於目前作用域中,但仍處於 TDZ,現在還不能使用:

JavaScript
console.log(dennis);

let dennis = 20;
// ReferenceError:dennis 還沒有完成初始化

這兩種情況都會發生 ReferenceError ,但原因不太一樣:

第一種是真的找不到 dennis
第二種是知道 dennis 存在,但他現在還不能出來。

TypeError

TypeError 代表 JavaScript 已經找到了這個值,但你對它做了不符合目前型別的操作。

JavaScript
let sayHello;

sayHello();
// TypeError:sayHello 現在是 undefined,不是函式,不能呼叫

在這裡,JavaScript 找得到 sayHello,所以不是 ReferenceError

真正的問題是 sayHello 目前的值為 undefined,但程式卻使用 () 把它當成函式呼叫,因此發生 TypeError

白話來說:

  • ReferenceError:找不到人,或知道這個人存在,但他現在還不能出來。
  • TypeError:人找到了,但你叫他做一件他根本做不到的事。

---

為什麼實務上不建議依賴 Hoisting?

雖然 JavaScript 允許某些變數或函式在宣告前使用,但不代表這樣寫比較好。

JavaScript
console.log(total);

var total = 100;

這段程式可以執行,卻需要額外思考:

  • total 是在哪裡宣告的?
  • 為什麼現在是 undefined
  • 是故意這樣寫,還是漏掉了什麼?
  • 然後腦子就有點亂了。

所以,養成良好的習慣,讓大家都清楚明白的方式是先宣告,再使用:

JavaScript
const total = 100;

console.log(total);

程式的閱讀順序和執行順序一致,之後除錯與維護也會比較容易。

一般函式宣告雖然可以提前呼叫,但實務上是否這樣安排,可以依程式結構決定。

例如先把主要流程放在上面、細節函式放在下面,有時反而能讓閱讀更順:

JavaScript
startApp();

function startApp() {
    console.log("程式開始執行");
}

重點不是看到任何 Hoisting 都要禁止,而是不要依賴難以閱讀、容易造成誤會的執行結果。

---

所以函式提升帶來了什麼好處?

第一個好處,是函式宣告的排列順序會比較自由。

JavaScript 在正式逐行執行目前作用域以前,就會先建立一般函式宣告,讓函式名稱直接指向對應的函式物件。因此,我們可以先在上方寫主要流程,再把負責細節的函式放在下面:

JavaScript
startApp();

function startApp() {
    loadUserData();
    showHomePage();
}

function loadUserData() {
    console.log("讀取使用者資料");
}

function showHomePage() {
    console.log("顯示首頁");
}

這種寫法可以讓讀者先看到程式主要要做什麼,再往下查看每個函式的實作細節。

如果所有函式都必須寫在使用位置以前,主要流程可能會被大量細節推到檔案最下方。當函式越來越多時,為了找主要流程一直上下捲動,會讓你想把螢幕打穿XD。

第二個好處,是在使用函式宣告進行相互遞迴(Mutual Recursion)時,不必太在意兩個函式的排列順序。

Tips:什麼是相互遞迴?

一般遞迴,是函式呼叫自己;相互遞迴則是兩個以上的函式互相呼叫。

例如,下面使用兩個函式判斷數字是奇數還是偶數:

JavaScript
// 以下範例假設傳入的是大於或等於 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 才能做到。

只要等所有函式都初始化完成後再呼叫,即使使用函式表達式也可以互相呼叫:

JavaScript
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
letconst 會提前影響作用域,但在執行宣告以前仍處於 TDZ,不能被存取。

從歷史成因來看,Brendan Eich 曾將 var 的提升描述為函式提升、缺乏區塊作用域,以及 JavaScript 早期快速設計所產生的非預期結果。

不過,在現行 ECMAScript 規範中, var 與函式宣告已經是兩套分別被明確定義的宣告處理行為。理解程式時,我們不需要把 var 想成每次都依附著函式提升發生,而應該分別理解它們的初始化時機。

函式提升主要帶來的是程式碼排列上的彈性。你可以先寫主要流程,再把細節函式放在下面,讓程式從上到下更像是在說明「接下來要做什麼」,也比較不會為了找一個函式而把螢幕打穿。

簡單整理

Hoisting 並不是把程式碼真的搬到最上方,而是 JavaScript 在正式逐行執行前,會先建立目前作用域所需的環境,並處理變數與函式宣告。

其中:

  • var 的名稱會先被建立,並初始化為 undefined
  • var 的賦值不會一起提前。
  • letconst 也會提前影響作用域,但宣告前處於 TDZ。
  • TDZ 期間存取變數,會直接發生 ReferenceError
  • let 執行到宣告時,即使沒有給值,也會初始化為 undefined
  • const 必須在宣告當下提供初始值。
  • 一般函式宣告可以在定義位置以前呼叫。
  • 使用 letconst 保存的函式,仍受到變數宣告規則限制。
  • 使用 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