JS Hoisting 與 Scope 一起看:為什麼同一個變數,有時能用有時直接爆?

你以為變數都住同一層?

TL;DR hoisting 是 JavaScript 在執行前,先把宣告放進對應作用域的行為。 scope 是變數能在哪裡被存取的範圍。 真正讓人混亂的,不是這兩個觀念各自多難,而是它們一混在一起,varletconst 就開始各演各的。

很多人學 JavaScript 時,hoistingscope 是分開讀的。

結果讀完之後,腦袋裡還是會冒出同一個問題:

「所以到底為什麼這裡可以用,那裡不行?」

很合理。因為這兩個觀念如果拆開看,好像都還行;一合體之後,就很容易變成 JavaScript 經典節目:《你以為你懂了,其實還沒有》。

這篇就把它們綁在一起講,順便把 varletconst 的差異一起收掉。

如果你是從 TDZ 那個錯誤一路追過來,建議先讀上一篇:

JS TDZ 是什麼?搞懂 Temporal Dead Zone,別再被 letconst 偷襲

那篇先處理「為什麼宣告了還不能用」;這篇再把 hoistingscope 的整體脈絡補完整,讀起來會比較順。


hoisting 是什麼?

Hoisting 可以先粗略理解成:

JavaScript 在正式執行程式前,會先把「宣告」放進對應的作用域。

注意,是宣告先處理,不代表賦值也一起完成。

例如:

console.log(a);
var a = 10;

很多教學會把它近似理解成:

var a;
console.log(a);
a = 10;

所以結果會是:

undefined

這就是 hoisting 最常見的入門版本。


scope 是什麼?

Scope(作用域) 就是變數的可見範圍。

你可以把它想成變數的活動區域:

  • 有些變數只能在房間裡用
  • 有些變數整層樓都能用
  • 有些變數出了門就查無此人

在 JavaScript 裡,先記兩個最重要的:

  • 函式作用域(function scope)
  • 區塊作用域(block scope)

最核心的一句話

如果你只想先記一件事,記這句:

hoisting 決定「宣告什麼時候進場」,scope 決定「它可以在哪裡活動」。

這兩個一交叉,才會決定你最後拿到的是:

  • 正常值
  • undefined
  • ReferenceError

先看 var:hoist 有,區塊作用域沒有

if (true) {
  var message = "hello";
}

console.log(message);

結果:

hello

很多新手第一次看到這段都會愣一下:

「不是寫在大括號裡嗎?怎麼外面還拿得到?」

因為 var 沒有區塊作用域。 它吃的是函式作用域

也就是說,如果它不在函式裡,基本上就會跑到目前較外層的作用域去。


letconst:有區塊作用域

同樣的例子,改成 let

if (true) {
  let message = "hello";
}

console.log(message);

結果:

ReferenceError: message is not defined

這次就合理多了。 因為 let區塊作用域,變數只活在那個大括號裡。

const 也是一樣:

if (true) {
  const message = "hello";
}

console.log(message);

結果同樣會噴錯。


為什麼 var 可以先用,let 卻不行?

這就是 hoisting 跟 scope 開始聯手搞事的地方。

var

console.log(count);
var count = 5;

結果:

undefined

因為:

  1. var count 被 hoist
  2. 而且初始化成 undefined
  3. 所以你可以先讀,只是值還沒進來

let

console.log(count);
let count = 5;

結果:

ReferenceError: Cannot access 'count' before initialization

因為:

  1. let count 也會 hoist
  2. 但它在初始化前會進入 TDZ
  3. 所以你不能先讀

這也是為什麼很多人會誤以為 let 沒有 hoisting。 其實不是沒有,是它 hoist 得比較有脾氣。


function scope:var 最容易藏雷的地方

看這段:

function demo() {
  if (true) {
    var x = 100;
  }

  console.log(x);
}

demo();

結果:

100

因為 var x 雖然寫在 if 裡,但它的作用域其實是整個 demo() 函式。

你可以近似理解成:

function demo() {
  var x;

  if (true) {
    x = 100;
  }

  console.log(x);
}

這也是很多舊程式碼會出現「欸,這變數怎麼從那裡漏出來」的原因。


block scope:let / const 比較像正常人世界

function demo() {
  if (true) {
    let x = 100;
  }

  console.log(x);
}

demo();

結果:

ReferenceError: x is not defined

這才比較符合直覺: 你在 if 裡宣告的東西,就留在 if 裡,不要跑出來四處社交。

所以我自己的立場很明確:除非你在維護舊專案,不然新程式大多數情況應該優先用 const,需要重新賦值時再用 letvar 能少碰就少碰。

不是因為 var 完全不能用,而是它太容易讓作用域變得黏黏的,不好清。


函式宣告也會 hoist,而且比變數還大牌

函式宣告是另一個很容易考的點。

sayHi();

function sayHi() {
  console.log("hi");
}

