本文翻譯自 https://blog.bitsrc.io/hoisting-in-modern-javascript-let-const-and-var-b290405adfda ,作者 Sukhjinder Arora,內容有部分刪改,標題有改動。
許多 JavaScript 程序員將提升解釋爲 JavaScript 將聲明(變量和函數)移至其當前作用域(函數或全局)頂部的行爲。好像它們實際上已經移到了代碼的頂部,事實並非如此。例如:
console.log(a);
var a = 'Hello World!';
他們會說,上面的代碼將在提升後轉換爲以下代碼:
var a;
console.log(a);
a = 'Hello World!';
儘管看起來是這樣,因爲代碼也工作正常了,但是 JavaScript 引擎事實上並不是這麼做的,你的代碼還是在這裏。
那麼,提升是什麼呢?
在編譯階段,即在代碼執行前的幾微秒內,將對其進行掃描以查找函數和變量聲明。所有這些函數和變量聲明都添加到內存中稱爲 詞法環境 的 JavaScript 數據結構內部。這樣,即使在源代碼中實際聲明它們之前也可以使用它們。
詞法環境是什麼?
詞法環境是用來保存標識符和變量映射關係的地方。標識符是變量或者函數的名字,變量是對實際對象(包括函數對象和數組對象)或者原始值的引用。
簡而言之, 詞法環境是存儲變量和對象引用的地方 。
詞法環境的結構如下:
LexicalEnvironment = {
Identifier: <value>,
Identifier: <function object>
}
如果想要了解更多詞法環境相關的內容,可以查看我翻譯的這篇文章 面試必備 | 一文讀懂 JavaScript 中的執行上下文 。
現在我們知道了提升的內部原理是什麼,讓我們看看函數和變量( let 、 const 、 var )聲明的提升是如何發生的。
函數聲明提升
helloWorld(); // prints 'Hello World!' to the console
function helloWorld(){
console.log('Hello World!');
}
我們已經知道 函數聲明是在編譯階段添加到內存的,因此我們可以在實際函數聲明之前在代碼中對其進行訪問 。
因此,以上代碼的詞法環境如下所示:
lexicalEnvironment = {
helloWorld: <func>
}
因此,當 JavaScript 引擎遇到 helloWorld() 時,它將查看詞法環境,找到該函數並能夠執行它。
函數表達式提升
JavaScript 引擎只會提升函數聲明,並不會提升函數表達式。看下面的例子:
helloWorld(); // TypeError: helloWorld is not a function
var helloWorld = function(){
console.log('Hello World!');
}
由於 JavaScript 僅提升聲明,而不賦值, helloWorld 會被視爲變量而不是函數。因爲 helloWorld 是一個 var 聲明的變量,所以在提升階段引擎將會給它賦值 undefined ,所以上述代碼會報錯。
下面的代碼是正常的:
var helloWorld = function(){
console.log('Hello World!'); prints 'Hello World!'
}
helloWorld();
var 變量提升
讓我們看一些示例,以瞭解 var 變量的提升。
console.log(a); // outputs 'undefined'
var a = 3;
我們期望得到 3 ,但是得到了 undefined 。爲什麼?
請記住, JavaScript 僅是提升聲明,並不會提升賦值操作 。也就是說, 在編譯期間,JavaScript 僅將函數和變量聲明存儲在內存中,並沒把賦值操作也一起提升,而 function 聲明的函數會被整體提升 。
但爲什麼是 undefined 呢?
當 JavaScript 引擎在編譯階段找到一個 var 變量聲明時,它會把該變量添加到詞法環境中,並給它賦值 undefined 作爲初始值,然後當代碼執行到賦值語句時,會把實際的值賦到詞法環境中對應的變量。
因此,以上代碼的初始詞法環境如下所示:
LexicalEnvironment = {
a: undefined
}
這就是我們得到 undefined 而不是 3 的原因。 在執行階段,當代碼執行到實際賦值的那一行的時候,會把值賦給詞法環境中對應的變量 。所以賦值之後的詞法環境將會是下面這樣:
LexicalEnvironment = {
a: 3
}
let 和 const 的提升
先看下面的例子:
console.log(a);
let a = 3;
執行代碼,將會報錯 Uncaught ReferenceError: Cannot access 'a' before initialization 。
那麼,報錯是因爲 let 和 const 聲明的變量沒有提升嗎。
答案要複雜得多。 所有聲明( function , var , let , const 和 class )都會提升,而 var 聲明會被初始化爲 undefined ,但是 let 和 const 聲明保持未初始化 uninitialized 。
只有當 JavaScript 事實上執行過了聲明語句之後,它們纔會被初始化,JS 引擎做了限制,你不能在初始化它們之前就使用它們。這也就是我們說的 暫時性死區 。
如果 JavaScript 引擎在聲明它們的行上仍找不到 let 或 const 的值,它將爲它們分配 undefined 或返回錯誤(如果爲 const)。
看下面的例子,由於 const 聲明的變量是不可改變的,所以聲明的時候沒有賦值將會直接報錯。
const ast
// VM275:1 Uncaught SyntaxError: Missing initializer in const declaration
再看下面的例子:
let a;
console.log(a); // outputs undefined
a = 5;
在編譯階段,JavaScript 引擎遇到該變量 a 並將其存儲在詞法環境中,但是由於它是 let 變量,因此引擎不會使用任何值對其進行初始化。因此,在編譯階段,詞法環境將如下所示:
lexicalEnvironment = {
a: <uninitialized>
}
現在,如果我們嘗試在聲明變量之前訪問變量,則 JavaScript 引擎將嘗試從詞法環境中獲取變量的值, 因爲該變量未初始化,因此將引發引用錯誤 。
在執行期間,當引擎到達聲明該變量的行時,它將嘗試給該變量賦值,因爲該變量沒有與之關聯的值,因此將爲其分配 undefined 。
因此,執行第二行後,詞法環境將如下所示:
lexicalEnvironment = {
a: undefined
}
所以 undefined 將會打印到控制檯,然後詞法環境中的 a 將會更新, a 將會被賦值爲 5 。
只要在變量聲明之前不執行該代碼,我們甚至可以在聲明它們之前在代碼(例如,函數主體)中引用 let 和 const 變量。
例如,此代碼是完全有效的。
function foo () {
console.log(a);
}
let a = 20;
foo(); // This is perfectly valid
但是下面的代碼將會報錯。
function foo() {
console.log(a); // ReferenceError: a is not defined
}
foo(); // This is not valid
let a = 20;
原因是函數後執行時,在作用域鏈中找到的 a 的值是已經被賦予了 20 的,如果函數先執行然後再賦值,訪問到的 a 是未被初始化的。
class 聲明提升
class 是 ES6 中出現的一個關鍵字,它也會提升,方式和 let const 一致,也會產生暫時性死區,它在初始情況下也是未初始化的,直到執行賦值。
// Uncaught ReferenceError: Cannot access 'Person' before initialization
let peter = new Person('Peter', 25);
console.log(peter);
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
}
因此,要訪問 class ,你必須先聲明它們。例如:
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
}
let peter = new Person('Peter', 25);
console.log(peter);
// Person { name: 'Peter', age: 25 }
讓我們從詞法環境的角度分析下。在編譯階段,上面代碼的詞法環境如下:
lexicalEnvironment = {
Person: <uninitialized>
}
當執行到 class 聲明的那段代碼, Person 會被初始化爲對應的值。
lexicalEnvironment = {
Person: <Person object>
}
class 表達式的提升
就像函數表達式一樣,類表達式也不會提升。例如,此代碼將不起作用。
// VM266:1 Uncaught ReferenceError: Cannot access 'Person' before initialization
let peter = new Person('Peter', 25);
console.log(peter);
let Person = class {
constructor(name, age) {
this.name = name;
this.age = age;
}
}
正確的方法是這樣的:
let Person = class {
constructor(name, age) {
this.name = name;
this.age = age;
}
}
let peter = new Person('Peter', 25);
console.log(peter);
// Person { name: 'Peter', age: 25 }
結論
因此,現在我們知道在提升過程中,JavaScript 引擎實際不會移動代碼。正確理解提升機制將有助於您避免將來由於提升而引起的任何錯誤和混亂。爲避免未定義的變量或引用錯誤( ReferenceError )等提升的副作用,請始終嘗試在變量的各自作用域頂部聲明變量,並始終在聲明變量時嘗試初始化變量。
加餐:塊級作用域的提升問題
上面章節都是翻譯的,接下來要講解一個漏掉的知識點。我們知道在 ES6 中提出了塊級作用域的概念,塊級作用域中聲明的變量也會存在變量提升,但是部分提升的方式和其他作用域稍微不同。
看下面的例子,是不是和我們平常碰到的情況不太一樣:
// undefined
console.log('a1', a)
{
// function a
console.log('a2', a)
a = 100
// 100
console.log('a3', a)
function a() {}
// 100
console.log('a4', a)
}
// 100
console.log('a5', a)
以下內容來自 阮一峯-ES6 入門 http://es6.ruanyifeng.com/#docs/let#%E5%9D%97%E7%BA%A7%E4%BD%9C%E7%94%A8%E5%9F%9F :允許在塊級作用域內聲明函數。 函數聲明類似於 var ,即會提升到全局作用域或函數作用域的頭部。同時,函數聲明還會提升到所在的塊級作用域的頭部。
由以上我們知道,塊級作用域內聲明的函數會有兩個操作:1. 提升到全局作用域;2. 提升到所在塊級作用域內部。
這兩個過程以及提升的時機用下面的代碼來描述(來自 https://stackoverflow.com/questions/31419897/what-are-the-precise-semantics-of-block-level-functions-in-es6 ):
// 在函數內的塊級作用域內聲明瞭一個函數 compat
function enclosing(…) {
…
{
…
function compat(…) { … }
…
}
…
}
提升的過程表示如下:
function enclosing(…) {
var compat₀ = undefined; // function-scoped
…
{
let compat₁ = function compat(…) { … }; // block-scoped
…
compat₀ = compat₁;
…
}
…
}
提升的過程存在三個步驟:
在塊級作用域外層,產生一個用 var 聲明的變量,並賦值爲 undefined ,類似於塊級作用域內部 var 聲明的變量;
在塊級作用域詞法分析階段,在頂部用 let 聲明一個同名變量, 並賦值爲這個函數 。注意內層不僅提升了而且賦值了。
在原來函數聲明的那一行,把內層用 let 聲明的變量的值賦值給塊級作用域外層用 var 聲明的同名變量。外層的變量就是在這個時候被賦值的。
從第1步可以知道 a1 爲 undefined ,從第2步可以知道 a2 爲 function a ,從第3步可以知道 a5 爲 100 。