簡單談談JavaScript變量提升

前言

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

1. 什麼變量提升?

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

變量提升(Hoisting)被認為是, Javascript中執行上下文 (特別是創建和執行階段)工作方式的一種認識。在 ECMAScript® 2015 Language Specification 之前的JavaScript文檔中找不到變量提升(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. 為什麼會有變量提升?

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

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

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

在 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 = "CSS"
  }
}
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 的作用范圍是整個函數,所以在編譯階段,會生成如下執行上下文:

從執行上下文的變量環境中可以看出,最終隻生成瞭一個變量 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)創建執行上下文

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

通過上圖可知:

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

(2)執行代碼

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

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

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

接下來,當執行到作用域塊中的console.log(a)時,就需要在詞法環境和變量環境中查找變量 a 的值瞭,查找方式:沿著詞法環境的棧頂向下查詢,如果在詞法環境中的某個塊中查找到瞭,就直接返回給 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變量提升內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: