Day 24 講 Scope 時,我們留下一個函式:
sayHello()
function sayHello() {
console.log('Hello')
}
照我平常讀程式的方式,JavaScript 應該是從上往下跑。
那第一行執行 sayHello() 的時候,下面那個 function 明明還沒走到,為什麼可以呼叫?
很多教學會回答:
因為 Hoisting,JavaScript 會把宣告搬到最上面。
但如果真的只是「搬到最上面」,那這段又要怎麼解釋?
sayHello()
const sayHello = () => {
console.log('Hello')
}
一樣都是函式。
一個可以先呼叫,一個卻不行。
所以今天我真正想問的是:
Hoisting 到底發生了什麼?
比較接近的畫面是:
真正開始一行一行執行以前
↓
JavaScript 先建立這個 Scope 會用到的名稱
↓
不同宣告方式,建立後的初始狀態不同
↓
才開始照原本順序執行程式
所以「Hoisting」比較像我們看到的現象名稱。
sayHello()
function sayHello() {
console.log('Hello')
}
結果可以正常執行。
如果只看執行順序,很像:
第 1 行
sayHello()
第 3 行
才出現 function sayHello
但在真正執行第一行以前,JavaScript 已經先處理這個 Scope 裡的宣告。
對 function declaration 來說,可以先把它想成:
建立 sayHello 這個名稱
↓
而且已經讓它指向那個 function
↓
開始執行程式
所以走到:
sayHello()
時,這個名稱已經找得到,而且已經是可呼叫的 function。
原始碼仍然是:
sayHello()
function sayHello() {
console.log('Hello')
}
不是偷偷變成:
function sayHello() {
console.log('Hello')
}
sayHello()
第二段只是很常見的教學比喻。
它可以幫忙記結果,但如果把比喻當成真的,就會在 let、const、var 出現時開始亂掉。
看這段:
console.log(count)
var count = 10
它不是直接說 count 完全不存在。
常見結果反而是:
undefined
明明:
var count = 10
還沒執行,為什麼 count 已經存在?
因為在進入執行階段以前,var 的名稱也已經先被建立。
但它和 function declaration 不一樣。
可以先粗略想成:
var count
↓
名稱先建立
↓
初始值先是 undefined
↓
真正執行到 count = 10
↓
才變成 10
所以:
console.log(count)
var count = 10
和:
console.log(count)
let count = 10
結果會不一樣。
這裡最容易被一句「let / const 不會 Hoist」帶歪。
看:
console.log(count)
const count = 10
它會出錯。
於是很容易得到結論:
const 沒有 Hoisting。
但更精確的理解是:
const 的名稱在這個 Scope 建立時也已經被處理,只是在執行到宣告那一行以前,不能被使用。
這段從 Scope 開始,到實際執行宣告之前的區域,常被稱為:
Temporal Dead Zone
TDZ
所以它不是:
count 完全不存在
而比較像:
count 這個 binding 已經建立
↓
但現在還不能存取
↓
執行到 const count = 10
↓
完成初始化
↓
之後才能使用
把兩個放在一起:
console.log(a)
var a = 10
可以先想成:
建立 a
↓
a = undefined
↓
開始執行
↓
console.log(a)
→ undefined
↓
a = 10
而:
console.log(b)
const b = 10
比較像:
建立 b
↓
尚未初始化
↓
開始執行
↓
console.log(b)
→ 不能存取
↓
執行 const b = 10
↓
完成初始化
這樣比「var 會 Hoist,const 不會」更能解釋實際行為。
回到 Day 23:
const sayHello = function () {
console.log('Hello')
}
或:
const sayHello = () => {
console.log('Hello')
}
這兩種寫法本質上都有一件事:
先有一個變數 sayHello
↓
再把 function value 放進去
所以如果我寫:
sayHello()
const sayHello = () => {
console.log('Hello')
}
真正卡住的不是「arrow function 不會 Hoist」。
而是:
sayHello 是 const binding
↓
執行到宣告以前仍在 TDZ
↓
不能先存取
這突然把 Day 23 和 Day 25 接起來了。
現在可以畫成:
function declaration
function sayHello() {}
建立 Scope 時
↓
sayHello 已經能對到 function
↓
所以前面可以呼叫
對比:
function expression / arrow function
const sayHello = () => {}
建立 Scope 時
↓
先處理 const sayHello
↓
但還沒執行到初始化
↓
不能先使用
所以差異不是:
一個是 function,一個不是 function。
它們最後都可以得到 function value。
真正不同的是:
這個 function value 是用哪一種宣告方式綁到名稱上的?
Day 24 才剛學:
Scope 決定某個名稱在哪裡找得到。
Day 25 其實是在問更前面一步:
進入這個 Scope 時,這些名稱是什麼時候建立的?
所以可以串成:
進入 Scope
↓
建立這個 Scope 的 bindings
↓
不同宣告有不同初始化狀態
↓
開始執行程式
↓
查找名稱時沿 Scope Chain 尋找
Scope 和 Hoisting 原來不是兩個互不相干的章節。
它們都在處理:
名字什麼時候存在?
名字現在能不能用?
名字要去哪裡找?
hello()
function hello() {
console.log('hello')
}
預期:可以呼叫。
console.log(a)
var a = 10
預期:先看到 undefined。
console.log(b)
let b = 10
預期:在初始化前存取會出錯。
hello()
const hello = () => {
console.log('hello')
}
預期:在初始化前存取 hello 會出錯。
光看這四段,就比背一句「Hoisting 是往上搬」完整很多。
不是。
這是一個幫助理解現象的說法,不是原始碼真的重新排列。
太粗略。
它們的 binding 會在 Scope 建立時被處理,但在宣告執行完成前處於不能存取的狀態。
不完全是。
關鍵要看它綁在 var、let 還是 const 上。
不是。
那是它在執行真正賦值前的初始狀態。
Hoisting 不要想成「把程式碼搬到上面」,而要想成「JavaScript 在執行程式以前,會先建立這個 Scope 裡的名稱;不同宣告的初始化方式不一樣」。
這樣記:
function declaration
→ 名稱與 function 都先準備好
var
→ 名稱先準備好,初始是 undefined
let / const
→ 名稱先建立,但初始化前不能使用