JavaScript是按順序執行的嗎?聊聊JavaScript中的變數提升

修谦得益發表於2024-12-15

作為一位前端開發者,我們經常會聽到這麼一句話:“JavaScript的執行是按照順序自上而下依次執行的。”這句話說的並沒有錯。但是它似乎又好像不完全對。我們先來看以下這段程式碼。你覺得結果會輸出什麼?

1 showName()
2 console.log(myName)
3 
4 var myName = '修謙'
5 function showName() {
6     console.log('我的名字叫修謙')
7 }

若是按照之前說的自上而下依次執行的邏輯話,那麼應該輸出的結果應該是:

1、因為函式showName執行時,其並未定義,因此會報錯

2、同樣的,因為變數myName也並未定義,因此也是會報錯

然而當我們在瀏覽器控制檯執行的時候,其實際的結果卻如下圖所示。

程式碼竟然沒有報錯!第一行輸出了“我的名字叫修謙”,第 2 行則輸出了“undefined”,這時候你是否會有疑問:“這怎麼和前面想象中的順序執行有點不一樣啊!怎麼結果會是這樣的呢?”

到這裡,我想你應該想到了一點什麼。那就是:“函式和變數是可以在定義之前使用的”但是我們如果執行未定義的函式和變數的話,又會是一個什麼樣的結果呢?

我們嘗試著將之前的第三行程式碼刪掉,然後執行。

1 showName()
2 console.log(myName)
3 
4 function showName() {
5     console.log('我的名字叫修謙')
6 }

執行程式碼後,如下圖所示,這一次我們看到的結果是函式已經執行了,但是console函式輸出的已經報錯了,輸出了“myName is not defined”

到這裡,對於以上的兩個結果,你是否又能得到了一些新的啟示呢?事實上,透過上面的兩次程式碼執行,我們至少可以得到以下幾個結論:

1、JavaScript在執行的過程中,如果使用了未定義的變數,則會報錯

2、在一個變數定義之前使用,不會報錯,只是其值是undefined。

3、在一個函式定義前使用它,並不會報錯,而是會正確執行

第一個結論我們很容易理解,因為變數未被定義,所以在使用的時候肯定是找不到,因此必然會報錯。但是對於第二個和第三個結論,確實讓人費解的:變數和函式為什麼能在其定義之前使用?這似乎表明JS程式碼並不是按之前說的自上而下依次執行的。

另外一點,就是同樣的方式,變數和函式的處理結果為什麼不一樣?如上面的執行結果,提前使用的showName函式能列印出來完整結果,但是提前使用的myName變數值卻是undefined,而不是我們定義時使用的“修謙”的這個值。要解釋這個,就不得不說到JavaScript中的一個很重要的概念:變數提升

1、什麼是JavaScript的變數提升(Hoisting)

在說JavaScript的變數提升之前,我們得要先說一下JavaScript中的宣告和賦值操作,對於如下的這行程式碼

1 var myName = '修謙'

實際上,這句程式碼你可以把它分為兩部分來看,即宣告賦值

1 var myName //  變數宣告
2 myName = '修謙' // 變數賦值

以上的這個是JavaScript中變數的宣告和賦值,我們再來看一下JavaScript中的函式宣告和賦值操作是什麼樣的,我們還是看以下這段程式碼

1 function showName() {
2     console.log('我的名字叫修謙')
3 }
4 
5 var showName = function() {
6     console.log('我的名字叫修謙')
7 }

我們可以看出第一個函式showName是一個完整的函式宣告,它沒有涉及到賦值操作;第二個函式是先宣告變數showName,再把function(){console.log('我的名字叫修謙')}賦值給了showName。到這裡你應該知道了JavaScript中的變數宣告和賦值是怎麼回事了。

說完了JavaScript中的變數宣告和賦值是怎麼回事後,我們再來說JavaScript中的變數提升。

在JavaScript中,所謂的變數提升:是指在 JavaScript 程式碼執行過程中,JavaScript 引擎把變數的宣告部分和函式的宣告部分提升到程式碼開頭的一種“行為”。當變數被提升後,會給變數設定預設值,而其所設定的預設值就是我們最為熟悉的undefined。從這個概念的字面意義上來看,“變數提升”意味著變數和函式的宣告會在物理層面移動到程式碼的最前面

但其實這樣說也並不準確。因為實際上,在JavaScript中,變數和函式的宣告在程式碼裡的位置是不會改變的。為什麼呢?因為在JavaScript中,一段程式碼的執行是需要先經過JavaScript引擎先編譯的,當程式碼編譯完後,才會進入到程式碼的執行階段(下圖所示)。說變數和函式的宣告在程式碼裡的位置是不會改變的原因,是因為程式碼在編譯階段便已經被JavaScript引擎放入到了記憶體中(既然放到了記憶體當中,那麼其位置當然就已經固定)。

那既然在編譯階段就在記憶體中固定了位置,為什麼又會出現提升呢?編譯階段和變數提升存在什麼關係呢?這裡我們就不得不說到另外一個概念:執行上下文(Execution context)

2、執行上下文(Execution context)

所謂執行上下文,我們可以簡單的理解為就是 JavaScript 執行一段程式碼時的執行環境,比如當我們在JavaScript檔案中呼叫一個函式,那麼就會進入這個函式的執行上下文,就會確定該函式在執行期間用到的諸如 this、變數、物件以及函式等。並且在執行上下文中還存在一個變數環境的物件(Viriable Environment),這是非常重要的。因為該物件中儲存了變數提升的內容,比如上面程式碼中的變數myName和函式showName,都會儲存在該物件中(我們先用下面的這段程式碼模擬一下,後面在詳細講解)。

1 ViriableEnvironment(變數環境)
2     myName -> undefined
3     showName -> function: {console.log(myName)}

在JavaScript中,執行上下文一般分為以下三種:

1、全域性執行上下文:當 JavaScript 執行全域性程式碼的時候,會編譯全域性程式碼並建立全域性執行上下文,而且在整個頁面的生存週期內,全域性執行上下文只有一份。

2、函式執行上下文:當呼叫一個函式的時候,函式體內的程式碼會被編譯,並建立函式執行上下文,在一般情況下,函式執行結束之後,建立的函式執行上下文會被銷燬。

3、eval :當使用 eval 函式的時候,eval 的程式碼也會被編譯,並建立執行上下文。

但是我們現在常接觸或者說的一般都是指前面兩者。瞭解完執行上下文的概念和分類後,我們再來了解一下另外的兩個知識點:函式執行(呼叫)

3、函式執行(呼叫)

函式呼叫概念很簡單,簡單一點來說就是執行一個函式,具體使用方式是使用函式名稱跟著一對小括號。我們舉個例子來說一下

1 var myName = '修謙'
2 function showName() {
3     console.log('我的名字叫修謙')
4 }
5 
6 showName() // 執行

這段程式碼很簡單。首先我們建立了一個名叫myName的變數,接著又建立了一個showName的函式。完後緊接著在最後面呼叫執行了該方法。下面我們就以這段簡單的程式碼來說一下函式呼叫的過程。

當執行到函式showName()之前,JavaScript 引擎會為上面這段程式碼建立全域性執行上下文,包含宣告的函式和變數,如下圖所示:

從圖中可以看出,上面那段程式碼中全域性變數和函式都儲存在全域性上下文的變數環境中。當執行上下文準備好之後,JavaScript引擎便開始執行全域性程式碼,當執行到showName函式時,JavaScript判斷出這是一個函式呼叫,於是便開始了以下操作:

1、首先,從全域性執行上下文中,取出showName函式程式碼。
2、其次,對showName函式的這段程式碼進行編譯,並建立該函式的執行上下文和可執行程式碼。
3、最後,執行程式碼,輸出結果。

我們可以用一張相對完整的圖來描述

當執行到showName函式的時候,我們就有了兩個執行上下文了——全域性執行上下文showName 函式本身的執行上下文(函式執行上下文)。這也就是說在執行JavaScript 時,會存在多個執行上下文。那當有多個上下文的時候,JavaScript引擎是如何管理的呢?這就是我們下面要說到的一種資料結構——

4、棧(Stack)

