1. 程式人生 > 程式設計 >簡單談談JavaScript變數提升

簡單談談JavaScript變數提升

目錄
  • 前言
  • 1. 什麼變數提升?
  • 2. 為什麼會有變數提升?
    • (1)提高效能
    • (2)容錯性更好
  • 3. 變數提升導致的問題
    • (1)變數被覆蓋
    • (2)變數沒有被銷燬
  • 4. 禁用變數提升
    • 5. 如何支援塊級作用域
      • (1)建立執行上下文
      • (2)執行程式碼
    • 6. 暫時性死區
      • 總結

        前言

        在 ECMAScript6 中,新增了 let 和 const 關鍵字用來宣告變數。在前端面試中也常被問到 let、const和 var 的區別,這就涉及到了變數提升、暫時性死區等知識點。下面就來看看什麼是變數提升和暫時性死區。

        1. 什麼變數提升?

        先來看看MDN中對變數提升的描述:

        變數提升(Hoisting)被認為是, 中執行上下文 (特別是建立和執行階段)工作方式的一種認識。在 ECMAScript® 2015 Language Sphttp://www.cppcns.com

        ecification 之前的Script文件中找不到變數提升(Hoisting)這個詞。
        從概念的字面意義上說,“變數提升”意味著變數和函式的宣告會在物理層面移動到程式碼的最前面,但這麼說並不準確。實際上變數和函式宣告在程式碼裡的位置是不會動的,而是在編譯階段被放入記憶體中。

        通俗來說,變數提升是指在 JavaScript 程式碼執行過程中,JavaScript 引擎把變數的宣告部分和函式的宣告部分提升到程式碼開頭的行為。變數被提升後,會給變數設定預設值為 undefined。 正是由於 JavaScript 存在變數提升這種特性,導致了很多與直覺不太相符的程式碼,這也是 JavaScript 的一個設計缺陷。雖然 ECMAScript6 已經通過引入塊級作用域並配合使用 let、const 關鍵字,避開了這種設計缺陷,但是由於 JavaScript 需要向下相容,所以變數提升在很長時間內還會繼續存在。

        在 ECMAScript6 之前,JS 引擎用 var 關鍵字宣告變數。在 var 時代,不管變數宣告是寫在哪裡,最後都會被提到作用域的頂端。 下面在全域性作用域中宣告一個num 變數,並在宣告之前列印它:

        console.log(num) 
        var num = 1
        

        這裡會輸出 undefined,因為變數的宣告被提升了,它等價於:

        var num
        console.log(num)
        num = 1
        

        可以看到,num 作為全域性變數會被提升到全域性作用域的頂端。

        除此之外,在函式作用域中也存在變數提升:

        function getNum() {
          console.log(num) 
          var num = 1  
        }
        getNum()
        

        這裡也會輸出 undefined,因為函式內部的變數宣告會被提升至函式作用域的頂端。它等價於:

        function getNum() {
          var num 
          console.log(num) 
          num = 1  
        }
        getNum()
        

        除了變數提升,函式實際上也是存在提升的。JavaScript中具名的函式的宣告形式有兩種:

        //函式宣告式:
        function foo () {}
        //變數形式宣告: 
        var fn = function () {}
        

        當使用變數形式宣告函式時,和普通的變數一樣會存在提升的現象,而函式宣告式會提升到作用域最前邊,並且將宣告內容一起提升到最上邊。如下:

        fn()
        var fn = function () {
        	console.log(1)  
        }
        // 輸出結果:Uncaught TypeError: fn is not a function
        
        foo()
        function foo () {
        	console.log(2)
        }
        // 輸出結果:2
        

        可以看到,使用變數形式宣告fn並在其前面執行時,會報錯fn不是一個函式,因為此時fn只是一個變數,還沒有賦值為一個函式,所以是不能執行fn方法的。

        2. 為什麼會有變數提升http://www.cppcns.com

        變數提升和 JavaScript 的編譯過程密切相關:JavaScript 和其他語言一樣,都要經歷編譯和執行階段。在這個短暫的編譯階段,JS 引擎會蒐集所有的變數宣告,並且提前讓宣告生效。而剩下的語句需要等到執行階段、等到執行到具體的某一句時才會生效。這就是變數提升背後的機制。

        那為什麼 JavaScript 中會存在變量提升這個特性呢?

        首先要從作用域說起。作用域是指在程式中定義變數的區域,該位置決定了變數的生命週期。通俗理解,作用域就是變數與pBXQVt函式的可訪問範圍,即作用域控制著變數和函式的可見性和生命週期。

        在 ES6 之前,作用域分為兩種:

        • 全域性作用域中的物件在程式碼中的任何地方都可以訪問,其生命週期伴隨著頁面的生命週期。
        • 函式作用域是在函式內部定義的變數或者函式,並且定義的變數或者函式只能在函式內部被訪問。函式執行結束之後,函式內部定義的變數會被銷燬。

        相較而言,其他語言則普遍支援塊級作用域。塊級作用域就是使用一對大括號包裹的一段程式碼,比如函式、判斷語句、迴圈語句,甚至一個單獨的{}都可以被看作是一個塊級作用域(注意,物件宣告中的{}不是塊級作用域)。簡單來說,如果一種語言支援塊級作用域,那麼其程式碼塊內部定義的變數在程式碼塊外部是訪問不到的,並且等該程式碼塊中的程式碼執行完成之後,程式碼塊中定義的變數會被銷燬。

        ES6 之前是不支援塊級作用域的,沒有塊級作用域,將作用域內部的變數統一提升無疑是最快速、最簡單的設計,不過這也直接導致了函式中的變數無論是在哪裡宣告的,在編譯階段都會被提取到執行上下文的變數環境中,所以這些變數在整個函式體內部的任何地方都是能被訪問的,這也就是 JavaScript 中的變數提升。

        使用變數提升有如下兩個好處:

        (1)提高效能

        在JS程式碼執行之前,會進行語法檢查和預編譯,並且這一操作只進行一次。這麼做就是為了提高效能,如果沒有這一步,那麼每次執行程式碼前都必須重新解析一遍該變數(函式),這是沒有必要的,因為變數(函式)的程式碼並不會改變,解析一遍就夠了。

        在解析的過程中,還會為函式生成預編譯程式碼。在預編譯時,會統計聲明瞭哪些變數、建立了哪些函式,並對函式的程式碼進行壓縮,去除註釋、不必要的空白等。這樣做的好處就是每次執行函式時都可以直接為該函式分配棧空間(不需要再解析一遍去獲取程式碼中聲明瞭哪些變數,建立了哪些函式),並且因為程式碼壓縮的原因,程式碼執行也更快了。

        (2)容錯性更好

        變數提升可以在一定程度上提高JS的容錯性,看下面的程式碼:

        a = 1;
        var a;
        console.log(a); // 1
        

        如果沒有變數提升,這兩行程式碼就會報錯,但是因為有了變數提升,這段程式碼就可以正常執行。

        雖然在可以開發過程中,可以完全避免這樣寫,但是有時程式碼很複雜,可能因為疏忽而先使用後定義了,而由於變數提升的存在,程式碼會正常執行。當然,在開發過程中,還是儘量要避免變數先使用後宣告的寫法。

        總結:

        • 解析和預編譯過程中的宣告提升可以提高效能,讓函式可以在執行時預先為變數分配棧空間;
        • 宣告提升還可以提高JS程式碼的容錯性,使一些不規範的程式碼也可以正常執行。

        3. 變數提升導致的問題

        由於變數提升的存在,使用 JavaScript 來編寫和其他語言相同邏輯的程式碼,都有可能會導致不一樣的執行結果。主要有以下兩種情況。

        (1)變數被覆蓋

        來看下面的程式碼:

        var name = "JavaScript"
        function showName(){
          console.log(name);
          if(0){
           var name = ""
          }
        }
        showName()
        

        這裡會輸出 undefined,而並沒有輸出“JavaScript”,為什麼呢?

        首先,當剛執行 showName 函式呼叫時,會建立 showName 函式的執行上下文。之後,JavaScript 引擎便開始執行 showName 函式內部的程式碼。首先執行的是:

        console.log(name);
        

        執行這段程式碼需要使用變數 name,程式碼中有兩個 name 變數:一個在全域性執行上下文中,其值是JavaScript;另外一個在 showName 函式的執行上下文中,由於if(0)永遠不成立,所以 name 值是 CSS。那該使用哪個呢?應該先使用函式執行上下文中的變數。因為在函式執行過程中,JavaScript 會優先從當前的執行上下文中查詢變數,由於變數提升的存在,當前的執行上下文中就包含了if(0)中的變數 name,其值是 undefined,所以獲取到的 name 的值就是 undefined。
        這裡輸出的結果和其他支援塊級作用域的語言不太一樣,比如 C 語言輸出的就是全域性變數,所以這裡會很容易造成誤解。

        (2)變數沒有被銷燬

        function foo(){
          for (var i = 0; i < 5; i++) {
          }
          console.log(i); 
        }
        foo()
        

        使用其他的大部分語言實現類似程式碼時,在 for 迴圈結束之後,i 就已經被銷燬了,但是在 JavaScript 程式碼中,i 的值並未被銷燬,所以最後打印出來的是 5。這也是由變數提升而導致的,在建立執行上下文階段,變數 i 就已經被提升了,所以當 for 迴圈結束之後,變數 i 並沒有被銷燬。

        4. 禁用變數提升

        為了解決上述問題,ES6 引入了 let 和 const 關鍵字,從而使 JavaScript 也能像其他語言一樣擁有塊級作用域。let 和 const 是不存在變數提升的。下面用 let 來宣告變數:

        console.log(num) 
        let num = 1
        
        // 輸出結果:Uncaught ReferenceError: num is not defined
        

        如果改成 const 宣告,也會是一樣的結果——用 let 和 const 宣告的變數,它們的宣告生效時機和具體程式碼的執行時機保持一致。

        變數提升機制會導致很多誤操作:那些忘記被宣告的變數無法在開發階段被明顯地察覺出來,而是以 undefined 的形式藏在程式碼中。為了減少執行時錯誤,防止 undefined 帶來不可預知的問題,ES6 特意將宣告前不可用做了強約束。不過,let 和 const 還是有區別的,使用 let 關鍵字宣告的變數是可以被改變的,而使用 const 宣告的變數其值是不可以被改變的。

        下面來看看 ES6 是如何通過塊級作用域來解決上面的問題:

        function fn() {
          var num = 1;
          if (true) {
            var num = 2;  
            console.log(num);  // 2
          }
          console.log(num);  // 2
        }
        fn()
        

        在這段程式碼中,有兩個地方都定義了變數 num,函式塊的頂部和 if 的內部,由於 var 的作用範圍是整個函式,所以在編譯階段,會生成如下執行上下文:

        簡單談談JavaScript變數提升

        從執行上下文的變數環境中可以看出,最終只生成了一個變數 num,函式體內所有對 num 的賦值操作都會直接改變變數環境中的 num 的值。所以上述程式碼最後輸出的是 2,而對於相同邏輯的程式碼,其他語言最後一步輸出的值應該是 1,因為在 if 裡面的宣告不應該影響到塊外面的變數。

        下面來把 var 關鍵字替換為 let 關鍵字,看看效果:

        function fn() {
          let num = 1;
          if (true) {
            let num = 2;  
            console.log(num);  // 2
          }
          console.log(num);  // 1
        }
        fn()
        

        執行這段程式碼,其輸出結果就和預期是一致的。這是因為 let 關鍵字是支援塊級作用域的,所以,在編譯階段 JavaScript 引擎並不會把 if 中通過 let 宣告的變數存放到變數環境中,這也就意味著在 if 中通過 let 宣告的關鍵字,並不會提升到全函式可見。所以在 if 之內打印出來的值是 2,跳出語塊之後,打印出來的值就是 1 了。這就符合我們的習慣了 :作用塊內宣告的變數不影響塊外面的變數。

        5. JS如何支援塊級作用域

        那麼問題來了,ES6 是如何做到既要支援變數提升的特性,又要支援塊級作用域的呢?下面從執行上下文的角度來看看原因。

        JavaScript 引擎是通過變數環境實現函式級作用域的,那麼 ES6 又是如何在函式級作用域的基礎之上,實現對塊級作用域的支援呢?先看下面這段程式碼:

        function fn(){
            var a = 1
            let b = 2
            {
              let b = 3
              var c = 4
              let d = 5
              console.log(a)
              console.log(b)
              console.log(d)
            }
            console.log(b) 
            console.log(c)
        }   
        fn()
        

        當這段程式碼執行時,JavaScript 引擎會先對其進行編譯並建立執行上下文,然後再按照順序執行程式碼。let 關鍵字會建立塊級作用域,那麼 let 關鍵字是如何影響執行上下文的呢?

        (1)建立執行上下文

        建立的執行上下文如圖所示:

        簡單談談JavaScript變數提升

        通過上圖可知:

        • 通過 var 宣告的變數,在編譯階段會被存放到變數環境中。
        • 通過 let 宣告的變數,在編譯階段會被存放到詞法環境中。
        • 在函式作用域內部,通過 let 宣告的變數並沒有被存放到詞法環境中。

        (2)執行程式碼

        當執行到程式碼塊中時,變數環境中 a 的值已經被設定成了 1,詞法環境中 b 的值已經被設定成了 2,這時函式的執行上下文如圖所示:

        簡單談談JavaScript變數提升

        可以看到,當進入函式的作用域塊時,作用域塊中通過 let 宣告的變數,會被存放在詞法環境的一個單獨的區域中,這個區域中的變數並不影響作用域塊外面的變數,比如在作用域外面聲明瞭變數 b,在該作用域塊內部也聲明瞭變數 b,當執行到作用域內部時,它們都是獨立的存在。

        其實,在詞法環境內部,維護了一個棧結構,棧底是函式最外層的變數,進入一個作用域塊後,就會把該作用域塊內部的變數壓到棧頂;當作用域執行完成之後,該作用域的資訊就會從棧頂彈出,這就是詞法環境的結構。這裡的變數是指通過 let 或者 const 宣告的變數。

        接下來,當執行到作用域塊中的console.log(a)時,就需要在詞法環境和變數環境中查詢變數 a 的值了,查詢方式:沿著詞法環境的棧頂向下查詢,如果在詞法環境中的某個塊中查詢到了,就直接返回給 JavaScript 引擎,如果沒有查詢到,那麼繼續在變數環境中查詢。這樣變數查詢就完成了:

        簡單談談JavaScript變數提升

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

        簡單談談JavaScript變數提升

        塊級作用域就是通過詞法環境的棧結構來實現的,而變數提升是通過變數環境來實現,通過這兩者的結合,JavaScript 引擎就同時支援了變數提升和塊級作用域。

        6. 暫時性死區

        最後再來看看暫時性死區的概念:

        var name = 'JavaScript';
        {
        	name = 'CSS';
        	let name;
        }
        
        // 輸出結果:Uncaught ReferenceError: Cannot access 'name' before initialization
        

        ES6 規定:如果區塊中存在 let 和 const,這個區塊對這兩個關鍵字宣告的變數,從一開始就形成了封閉作用域。假如嘗試在宣告前去使用這類變數,就會報錯。這一段會報錯的區域就是暫時性死區。上面程式碼的第4行上方的區域就是暫時性死區。

        如果想成功引用全域性的 name 變數,需要把 let 宣告給去掉:

        var name = 'JavaScript';
        {
        	name = 'CSS';
        }
        

        這時程式就能正常運行了。其實,這並不意味著引擎感知不到 name 變數的存在,恰恰相反,它感知到了,而且它清楚地知道 name 是用 let 宣告在當前塊裡的。正因如此,它才會給這個變數加上暫時性死區的限制。一旦去掉 let 關鍵字,它也就不起作用了。

        其實這也就是暫時性死區的本質:當程式的控制流程在新的作用域進行例項化時,在此作用域中用 let 或者 const 宣告的變數會先在作用域中被創建出來,但此時還未進行詞法繫結,所以是不能被訪問的,如果訪問就會丟擲錯誤。因此,在這執行流程進入作用域建立變數,到變數可以被訪問之間的這段時間,就稱之為暫時死區。

        在 let 和 const關鍵字出現之前,typeof運算子是百分之百安全的,現在也會引發暫時性死區的發生,像import關鍵字引入公共模組、使用new class建立類的方式,也會引發暫時性死區,究其原因還是變數的宣告先與使用。

        typeof a    // Uncaught ReferenceError: a is not defined
        let a = 1
        

        可以看到,在a宣告之前使用typeof關鍵字報錯了,這就是暫時性死區導致的。

        總結

        到此這篇JavaScript變數提升的文章就介紹到這了,更多相關JavaScript變數提升內容請搜尋我們以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援我們!