JavaScript是按順序執行的嗎?聊聊JavaScript中的變量提升
當前位置:點晴教程→知識管理交流
→『 技術文檔交流 』
作為一位前端開發者,我們經常會聽到這么一句話:“JavaScript的執行是按照順序自上而下依次執行的。”這句話說的并沒有錯。但是它似乎又好像不完全對。我們先來看以下這段代碼。你覺得結果會輸出什么? 1 showName() 2 console.log(myName) 3 4 var myName = '修謙' 5 function showName() { 6 console.log('我的名字叫修謙') 7 } 若是按照之前說的自上而下依次執行的邏輯話,那么應該輸出的結果應該是:
然而當我們在瀏覽器控制臺執行的時候,其實際的結果卻如下圖所示。
代碼竟然沒有報錯!第一行輸出了“我的名字叫修謙”,第 2 行則輸出了“undefined”,這時候你是否會有疑問:“這怎么和前面想象中的順序執行有點不一樣啊!怎么結果會是這樣的呢?” 到這里,我想你應該想到了一點什么。那就是:“函數和變量是可以在定義之前使用的”。但是我們如果執行未定義的函數和變量的話,又會是一個什么樣的結果呢? 我們嘗試著將之前的第三行代碼刪掉,然后執行。 1 showName() 2 console.log(myName) 3 4 function showName() { 5 console.log('我的名字叫修謙') 6 } 運行代碼后,如下圖所示,這一次我們看到的結果是函數已經執行了,但是console函數輸出的已經報錯了,輸出了“myName is not defined”。
到這里,對于以上的兩個結果,你是否又能得到了一些新的啟示呢?事實上,通過上面的兩次代碼執行,我們至少可以得到以下幾個結論:
第一個結論我們很容易理解,因為變量未被定義,所以在使用的時候肯定是找不到,因此必然會報錯。但是對于第二個和第三個結論,確實讓人費解的:變量和函數為什么能在其定義之前使用?這似乎表明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判斷出這是一個函數調用,于是便開始了以下操作:
我們可以用一張相對完整的圖來描述 當執行到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關于let和const。我們還是先來看如下的代碼 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中,當我們用let和const 聲明的變量在聲明之前是處于一種“未初始化”狀態,而這種狀態被稱為暫時性死區(官方的定義是:在 JavaScript 中,"暫時性死區"(Temporal Dead Zone, TDZ)是指在塊級作用域(如 let 和 const 聲明的變量所在的代碼塊)中,在變量聲明之前訪問該變量會導致引用錯誤(ReferenceError))。 說完let和const,我們再來看以下的這兩行簡單的代碼 1 var myName = '修謙' 2 let myAag = 35 這兩行代碼其實并沒有什么特別的,我用其來就只是為了引出一個問題,即:JavaScript是怎么樣在支持變量提升特性的同時又支持塊級作用域的呢?因為我們在項目中,有時候你會發現有的人在代碼中即會用var關鍵字來聲明變量,同時又用let和const來聲明變量。雖然這種方式不推薦,但是總歸是不可避免的。前面我們已經談到了變量提升特性。所以接下來我們重點談的就是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函數執行這一步。我么可以從調用棧中看出:
第三步:繼續往下執行代碼。當執行到代碼塊里面時,變量環境中myName的值已經被設置成了"修謙",而詞法環境中myAag的值則被設置成了35,此時的函數的執行上下文如下圖所示: 從第三步的圖中我們可以看出,當進入函數內部的作用域塊時,作用域塊中通過 let 聲明的變量(myAag和heAag),會被存放在詞法環境的一個單獨的區域中,且不影響作用域塊外面的變量(之前的myAag)。因此它們都是獨立的存在。另外我們從中也可以看出,其實在詞法環境內部,也是維護了一個小型棧結構,棧底是函數最外層的變量(即內部作用域塊外邊的變量,這里就是myAag),當進入某一個作用域塊后,就會把該作用域塊內部的變量壓到棧頂(myAag和heAag);當作用域執行完成之后,該作用域的信息就會從棧頂彈出,而這就是詞法環境的結構(前提就是必須用let或者const關鍵字定義)。 第四步:繼續往下執行代碼。將作用塊中的myAag和heAag分別賦值為16,63,同時也將環境變量中的heName的值賦值為“華仔”。如圖所示 第五步:繼續往下執行代碼。當執行到作用域塊中的console.log(myName)這行代碼時,此時就需要在詞法環境和變量環境中查找變量myName的值了,而具體查找方式是:沿著當前詞法環境的棧頂向下查詢,如果在詞法環境中的某個塊中查找到了,就直接返回給JavaScript引擎,如果沒有查找到,那么繼續在變量環境中查找(同樣的,作用域塊中的console.log(myAag)也是這樣的規則)。此時如下圖所示:因為在詞法環境中沒有找到myName的這個變量,因此就會去變量環境中去找,最終在變量環境中找到了myName(黃色箭頭所指),因此輸出“修謙”。同樣console.log(myAag)因為在詞法環境中找到了myAag(深藍色箭頭所指),因此輸出18。而將上面的代碼在瀏覽器里執行,也是這樣的結果
當函數內部作用域塊執行結束之后,其內部定義的變量就會從詞法環境的棧頂彈出,最終的執行上下文如下圖所示: 通過上面的分析,我們基本已經理解了詞法環境的結構和工作機制:ES6中的塊級作用域就是通過詞法環境的棧結構來實現的,而之前的變量提升是通過變量環境來實現,通過這兩者的結合,JS 引擎也就同時支持了變量提升和塊級作用域了。至此,我想關于變量提升,你應該有一個比較深刻的印象了。當然了,上面寫的可能并不完全正確。也歡迎大家指正批評。 ?轉自https://www.cnblogs.com/xiuqian/p/18595873 該文章在 2024/12/17 15:53:47 編輯過 |
關鍵字查詢
相關文章
正在查詢... |