棧(Stack)是一種線性資料結構,遵循後進先出(Last In, First Out, LIFO)的原則。這意味著最後進入棧的元素會最先被移除。如下圖所示,最先進入的是A,但是最先出的卻是E。而avaScript引擎正是利用棧的這種結構來管理執行上下文的。在執行上下文建立好後,JavaScript引擎會將執行上下文壓入棧中,然後進行執行,而通常把這種用來管理執行上下文的棧稱為執行上下文棧,或者叫JavaScript呼叫棧

4、執行上下文棧(JavaScript呼叫棧

下面我們就來具體的用程式碼和圖來模擬JS執行上下文棧是如何執行程式碼的,如下面一段程式碼(以ES5來演示)

 1 var a = 2
 2 function add(b,c){
 3   return b+c
 4 }
 5 function addAll(b,c){
 6   var d = 2
 7   result = add(b,c)
 8   return a+result+d
 9 }
10 addAll(3,3)

第一步:建立全域性上下,並將其壓入棧底(如圖所示)此時變數a、函式add 以及 addAll 都儲存到了全域性上下文的變數環境物件中。

當全域性執行上下文壓入到呼叫棧後,緊接著,JavaScript引擎便開始執行全域性程式碼了。首先會執行a=2的賦值操作,賦值完後,此前a的值就從undefined變成了2。因為此時的add函式和addAll函式都還沒有執行,因此狀態還是之前的。這一步完成後,我們再來看全域性上下文的狀態,如下圖所示:

第二步:執行addAll函式,此時JavaScript引擎會編譯該函式,併為其建立一個執行上下文,然後將其執行上下文壓入棧中,如圖所示:

同樣的,當addAll函式的執行上下文建立好之後,就會進入了函式程式碼的執行階段了,因為函式中有一個變數d,因此還是先執行賦值操作,即將d的值從之前的d=undefined設定成d=10 。然後接著往下執行。

第三步:執行add函式,當執行到add函式呼叫語句時,JavaScript引擎同樣又會為其建立執行上下文,並將其壓入呼叫棧,此時的呼叫棧的狀態如下圖所示:

然後add函式執行,將返回結果賦值給變數result,此時的result的值便從之前的undefined變成了6。隨後該函式的執行上下文便從從棧頂彈出。此時的呼叫棧如下圖所示:

緊接著addAll執行最後一個相加操作後並返回,完成之後,addAll的執行上下文也會從棧頂部彈出,此時呼叫棧中就只剩下全域性上下文了。最終如下圖所示。 至此,整個JavaScript的執行便完成了。

透過以上的分析,我們可以知道,正是由於JavaScript存在變數提升這種特性,從而導致了我們在日常的學習或者工作中,總是能看到很多與直覺不符或者說與我們思習慣不一樣的程式碼,而這也是JavaScript的一個重要設計缺陷。為此, ECMAScript6引入塊級作用域的概念並配合 let、const 關鍵字,來避開了這種設計缺陷(這個我們接下來就會說)。但是在說之前,我們還要繼續說變數提升剩餘的兩個問題:為什麼JS中會出現變數提升?變數提升有什麼缺點?

5、JS中變數提升的原因

我們都知道在ES6 之前,JavaScript是不支援塊級作用域的。因為當初設計這門語言的時候,只是按照最簡單的方式來設計的。即只設計了全域性作用域函式作用域以此來簡化JavaScript程式碼的解析和執行過程。可沒有想到的是 JavaScript後面會這麼火,最後其沒有塊級作用域的缺陷便慢慢暴露了出來

既然問題已經暴露出來了的話,那就解決問題。但是你不可能貿然的立馬增加塊級作用域吧!畢竟已經用JavaScript這門語言開發了那麼多應用。於是就採取了一個不是特別激進的方法——把作用域內部的變數統一提升。這也是彼時最快速,也是最簡單的方式。

當然了任何事物都有兩面性。這一做法的一個很大的缺點就是直接導致了函式中的變數無論是在哪裡宣告的,在編譯階段都會被提取到執行上下文的變數環境中,所以這些變數在整個函式體內部的任何地方都是能被訪問的,而這也就是我們通常說的JS 中的變數提升。

6、JS中變數提升的問題

1、變數在不知不覺中就被覆蓋

我們先來看下面的一段程式碼,你認為會輸出什麼結果?是修謙?是吳門山人

 1 var myName = "修謙"
 2 
 3 function showName(){
 4   console.log(myName);
 5   if(0){
 6    var myName = "吳門山人"
 7   }
 8   console.log(myName);
 9 }
10 
11 showName()

其實你把程式碼執行的話,會發現其輸出的結果兩者都不是。而是輸出了undefined。為什麼會這樣呢?你可以參照前面舉的那個JS執行的例子來自己試著畫一下過程圖。這裡我們就直接貼最後的執行棧圖。

showName函式的執行上下文建立後,JavaScript引擎便開始執行其內部的程式碼。首先執行的是console.log(myName)。而執行這段程式碼需要使用變數myName,而從圖上我們可以看到,這裡有兩個myName變數:一個是在全域性執行上下文中,其值是“修謙”;另外一個則是在showName函式的執行上下文中,其值是undefined。這個時候JS到底要使用哪一個輸出呢?作為一個前端開發人員,我想絕大部分人都會說出正確的答案:“肯定是先使用showName函式執行上下文裡面的變數啦!”

的確是這樣,因為函式執行過程中,JavaScript會優先從當前所在的執行上下文中查詢變數,但是因為變數提升的原因,當前的執行上下文中就包含了變數myName,而其值是undefined,所以獲取到的myName的值就是undefined。而不是如其它語言一樣,會輸出“修謙”

2. 本應銷燬的變數沒有被銷燬

那既然在JavaScript中,變數提升會帶來上面說到的那些個問題?最後的解決方案又是什麼呢?答案就是在2015年的時候釋出了新的JS標準——ECMAScript6(簡稱ES6)。在 該標準中,正式引入了塊級作用域的概念。並且還引入了 let const 關鍵字來宣告塊級作用域,至此,JavaScript也能像其他語言一樣擁有了塊級作用域。

7、ES6中的let和const

關於letconst。我們還是先來看如下的程式碼

1 let myName = '修謙'
2 const myAag = 35
3 myName = '山人'
4 console.log(myName)
5 
6 myAag = 18
7 console.log(myAag)

這段程式碼輸出的結果,我覺得只要是寫過JavaScript的人都應該知道結果是啥。第一個輸出的是“山人”;而第二個則輸出一個錯誤。從這裡我們可以看出,雖然兩者都是用來宣告塊級作用域的,但是兩者之間還是有區別的,使用 let 關鍵字宣告的變數是可以被改變的,而使用 const 宣告的變數其值是不可以被改變的。說到這裡我們也順帶說一下面試中常被問到的一個問題:在JavaScript中,什麼是暫時性死區?

還是先看程式碼

1 function example() {
2   console.log(x); 
3   let x = 10;
4 }
5 
6 example();

當我們把這段程式碼複製到到瀏覽器控制檯的時候會報這樣一個錯誤: “ReferenceError: Cannot access 'x' before initialization”。這個錯誤翻譯過來是:引用錯誤:初始化之前無法訪問“x”(翻譯的可能不準,但是意思差不多)。從這個錯誤我們知道了在ES6中,當我們用letconst 宣告的變數在宣告之前是處於一種“未初始化”狀態,而這種狀態被稱為暫時性死區(官方的定義是:在 JavaScript 中,"暫時性死區"(Temporal Dead Zone, TDZ)是指在塊級作用域(如 let 和 const 宣告的變數所在的程式碼塊)中,在變數宣告之前訪問該變數會導致引用錯誤(ReferenceError))。

說完letconst,我們再來看以下的這兩行簡單的程式碼

1 var myName = '修謙'
2 let myAag = 35

這兩行程式碼其實並沒有什麼特別的,我用其來就只是為了引出一個問題,即:JavaScript是怎麼樣在支援變數提升特性的同時又支援塊級作用域的呢?因為我們在專案中,有時候你會發現有的人在程式碼中即會用var關鍵字來宣告變數,同時又用letconst來宣告變數。雖然這種方式不推薦,但是總歸是不可避免的。前面我們已經談到了變數提升特性。所以接下來我們重點談的就是JavaScript是如何支援塊級作用域的。

8、JavaScript 是如何支援塊級作用域的?

前面我們說到,在JavaScript引擎中是透過變數環境實現函式級作用域的,那麼在 ES6 中,又是如何在其基礎之上,實現對塊級作用域的支援呢?我們還是先來看下面的一段程式碼

 1 function showName(){
 2     var myName = '修謙'
 3     let myAag = 35
 4     {
 5       let myAag = 18
 6       var heName = '華仔'
 7       let heAge = 63
 8       console.log(myName)
 9       console.log(myAag)
10     }
11 }   
12 showName()

當執行上面這段程式碼的時候,JavaScript引擎會先對其進行編譯並建立執行上下文,然後再按照順序執行程式碼,之前我們的例子是沒有使用ES6中的關鍵字let。但是現在引入了 let 關鍵字,它會建立塊級作用域,那麼它是如何影響執行上下文的呢?這裡我們就不得不提到一個名詞——詞法環境。你應該還記得之前的例子中,右邊一直有一塊空著的,名叫詞法環境的塊。而JavaScript之所以支援塊級作用域,就是與它有關。

下面我們還是按照之前的方式來梳理一下這段程式碼的執行。

第一步:編譯並建立全域性執行上下文

第二步:執行showName函式,為其建立函式執行上下文

showName函式執行這一步。我麼可以從呼叫棧中看出:

1、函式內部透過 var 宣告的變數,在編譯階段全都被存放到變數環境裡面了(這個和之前的一樣)
2、透過 let 宣告的變數,在編譯階段則會被存放到詞法環境(Lexical Environment)中
3、在函式的作用域塊內部,透過 let 宣告的變數並沒有被存放到詞法環境中

第三步:繼續往下執行程式碼。當執行到程式碼塊裡面時,變數環境中myName的值已經被設定成了"修謙",而詞法環境中myAag的值則被設定成了35,此時的函式的執行上下文如下圖所示:

從第三步的圖中我們可以看出,當進入函式內部的作用域塊時,作用域塊中透過 let 宣告的變數(myAagheAag),會被存放在詞法環境的一個單獨的區域中,且不影響作用域塊外面的變數(之前的myAag)。因此它們都是獨立的存在。另外我們從中也可以看出,其實在詞法環境內部,也是維護了一個小型棧結構,棧底是函式最外層的變數(即內部作用域塊外邊的變數,這裡就是myAag),當進入某一個作用域塊後,就會把該作用域塊內部的變數壓到棧頂(myAagheAag);當作用域執行完成之後,該作用域的資訊就會從棧頂彈出,而這就是詞法環境的結構(前提就是必須用let或者const關鍵字定義)。

第四步:繼續往下執行程式碼。將作用塊中的myAagheAag分別賦值為16,63,同時也將環境變數中的heName的值賦值為“華仔”。如圖所示

第五步:繼續往下執行程式碼。當執行到作用域塊中的console.log(myName)這行程式碼時,此時就需要在詞法環境變數環境中查詢變數myName的值了,而具體查詢方式是:沿著當前詞法環境的棧頂向下查詢,如果在詞法環境中的某個塊中查詢到了,就直接返回給JavaScript引擎,如果沒有查詢到,那麼繼續在變數環境中查詢(同樣的,作用域塊中的console.log(myAag)也是這樣的規則)。此時如下圖所示:因為在詞法環境中沒有找到myName的這個變數,因此就會去變數環境中去找,最終在變數環境中找到了myName(黃色箭頭所指),因此輸出“修謙”同樣console.log(myAag)因為在詞法環境中找到了myAag(深藍色箭頭所指),因此輸出18。而將上面的程式碼在瀏覽器裡執行,也是這樣的結果

當函式內部作用域塊執行結束之後,其內部定義的變數就會從詞法環境的棧頂彈出,最終的執行上下文如下圖所示:

透過上面的分析,我們基本已經理解了詞法環境的結構和工作機制:ES6中的塊級作用域就是透過詞法環境的棧結構來實現的,而之前的變數提升是透過變數環境來實現,透過這兩者的結合,JS 引擎也就同時支援了變數提升和塊級作用域了。至此,我想關於變數提升,你應該有一個比較深刻的印象了。當然了,上面寫的可能並不完全正確。也歡迎大家指正批評。

相關文章