簡單談談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!
推薦閱讀:
- JavaScript三大變量聲明詳析
- javascript的var與let,const之間的區別詳解
- 基於JavaScript ES新特性let與const關鍵字
- Javascript基礎學習之十個重要問題
- 一篇文章弄懂js中的typeof用法