提升到哪去?詳解Hoisting | Javascript鍛鍊日記

CHC1024
狗奴工程師
Published in
19 min readMay 16, 2020
Photo by Franck V. on Unsplash

前言

上次說好的hoist教學終於生出來了,因為前陣子都在自己鑽研一些React方面的東西,像是自己寫一個套件等等,花了不少時間與精力在上面,導致這篇文的撰寫可以說是被我擱置在一旁長灰塵(被打死)。

工商一下:我寫了一個React的日期&時間選擇器,有興趣的讀者可以點進去看一下,目前還在持續更新中,有任何建議也可以在底下回應給我知道。

總之,這篇文就是要帶你了解什麼是hoisting,以及他對程式撰寫有何影響。

接下來的內容會包含以下幾點:

1. 何謂hoisting

2. Hoisting的影響

3. 規格上的hoisting

4. 實作上的hoisting

基本上,大部分你只需要知道前面兩點即可,因為hoisting是來自於Javascript引擎處理程式碼時產生的side effect(後面會提到)。一般只要你「正常」撰寫程式碼,你不太會注意到hoisting的存在。

因此,如果你只是想知道hoisting的行為,可以只閱讀前面兩大點;而若你對於hoisting背後的原理、提升的優先權有興趣,可以進而閱讀後面幾點。

那話不多說了,我們趕緊開始介紹hoisting吧!!

第一課:何謂hoisting

首先,對於Javascript來說,當我們存取一個尚未宣告的變數,通常會出現以下錯誤:

是的,他會拋出一個「存取錯誤」的錯誤,也就是因為你還沒定義foo,記憶體空間也就不會有他的位置。

不過勒,Javascript總是給予我們驚喜。如果你這麼寫,就會發生一件神奇的事情:

OKOK,這下真的尷尬了。看起來在Javascript中並不像C或是JAVA那樣,是一行一行照著順序在執行程式,反而有種把變數宣告擺到最上層的感覺。

如果你有這樣的感覺,那很好,恭喜你發現了hoisting。那如果沒有感覺,記得要吃徐X寧(誤)。

總之,當你存取一個變數或是函式時,只要能夠找到他的宣告,都不會拋出存取錯誤,因為Javascript幫你把這些宣告「提升」到最上層去。

在找宣告的時候是利用scope chain來找,關於scope chain,我會再寫一篇文來解釋,這邊就先不做介紹以免篇幅太大。

借用上述的範例,你大概可以把hoisting想像成這樣:

Javascript引擎在處理敘述(statement)時,會把 var foo = ‘bar’分成兩個步驟:宣告與賦值。而提升的對象只會是宣告,賦值則是會留在原地不動。

因此,給一個小口訣:「宣告提升,賦值不動」。但是千萬記住,提升是邏輯上的搬動,而不是真的把程式碼搬移到上層。

那剛剛說到不只是變數,就連函式也會跟著被提升,馬上來一道範例:

好像很簡單對不對?那我們試著加入參數看看會有什麼不同:

根據我們前面所說的:「宣告提升,賦值不動」,可以將上面的程式碼轉換成下面的形式:

這時你可能會大喊:「我知道了,所以是undefined對吧?」。如果你認為是undefined,很可惜地,這個答案是錯的。其實真正的答案是number,也就是說當下的v是一個數字。

會得到undefined就代表你忘記了一個最原始的東西:參數。若傳進來的參數剛好跟新宣告的參數同名,我們可以把這些參數看作是新宣告一個變數在函式的頂端,像是這個樣子:

當然,這是想像的狀態,真實的作法不是這樣(後面會提到)。只是假設你是第一次看到,給一個比較好理解的方式而已。

總而言之,hoisting的概念先在這裡打住,統整一下前面講的:

  1. Hoisting 會提升變數與函式
  2. 宣告提升、賦值不變
  3. 要記得參數的存在,而且優先權較高

第二課:Hoisting帶來的影響

Hoisting並不是所有程式語言都具有的特性,像是C與JAVA就沒有。那你可能就會問:「那麼hoisting的影響是什麼?」。

對於這樣的疑問,我自己是覺得有三個大方向。

第一,我們可以不用先宣告變數,事後補齊即可。

個人認為這是一個缺點,因為一般撰寫程式碼,我們應該是先宣告好變數才能在後面使用它。假如我們養成先上車後補票的習慣,往後維護程式碼時,反而可能會發現到處都在宣告變數,造成可讀性降低且管理上的麻煩。

所以強烈建議讀者不要把變數提升當作是語法特性的一部分,而是某種警惕。

第二,函式宣告可以擺在後面。