這段可以正常執行,結果是:

hi

因為函式宣告本身也會 hoist,而且是整個函式內容一起進場。


但函式表達式就不是這樣了

sayHi();

var sayHi = function () {
  console.log("hi");
};

結果:

TypeError: sayHi is not a function

原因是這裡被 hoist 的只有:

var sayHi;

不是那個 function 本體。

所以執行 sayHi() 時,sayHi 還只是 undefined,你對 undefined 呼叫函式,JS 當然會不爽。

如果改成 let

sayHi();

let sayHi = function () {
  console.log("hi");
};

這次錯誤會變成:

ReferenceError: Cannot access 'sayHi' before initialization

同樣是 hoisting,但因為 let 有 TDZ,所以錯誤型態也不同。


scope chaining:內層找不到,會往外找

再看一個很重要的基本功:

const siteName = "Halfmemo";

function showName() {
  console.log(siteName);
}

showName();

結果:

Halfmemo

因為函式裡找不到 siteName,就往外層找。這叫 scope chain

這件事平常很好用,但如果你在內層又宣告同名變數,就可能出事。

const siteName = "Halfmemo";

function showName() {
  console.log(siteName);
  let siteName = "AJ Blog";
}

showName();

這段不會去拿外層的 siteName,而是直接炸 TDZ。 原因不是外層消失了,而是內層已經先宣告了自己的 siteName,只是它還沒初始化。


一張速查表看懂差異

類型會 hoist 嗎初始化時機作用域宣告前存取
varhoist 時先設成 undefined函式作用域undefined
let執行到宣告那一行才初始化區塊作用域TDZ,報 ReferenceError
const執行到宣告那一行才初始化區塊作用域TDZ,報 ReferenceError
function declarationhoist 時可直接用依所在作用域可直接呼叫

如果你要背面試題,這張表很夠用。 如果你要寫正式專案,請再多記一句:不要因為背得出表格,就以為自己永遠不會踩坑。

JavaScript 最會在你自信回升的時候補一刀。


實務上怎麼寫比較不容易亂?

1. 預設用 const

能不改值就不要改。 這樣你看到宣告時,心裡會比較有底。

const apiUrl = "/api/users";

2. 需要重新賦值時再用 let

let currentPage = 1;
currentPage += 1;

3. 避免用 var

除非你是在讀舊程式、修舊專案,或你真的非常清楚它在那段程式裡的作用域效果。

不然很多時候你以為自己省了一個選擇,實際上是幫未來的自己增加 debug 工時。


4. 變數先宣告,再使用

這條很老派,但超有效。

尤其是當程式稍微長一點,或作用域多一點,先宣告真的可以少很多莫名其妙的 hoisting / TDZ 問題。


5. 少用同名變數遮蔽外層

const userName = "AJ";

function render() {
  const displayName = "Guest";
  console.log(displayName);
}

比起在函式裡再宣告一個 userName,這種命名通常更清楚。


怎麼把 hoisting 跟 scope 一起記?

我自己的記法很簡單:

  1. 先問:這個宣告會不會先進場? 看 hoisting。
  2. 再問:它能在哪裡被看到? 看 scope。
  3. 最後問:在我使用的這個時間點,它初始化了沒? 這一步就會連到 undefined、TDZ 或正常值。

你把這三步順著想,很多題目就不再只是死背答案,而是真的推得出來。


常見問題

Q:var 為什麼這麼常被建議不要用? A:因為它只有函式作用域,沒有區塊作用域,搭配 hoisting 很容易讓變數提早存在、範圍又比你想像的大,讀程式和除錯都比較累。

Q:letconst 都有 hoisting 嗎? A:有。差別在於它們不會像 var 那樣先給你 undefined,而是先進 TDZ,初始化前不能存取。

Q:函式宣告跟函式表達式最大的差別是什麼? A:函式宣告可以在宣告前直接呼叫;函式表達式不行,因為被 hoist 的通常只是變數宣告,不是函式值本身。

Q:scope 跟 closure 一樣嗎? A:不一樣。scope 是變數可見範圍;closure 是函式即使離開原本執行環境,仍能記住外層作用域變數的能力。兩者有關,但不是同一件事。


小結

hoistingscope 單獨看都不算太可怕,可怕的是它們一起出現時,JavaScript 會突然變得很有個性。

你只要抓住幾個核心:

  • var:會 hoist、先給 undefined、只有函式作用域
  • let / const:也會 hoist,但有 TDZ,而且是區塊作用域
  • function declaration:可以提前呼叫
  • function expression:要看你用 varlet 還是 const 接它

這樣大部分「為什麼這裡可以、那裡不行」的問題,你都能自己拆開來看。

如果你剛好是從 TDZ 那篇看過來,這篇可以把整張地圖補完整。 JavaScript 不會突然變簡單,但至少它哪裡機車,你會看得比較明白。

留言區

載入中...

發表留言