JavaScript 中的 hoisting 到底是甚麼 ?
前言
寫這篇最大的一個原因在於,有一次聽到幾位大佬們在談關於 JS 中的 hoisting,也就是狀態提升,本人實在難以聽懂他們發表的看法,因此就開始上網搜搜相關文章。結果發現,這貌似不是一個一時片刻可以弄清楚的東西,於是著手開始這篇博客,希望能再攻破 JS 的又一個知識點。這篇文章不會太短,且字會較多,但還是希望大家賞個臉,看看小白我的理解吧!
正文
到底什麼是 hoisting?
廢話不多說,直接先看個例子。如果今天嘗試對一個尚未宣告的變數取值,會發生什麼事呢?
console.log(a)
// ReferenceError: a is not defined
我們發現,瀏覽器會提示你一個引用錯誤,跟你說 a
尚未定義。但是如果這樣呢?
console.log(a) // undefined
var a
怎麼會這樣?照理來說,代碼一行行運行,報錯應該是 ReferenceError 啊?為什麼是 undefined?
這就是因為 JS 的 hoisting,狀態提升。提升體現在於,var a 這一行被「提升」到了上面。所以其實 JS 引擎在解析這段代碼可以想像成這樣:
var a
console.log(a) // undefined
但是這只是想像,並不是說 JS 引擎真的搬動了代碼!
那如果我再改下代碼成這樣呢?
console.log(a) // undefined
var a = 10
一樣是 undefined,那為什麼要提呢?因為這邊要引入另一個觀念: 只有變數的宣告會提升,賦值不會。
所以上面的代碼可以想像成這樣:
var a
console.log(a) // undefined
a = 10
所以其實可以將 var a = 10分解成兩個步驟,首先,會先提升 var = a,也就是宣告部分,而第二步 a = 10 則留下來不會參與 hoisting 的過程。
目前來說,一切都還好理解,但接下來可能就會開始混亂了。看看下面這個例子:
function func(h) {
console.log(h)
var h = 3
}
func(10)
本人一開始覺得可笑,有甚麼好說的,不就是這樣?
function func(h) {
var h
console.log(h)
h = 3
}
func(10)
所以輸出當然會是 undefined 啊!結果馬上打臉,輸出了 10。
其實轉換提升的過程都對了,只是忘記了呼叫函式這步。所以其實是這樣:
function func(h) {
var h = 10
var h
console.log(h)
h = 3
}
func(10)
但還是有點奇怪,因為即使是這樣,但在取值 h 之前還是又 var h 了一次,但沒有賦值阿?還是應該是 undefined 吧?
那直接來試試看這個更直接的例子:
var a = 10
var a
console.log(a) // 10
結果發現,輸出是 10 歐。再利用上面分解的方法,其實可以看成這樣:
var a
var a
a = 10
console.log(a) // 10
這樣輸出 10,就還可以接受。當時我的感覺就是,到底什麼鬼東西,哪裡來的這麼多沒來由的規則?這樣誰搞的清楚?再忍忍,看看最後一個例子吧:
console.log(a)
var a
function a() {
}
按照上面的分解方法,想要分解成這樣:
var a
console.log(a)
function a() {
}
應該會輸出 undefined 吧?結果再次打臉,輸出 [Function: a]。這時我已經槁木死灰了。原來除了變數宣告賦值有狀態提升的概念,函數宣告也有。而且,函數宣告的狀態提升的匹配優先級還比變數宣告還高級,所以呢,其實上面的代碼應該要這樣想像:
// 優先匹配
function a() {
}
var a
console.log(a)
講了這麼多,先小小整理一下:
- 變數宣告跟函式宣告都會提升
- 變數只有宣告會提升,賦值不會提升
- 別忘了函式裡面還有傳進來的參數
let const 與 hoisting
有沒有注意到,上面在介紹 hoisting 的概念時都是用 var 這個關鍵字在宣告變數,但是,其實大家都知道 ES6 推出了 let 跟 const,且目前主流都不再推薦使用 var,改為使用 let 跟 const。對於 let 跟 const,其實對於 hoisting 這個概念差不多,在這邊拿 let 看看些例子。
console.log(a)
let a
// ReferenceError: a is not defined
神奇的事情發生了!這樣子輸出竟是 ReferenceError: a is not defined。不過,其實這樣比較符合常理吧?那是不是說用 let 宣告函數就都沒有 hoisting 這回事了?如果是這樣就太好了,可惜,並不是的。看看下面例子:
var a = 10
function func() {
console.log(a)
let a
}
如果說 let 都沒有狀態提升,那這個輸出應該是 10 吧?因為外面有 var a = 10,然後 let a 也沒有提升。再次再次被打臉,輸出是 ReferenceError: a is not defined。所以說其實 let 也是有提升的,只是可能 let 提升的過程行為跟 var 不太一樣,所以乍看下像是沒有提升。至於到底怎麼運作,回頭再來討論。
在這邊先暫停一下。如果只是想要稍微了解下 hoisting 大概是個什麼東西的人,其實到這邊就可以停了,因為其實好好使用 let const,然後好好聲明賦值,其實也不見得要了解太多,也不會遇到甚麼問題。但如果想要了解得更透徹,那就跟著我堅持下,繼續看下去。接下來,我們先討論兩個關於 hoisting 重要的問題。
- 為什麼要有 hoisting?
- hoisting 到底是怎麼運作的?
為什麼要有 hoisting?
回顧上面所說的有關 hoisting 的一些規則和概念,我們可以感覺到一些它帶來的好處。回答這個問題,可以從反面來思考:「如果沒有 hoisting 會怎樣?」
- 沒有 hoisting 的話,我們就必須在使用某個變數前,一定宣告這個變數。但這其實很好啊,畢竟大家編程時就是這麼寫的吧,你不會因為想到 JS 有 hoisting 的機制,所以就不宣告變量就直接使用吧?
- 沒有 hoisting 的話, 那就也規定說,我們在使用一個函式時,它一定要在上面被宣告定義過。乍看下來好像也沒什麼毛病,但其實是有點麻煩的。因為,這就意味著,除非把每一個函式都放在最上面,才能完全確保下面呼叫任何函式都可以正常執行。
- 最後一個點較為有趣。沒有 hoisting 的話,那我們就不能在不同函式之間呼叫對方了。這什麼意思?看看下面的代碼:
function loop_1() {
console.log('loop 1')
loop_2()
}
function loop_2() {
console.log('loop 2')
loop_1()
}
這段代碼不難理解,反正就是 loop_1 跟 loop_2 相互呼叫。但是有個問題,沒有 hoisting 的話,怎麼可能 loop_1 在 loop_2 上面,而同時,loop_2 也在 loop_1 上面。沒有 hoisting 的話,這段代碼不可能可行。
所以,hoisting 就是為了要解決這些問題的!
hoisting 到底是怎麼運作的?
首先,要先介紹一個概念,那就是 JavaScript 的 Execution Context,以下用 EC 做簡稱。EC 的概念是每次進入一個函式,這個函式就會有一個 EC,然後會將這個 EC 壓到棧中,當函數執行完畢,該 EC 就會被 pop 出來。
網上找到了一個很清楚的示意圖:
注意,最下面還有一個全局的 EC (粉色部分)。
總的來說,EC 就是存著各自函數的信息,當函數需要什麼東西,就是去自己的那個 EC 找。
有了 EC 的概念後,進入重點。每個 EC 都有相對應的 VO(Variable Object)。這個 VO 就是存儲所有信息的東西,包含該函數裡的變量,函數,還有函數裡面的參數。查找 VO 的機制就是說,以上面 var a = 10 為例,第一步就是先去 VO 裡新增一個屬性 a,再來再找到名為 a 的屬性,並設定成 10。
Step1: var a
Step2: a = 10
那一個函數裡面那麼多東西,他是怎麼放進每個 EC 的 VO 呢?規則就是,對於參數,它會直接被放到 VO 裡面去,如果有些參數沒有值的話,那它的值會被初始化成 undefined。看下面這個例子:
function func(a, b, c) {
...
...
}
func(10)
上面這個函數以及呼叫,它的 VO 會是下面這個樣子:
// VO
{
a: 10,
b: undefined,
c: undefined
}
對於函數裡面如果又有函數宣告,一樣也是加入到 VO 哩,沒什麼問題。但是萬一函數的名字,跟某一個變量名重名了呢?像下面這樣:
function func(a) {
function a() {
...
...
}
}
func(10)
上面這個函數以及呼叫,它的 VO 會是下面這個樣子:
// VO
{
a: function a
}
所以可以知道,函數宣告會優先於變數宣告,就像上面例子一樣,參數 a 被 函數 a 覆蓋掉了。
對於函數內部的變數宣告則會最後放進 VO,如果 VO 裡已經有重名的屬性了的話,直接忽略這個變量,原有的值也不會被改變。
概括一下,我們可以把上面所提到的 VO 的動作想成是要執行一個函數以前的前置作業。順序如下:
Step1: 把參數放進 VO,然後看看有無傳入參數。按照參數聲明次序依序匹配,如果沒有被匹配到,會被賦值為 undefined。
Step2: 去找函數裡面的成員方法,也就是其他函數,然後把它也放進 VO,如果跟當前 VO 中的任一屬性重名,就把舊的覆蓋掉。
Step3: 最後才去找函數裡面的變量宣告,並放進 VO 裡。如果跟當前 VO 中的任一屬性重名,會以當前狀態為主。
講了這麼多,回來看看上面一個我們提到的例子:
function func(h) {
console.log(h)
var h = 3
}
func(10)
所以每個函數的執行其實可以分成兩個階段。首先,會先進入該函數的 Execution Context,接著要開始準備自己的 VO。對於上面的例子,首先因為呼叫時有傳參,因此會先在 VO 裡宣告一個叫做 h 的變量,且賦值為 10。接著因為函數裡沒有找到成員函數,所以不變。最後找到 var h = 3,它是一個變量聲明的語句,所以要把它加入到 VO 哩,但是因為此時的 VO 已經以一個叫做 h 的變量了,所以 VO 不變。至此,這個函數的 VO 都建立完成了。
// VO
{
h: 3
}
建立完 VO 後,就開始執行這個函數。運行到 console.log(h) 時,查找 VO 發現有一個叫做 h 的變量,且值為 10,所以輸出了 10。所以上面的問題得到了解答,確實是輸出 10 沒錯!
如果把代碼改成這樣呢?
function func(h) {
console.log(h)
var h = 3
console.log(h)
}
func(10)
那麼第一次輸出將會是 10,而第二次輸出會是 3。其實建立 VO 的過程也都跟上面一樣,所以執行時第一次輸出確實是 10 沒問題。而因為執行到第 3 行時,它又去更改了 VO 裡的 h,因此第二次輸出就當然會是 3 啦!
結語
終於寫完了這篇博客,其實心裡還是小有成就感的。說真的,搞懂 JS 裡的這個 hoisting(狀態提升) 似乎不會對編程有太大的幫助,但想著以後如果面試被問到了,應該能說出點東西挺好的,畢竟感覺不是大家都有搞明白這個知識點。在這邊要感謝 GitHub 上的這篇博文章 我知道你懂 hoisting,可是你了解到多深?。本人也是參考這篇理解後,才完成了自己的博客,如果想要瞭解更深,歡迎前往閱讀。對於想要了解 hoisting(狀態提升) 的人們,希望看完這篇能有所收穫,也歡迎大神們多多指教啦!