這點老實說解決了一個問題,就是我們必須先宣告函式才能使用。以邏輯來說,的確是必須先有函式,呼叫他才會有意義。但對於開發者來說反而蹩腳,畢竟你一個檔案也許有十幾個函式要用,要是得先宣告在最上面,檔案看起來頭就很大(工程師頭也很大QQ)。

所以hoisting的機制使開發者在使用函式的體驗變得更好,這點我認為是一大優點。

第三,承上,我們可以做到互相呼叫函式。

不只是解決函式必須先宣告的困擾,更有趣的影響是我們能做到兩個函式互相呼叫。

舉個例子:

上面的範例中,doubleNumberincreNumber在尚未超過200之前會不斷互相呼叫。如果hoisting沒有被Javascript採用,那麼我們就不可能在宣告時就做到「兩者互相在對方之上」的狀況。

當然,在C裡面有一種方式是先宣告,底下再補充函式的內容。但原生的Javascript並不支援這樣的宣告模式,所以引入hoisting是有其必要性的。

在這三點中,我認為最具有影響性的是第三點,也就是賦予函式互相呼叫的能力。而Javascript的作者也曾在Twitter中做出相關的回應:

這段推文的起源可以參考這篇文

總結一下:

  1. Hoisting帶來兩個優點與一個缺點
  2. Hoisting最初的設計是為了實現函式互相遞迴,以及避免在檔案開頭得先宣告一堆函式,進而提升開發者體驗。

第三課:規格上的Hoisting

在介紹完hoisting的概念與影響,我們就稍微來探討一下hoisting在規格書上是怎麼被描述的吧!

關於規格書的部分,我是參考自《我知道你懂hoisting,可是你了解到多深?》,該篇作者引用ES3的規格書內容,所以接下來的內容都是以ES3為主。

簡單地來說,在規格書上是找不到hoisting這個關鍵字的,因為hoisting是由兩個概念組成的:Execution Context與Variable Object。

Execution Context

簡單介紹一下什麼是Execution Context(接下來都稱為EC)。

對於每一個function或是全域,他們都是一個「執行環境」,也就是可以執行某一段程式碼的地方。

在這些執行環境中,JS引擎會儲存有關於這個環境的相關資訊,例如:this、全域物件(像是window)、outer environment與該環境的程式碼。

當執行程式碼時,會使用一個stack叫做execution stack,裡面會存放許多EC。

一旦程式執行了一個function,JS就會把這個function所對應的EC堆放到stack最上層,大概會長的像這樣:

待在最上層的就會是當前所執行的function,最下層會保有一個global EC,代表全域執行環境。

如果不清楚我在說什麼,這裡給一段程式碼:

根據執行的順序,我們可以模擬stack的擺放過程如下:(數字代表執行順序)

可以很清楚地看到,因為Hi並沒有再呼叫任何函式,所以一執行完就會pop出stack,接著換執行Hey與Yay。

總之,每次呼叫function都會產生一個EC,並堆疊到stack上面再執行。

講完EC你可能還不懂跟今天的主題有什麼關連,先別急,接下來的這個東西才是真正主宰hoisting的角色,那就開始吧!

Variable Object

根據ES3規格書第十章中記載的這一段:

Every execution context has associated with it a variable object. Variables and functions declared in the source text are added as properties of the variable object. For function code, parameters are added as properties of the variable object.

簡單來說,就是每個EC會配一個variable object(以下皆稱VO),裡面會存放該執行環境所宣告的變數與函式,而如果他本身是一個function,也會一併把參數放進去VO中。

此外,當進入EC時,會分成兩個階段:creation phase與execution phase,其中VO裡面的屬性則是在creation phase附加上去。

至於附加的方式則是遵照以下的規則:

1. 先看是否為function code:

For function code: for each formal parameter, as defined in the FormalParameterList, create a property of the variable object whose name is the Identifier and whose attributes are determined by the type of code. The values of the parameters are supplied by the caller as arguments to [[Call]].

If the caller supplies fewer parameter values than there are formal parameters, the extra formal parameters have value undefined

翻譯:如果有參數,直接放進去VO中,並且他的值取自呼叫時傳入的值。假設傳入的參數少於預設,就賦予undefined。

2. 看是否有函式宣告:

For each FunctionDeclaration in the code, in source text order, create a property of the variable object whose name is the Identifier in the FunctionDeclaration, whose value is the result returned by creating a Function object as described in 13, and whose attributes are determined by the type of code.

If the variable object already has a property with this name, replace its value and attributes. Semantically, this step must follow the creation of FormalParameterList properties.

翻譯:假設沒有同名的屬性,一樣新增屬性,至於值則是建立函式後的回傳值(大概就是指標)。那假如剛好已經有同名的屬性,那就直接覆蓋過去。

簡單做個示範:

依據規格書給的規則,不需要執行也能夠知道最後bar輸出的答案是一個function,就是因為函式宣告會覆寫原先參數在VO增加的屬性。

3. 看是否有變數宣告:

For each VariableDeclaration or VariableDeclarationNoIn in the code, create a property of the variable object whose name is the Identifier in the VariableDeclaration or VariableDeclarationNoIn, whose value is undefined and whose attributes are determined by the type of code. If there is already a property of the variable object with the name of a declared variable, the value of the property and its attributes are not changed.

翻譯:假如沒有同名的屬性,直接新增屬性而且數值配給為undefined;若不幸已有同名的屬性,則是直接忽略這個變數的宣告。

這裡忽略的只有變數的宣告,賦值並沒有被忽略,所以當執行時一樣會更新上去。

講了那麼多,稍微統整一下目前有的資訊:

1. 參數第一個被放進VO中,並依據呼叫時給的參數賦予其值,要是不夠就設定為undefined。

2. 函式宣告會覆蓋同名的屬性

3. 變數宣告地位最低,若有同名則直接忽略。

OK,看完上面的內容,我們可以真正了解為什麼會有hoisting的存在,以及他的運作原理,就是來自於創建EC時會一併建立一個VO,內容就會是該scope中的各種宣告與函式參數。

根據這些「理論」,我們趕緊來試試看是不是真的如同規格書所說。直接拿前面的範例來再看一次:

還記得最後輸出是number對吧?如果以VO的角度來看,在執行foo時,參數和宣告都會一併附加到VO中,所以會呈現這樣:

因為在真正執行整個foo之前,已經先建立好一個這樣的VO,所以當執行到console.log時,自然已經有實際的內容可以存取,而且其值為20。

如果感覺還不夠,這裡再準備一個範例給你:

一次把三種宣告放在一起使用,猜猜看第一個輸出跟第二個分別是什麼?另外,foo的VO會長什麼樣子?

公布答案囉!

第一個輸出是function,第二個則是5。至於VO會長的像這樣:

步驟這裡就不多提了,篇幅已經夠長了 (昏頭

總之,遵照這份流程,不論遇到什麼題目,我想你都能夠很自然地回答出經過hoisting的結果。

最後,講完規格層面,我想來提一下實作上JS引擎是怎麼做到hoisting的。

第四課:實作上的hoisting

JS其實也有編譯

綜合上面的流程,我們知道JS引擎會幫我們在進入EC時把VO的屬性都附加上去。一切看起來都是那麼得美好,但總覺得哪裡怪怪的,對吧?

一般來說,我們會稱Javascript是一個直譯式語言,也就是大家常說的「一行一行執行」。如果說JS真的是一行一行在執行,他是不可能做到提升這件事的。

針對這一點,我參考了《虚拟机随谈(一):解释器,树遍历解释器,基于栈与基于寄存器,大杂烩》這篇文章,裡面給予以下的解釋:

1. 語言一般只會定義抽象語義,並不會強制規定他要怎麼實現。例如C語言我們會稱他為編譯式語言,但事實是他也有直譯器 — Ch。所以當我們說某種語言是XX類型的語言,其實只是說他在主流上是採用XX,但也可能存在OO類型。

2. 編譯與直譯最大的差異就是執行過程。一般在編譯後,我們會得到另外一個目的檔,內容可能是優化過後的同等程式,或是較為低階的機器碼。不論是最後輸出哪一種程式語言,他都必須保證執行的結果在語義上是相同的(簡單來說就是輸出順序要一樣)。
至於直譯式語言只是把執行結果秀出來,中間的過程是一個黑箱作業,你不會知道裡面到底做了什麼。

但大部分的直譯器都是使用「編譯+虛擬機」來運作,所以事實就是JS也潛藏著編譯器,透過事先編譯來達成hoisting。

JS引擎的運行模式

既然JS存在編譯器,那就代表其實hoisting是在編譯階段做處理的。我們可以把JS分成兩個階段:編譯與執行。

假設我們有這麼一段非常複雜的程式碼:

編譯階段

在這個階段會處理所有宣告,並且加入到該scope中,過程大概像這樣:

這裡要稍微提一下,當編譯時遇到函式宣告,JS會遞迴往下先做該函式的編譯,等到完成再跳出繼續處理上一層。

當完成整份程式碼的編譯,我們會得到這樣一個的東西:

這裡會注意到每個scope都有一個foo,但每個所代表的意義不同。在全域中,foo代表一個變數;在say當中,則是代表一個函式;最後在foo當中,其所代表的是一個參數宣告。

其中比較特別的是say這個函式中的foo,因為我們有重複宣告,一個是變數一個則是函式。還記得我們前面說到規格書規範的次序嗎?函式的優先權比任何人都高,所以變數的宣告是不能夠覆寫函式的宣告,因而最後foo才會是一個function而不是變數。

執行階段

在這個階段,我們會引入兩個名詞:LHS與RHS。LHS代表對物件進行賦值,至於RHS則是引用這個物件的值。

準備好做最後衝刺了嗎?那我們就一步一步來看如何執行上面的程式:

  • Line 1: var foo = ‘bar’

JS引擎:嘿global scope,我要對foo做LHS,你有看過它嗎?

Global Scope:有喔,來!給你

>> 更新global scope:foo變成’bar’

  • Line 13: say()

JS引擎:嘿global scope,我要對say做RHS引用,你有看過它嗎?

Global Scope:有喔,來給你!(成功呼叫)

>> 進入say function的EC

  • Line 3: console.log(foo)

JS引擎:嘿say scope,我要對foo做RHS引用,你有看過它嗎?

Say Scope:有喔,交給你吧~ (取得function指標)

>> 成功輸出function指標

  • Line 9: foo()

JS引擎:嘿say scope,我要對foo做RHS引用,你有看過它嗎?

Say Scope:有喔,再給你一次吧!

>> 進入foo function的EC

  • Line 4*: 進入EC的瞬間,將foo的值設為undefined(因為呼叫時沒給值)
  • Line 5: console.log(foo)

JS引擎:嘿foo scope,我要對foo做RHS引用,你有看過它嗎?

Foo Scope:有喔,他是我的參數,來給你吧!

>> 成功輸出undefined

  • Line 6: foo = ‘hey’

JS引擎:嘿foo scope,我要對foo做LHS賦值,你有看過它嗎?

Foo Scope:有阿,給你~

>> 更新foo scope:foo變成’hey’

  • Line 7: bar = ‘yap’

JS引擎:嘿foo scope,我要對bar做LHS,你有看過嗎?

Foo Scope:沒有,我問上層看看

Say Scope:我也沒有,我再問問看上層

Global Scope:我其實也沒有,不然我幫你建立一個變數吧!

這時候其實分成兩種可能,如果處於嚴格模式,會拋出存取錯誤;若是非嚴格模式則是會自動加上該變數並賦值。這裡就以非嚴格模式做討論。

所以此時會更新global scope:新增bar並且設為’yap’

>> 因foo已經執行完畢,將foo的EC自堆疊移除

  • Line 10: var foo = ‘bar’

JS引擎:嘿say scope,我要對foo做LHS,你有看過它嗎?

Say Scope:有喔,來給你!

>> 更新say scope:foo變成’again’

後面的輸出我就不提了,大致就跟上面的討論一樣,由讀者自行trace。

總之,所有的輸出就會是這樣:

值得注意的是程式碼中的第四行。因為當我們呼叫foo時,若有傳入參數,則會在該scope中尋找是否有同名的屬性。

在規格書中有規定說函式宣告可以覆蓋參數宣告,所以JS引擎要找的對象就會是undefined,因為如果發現數值已設為function,則必定是被覆蓋過的屬性,因而可以透過undefined判斷是否為該函式的參數。

以上,就是JS引擎的運作流程。

上面的內容我是參考《Hoisting in Javascript》,裡面的內容比較詳細。

總結

把今天講的主題統整一下:

1. Hoisting只會提升宣告

2. 提升優先權: 函式 > 變數

3. 函式的提升可以覆蓋參數

4. JS有個東西叫Variable Object,負責存放所有宣告

5. 實作上,JS是有編譯階段的,負責處理VO的前置作業

老實說,我在找資料的時候,還真的嚇了一跳。因為原本只想知道什麼是hoisting,沒有想到背後的原理那麼深奧,還牽扯到EC。

寫的過程中也慢慢自己去找資料佐證我自己的想法,試著消化他們並化為自己的東西。像是最後講的執行階段,事實上在我找資料的過程中,都只有說JS有編譯階段,編譯時會建立類似VO的東西,但其中的範例並沒有帶到「參數」的部分。當時我就在想:「編譯階段又不能知道參數的值,那到時候給予EC的VO又是怎麼把參數加進去的?」,所以苦惱了一天之久。後來就想到規格書上的規則,因此提出以「是否為undefined」為判斷方式。雖然目前還沒有真正的資訊可以佐證這件事(累),但我想應該相去不遠。

總之,希望今天這一篇可以幫助那些想多了解hoisting的人。如果文中有任何錯誤也歡迎再跟我反應!!

謝謝所有閱讀此篇的讀者,感謝你們的耐心。

參考資料

  1. Note 4. Two words about “hoisting”.
  2. 我知道你懂 hoisting,可是你了解到多深?
  3. Hoisting in Javascript
  4. JavaScript學習筆記 — Understanding JavaScript The Weird Part-1

--

--