提升實際上是把變數和函式定義移動到作用域頂部的過程,通常發生在變數宣告var
或函式宣告function fun() {...}
。
當let
(包括和let
有同樣宣告行為的const
和class
)被ES2015提出來的時候,包括我在內的許多開發人員都使用提升來描述變數是如何被訪問的。但是在對這個問題進行更多的搜尋之後,讓我驚訝的是,提升並不是描述let
變數初始化和可用性的正確術語。
ES2015給let
提供了不同並且改善的機制。它要求更嚴格的變數宣告實踐(在定義之前不能使用),因此會有更高質量的程式碼。
我們去看看這個過程中更多的細節。
1.容易出錯的var
提升
有時候我會看見一個奇怪的實踐,變數var varname
和函式function funcName() {...}
在作用域任意地方宣告:
// var hoisting
num; // => undefined
var num;
num = 10;
num; // => 10
// function hoisting
getPi; // => function getPi() {...}
getPi(); // => 3.14
function getPi() {
return 3.14;
}
複製程式碼
num
變數在var num
宣告之前被訪問,所以被認定為undefined
。
函式function getPi() {...}
被定義在末尾。由於它被提升到作用域的頂部,所以函式能在定義getPi()
之前被呼叫。
這就是典型的提升。
事實證明,先使用後宣告變數或函式可能會造成混亂。假設您在滾動檢視一個大檔案,突然看到一個未宣告的變數...它是怎麼出現在這裡,又是在哪裡定義的?
當然一個經驗豐富的JavaScript開發者不會用這種方式寫程式碼,但是在成千上萬的JavaScript GitHub倉庫中很有可能會面對這樣的程式碼。
即使看著上面給出的程式碼示例,也很難理解程式碼中的宣告流程。
自然地,首先宣告或描述一個未知的詞語,然後才使用它來編寫短語。let
鼓勵遵循這種方式來使用變數。
2.探索底層:變數的生命週期
當引擎訪問變數的時候,它們的生命週期包括下面幾個階段:
- 宣告階段:在作用域中註冊一個變數
- 初始化階段:分配記憶體,給作用域中的變數建立繫結。在這個階段,變數自動地被初始化為
undefined
- 賦值階段:給已經初始化過的變數賦值
通過宣告階段但是沒有到達初始化階段的變數是處於未定義的狀態。
注意,根據變數的生命週期,宣告階段和一般說的變數宣告是不同的術語。簡言之,引擎在三個階段處理變數宣告:宣告階段、初始化階段和賦值階段。
3.var
變數的生命週期
熟悉了生命週期階段,我們用它們來描述引擎是怎樣處理var
變數的。
var
宣告的變數的函式。在任何語句執行之前,這個變數就通過了宣告階段和初始化階段(第一步)。
var variable
在函式作用域中宣告的位置不影響宣告和初始化階段。
在宣告和初始化之後,賦值階段之前,變數值為undefined
並且可以被訪問使用。
在賦值階段,variable = 'value'
,變數會得到它的初始值(第二步)。
嚴格來說,提升的概念是在函式作用域的頂部宣告和初始化變數。在宣告和初始化階段之間沒有間隙。
來研究一個例子。下面的程式碼建立了一個帶有var
變數的函式:
function multiplyByTen(number) {
console.log(ten); // => undefined
var ten;
ten = 10;
console.log(ten); // => 10
return number * ten;
}
multiplyByTen(4); // => 40
複製程式碼
當JavaScript開始執行multipleByTen(4)
的時候,它就進入了multipleByTen
的函式作用域,在第一條語句之前,變數ten
完成了宣告和初始化階段。所以呼叫console.log(ten)
會列印出undefined
。
語句ten = 10
分配了一個初始值。分配之後,console.log(ten)
正確地輸出了10。
4.函式宣告的生命週期
假設一個函式宣告語句function funName() {...}
,這更加容易。
funcName()
可以在該作用域的任何地方呼叫,不依賴於宣告語句的位置(甚至可以在最後)。
下面的程式碼示例展示了函式提升:
function sumArray(array) {
return array.reduce(sum);
function sum(a, b) {
return a + b;
}
}
sumArray([5, 10, 8]); // => 23
複製程式碼
當JavaScript呼叫sumArray([5, 10, 8])
的時候,它就進入了sumArray
的函式作用域。在作用域裡面,在所有語句執行之前,sum
通過了三個階段:宣告、初始化、賦值。
這種方式,array.reduce(sum)
甚至可以在宣告語句function sum(a, b) {...}
之前使用sum
。
5.let
變數的生命週期
let
變數的處理方式和var
不同。最主要的區別就是宣告和初始化階段被分開了。
let variable
語句。變數立刻通過了宣告階段,在作用域註冊了它的名字(步驟 1)。
接著直譯器一行一行的解析語句。
如果在這個階段嘗試訪問變數variable
,JavaScript會丟擲ReferenceError: variable is not defined
。因為變數的狀態是未定義的,variable
在暫時性死區。
當直譯器到達let variable
語句的時候,初始化階段通過(步驟 2)。現在變數的狀態是已定義並且訪問它會得到undefined
。
變數退出了暫時性死區。
稍後,當賦值語句variable = 'value'
出現,就通過了賦值階段(步驟 3)。
如果JavaScript遇到let variable = 'value'
,那麼初始化和賦值會發生在這一條語句上。
來看一個例子。在塊級作用域裡面用let
宣告一個變數variable
:
let condition = true;
// console.log(number); // => Throws ReferenceError
let number;
console.log(number); // => undefined
number = 5;
console.log(number); // => 5
}
複製程式碼
當JavaScript進入if (condition) {...}
塊級作用域的時候,number
立刻通過了宣告階段。
因為number
處於未定義的狀態,處於暫時性死區,試圖訪問這個變數會丟擲ReferenceError: number is not defined
。
後來,語句let number
完成了初始化。現在這個變數可以訪問了,但是它的值是undefined
。
賦值語句number = 5
當然就是完成了賦值階段。
const
和class
型別和let
有相同的生命週期,除了賦值只能發生一次。
5.1 為什麼提升在let
的生命週期裡無效
如上所述,提升就是變數在作用域頂部進行宣告和初始化。但是let
的生命週期將宣告和初始化兩個階段解耦了。解耦讓提升這個術語失效了。
兩個階段中間的間隙建立了暫時性死區,在這裡,變數不能被訪問。
以一種科幻的風格,在let
生命週期中,提升這個術語的崩塌創造了暫時性死區。
6.結論
隨意使用var
宣告變數容易犯錯。基於這個教訓,ES2015建立了let
。它使用一種改進的演算法來宣告變數,並且使用塊級作用域。
由於宣告和定義兩個階段解耦,提升對於一個let
宣告的變數(包括包括const
和class
)無效。在初始化之前,變數處於暫時性死區並且不可以訪問。
保持穩定的變數宣告,有如下建議:
- 宣告、初始化,然後再使用變數。這個流程正確並且容易遵守;
- 儘可能隱藏變數。變數暴露的越少,程式碼就越模組化。