你知道下面這段JavaScript程式碼段執行出來的結果嗎?
var foo = 1;
function bar() {
if (!foo) {
var foo = 10;
}
alert(foo);
}
bar();
複製程式碼
如果結果“10”令你驚訝,那麼下面這個程式真的會讓你找不著北。
var a = 1;
function b() {
a = 10;
return;
function a() {}
}
b();
alert(a);
複製程式碼
瀏覽器會alert("1")。那麼,到底發生了什麼?這看起來可能很陌生、很古怪並且令人困惑,但這正是這個語言強而有力的表現特徵。我不清楚這個特徵的專有名詞,但是我更願意用“hoisting”來表達。這篇文章將試著去揭開這種機制的面紗,但是我們先著重理解JavaScript的作用域。
Scoping in JavaScipt
對於JavaScript初學者來說,作用域是產生困惑的根源之一。事實上,不僅包括初學者,我遇到的很多有經驗的JavaScript程式設計師都沒有充分理解作用域。在JavaScript的作用域上有如此之多的困惑的根源是因為它看起來很像C系的語言。思考下面的C程式:
#include <stdio.h>
int main() {
int x = 1;
printf("%d, ", x); // 1
if (1) {
int x = 2;
printf("%d, ", x); // 2
}
printf("%d\n", x); // 1
}
複製程式碼
這個程式的輸出是1, 2, 1。這是因為C和其他C系的語言都有塊級作用域(block-level scope)當流程控制走進了塊級域,例如下面的if語句塊,可以在這個作用域宣告一個新的變數,而不影響外部的作用域。這不同於JavaScript。在Firebug裡面試試下面的程式碼:
var x = 1;
console.log(x); // 1
if (true) {
var x = 2;
console.log(x); // 2
}
console.log(x); // 2
複製程式碼
這種情況下,Firebug會列印1, 2, 2。這是因為JavaScript有函式作用域(function-level scope)。這完全不同與C系語言的塊級作用域,例如下面的if語句塊裡面不會建立一個新的作用域。只有function才能建立新的作用域。
對於許多熟悉像C,C++,C#或者Java的程式設計師來,這個設定是超出預期並且不友好的。幸運的是,鑑於JavaScript中函式的靈活性,有個曲線救國的方法。如果你一定要在function中建立臨時的作用域,可以做如下嘗試:
function foo() {
var x = 1;
if (x) {
(function () {
var x = 2;
// some other code
}());
}
// x is still 1.
}
複製程式碼
這個方法確實很靈活,並且能在任何時候需要臨時作用域時使用,不僅僅侷限在塊語句中。但是,我強烈的建議你真的需要應該花些時間去理解和正確的認識JavaScript的作用域。它真的很有用,也是這個語言吸引我的特色之一。如果你理解了作用域,hoisting對你來說也將變得容易許多。
Declarations, Names, and Hoisting
在JavaScript,以一個名稱存在於作用域有以下4中方法:
1.語言本身定義(Language-defined):所有作用域預設包含this和arguments。
2.形式引數(Formal parameters):函式能帶入形式引數,使其能從函式外部作用域進入函式內部作用域。
3.函式宣告(Function declarations):這是函式宣告的形式 function foo(){}。
4.變數宣告(Variable declarations):宣告的形式 var foo;
函式的宣告和變數的宣告總是被JavaScript編譯器偷偷的提升(“hoisted”)到它們所在作用域的頂部。函式引數和語言本身定義的已經明顯的存在在那裡。這種形式像下面這段程式碼:
function foo() {
bar();
var x = 1;
}
複製程式碼
事實上被編譯成下面這樣:
function foo() {
var x;
bar();
x = 1;
}
複製程式碼
結論是宣告的那行是否被執行都是無關緊要的。下面兩個function是等價的:
function foo() {
if (false) {
var x = 1;
}
return;
var y = 1;
}
function foo() {
var x, y;
if (false) {
x = 1;
}
return;
y = 1;
}
複製程式碼
需要注意的是,分配賦值的部分沒有被提升。僅僅是命名的部分被提升。這與函式宣告不同,整個函式體也會被提升。但是請記住有兩種常規的辦法可以宣告函式。參考下面的JavaScript程式碼:
function test() {
foo(); // TypeError "foo is not a function"
bar(); // "this will run!"
var foo = function () { // function expression assigned to local variable 'foo'
alert("this won't run!");
}
function bar() { // function declaration, given the name 'bar'
alert("this will run!");
}
}
test();
複製程式碼
這種情況下,只有函式宣告的形式才會帶著函式體一起提升。函式表示式形式:“foo”被提升了,但是它的函式體部分被遺留在賦值的時候執行。
以上涵蓋了基本的提升(“hoisting”),並不是看起來那麼複雜和令人迷惑。當然,作為JavaScript,在特殊情況下是會有那麼一些複雜的東西。
Name Resolution Order
在大多數重要特殊的時候應時刻銘記在心是名稱解析的順序。牢記一個名稱進入作用域有四種方法。我在上面列舉的例子就是他們解析的順序。總的來說,如果一個名稱已經被定義,它永遠不會被另一個同名的不同屬性覆蓋。這意味著函式的宣告要優先於變數的宣告。但這並不代表對著名稱的複製不起作用,僅僅只是宣告部分被忽略。這裡有一些例外:
- 內建的arguments有些古怪,它似乎在形式引數之後宣告,但是有是在函式宣告之前。這一位置形式引數比arguments擁有更高的優先順序,即使這個引數是undefined。這是個壞的特性。不要使用arguments作為形式引數。
- 試圖使用this作為會造成SyntaxError錯誤。這是個好的特性。
- 如果有多和形式引數名稱一模一樣,對優先使用最後一個,及時這個引數是undefined。
Named Function Expressions
你可以在函式表示式中給函式定義名稱,就像函式宣告的語句一樣。這樣並不能使它成為函式的宣告,並且這個名稱沒有被帶入到作用域,函式體也沒有被提升。下面是一些程式碼來闡明我的意思:
foo(); // TypeError "foo is not a function"
bar(); // valid
baz(); // TypeError "baz is not a function"
spam(); // ReferenceError "spam is not defined"
var foo = function () {}; // anonymous function expression ('foo' gets hoisted)
function bar() {}; // function declaration ('bar' and the function body get hoisted)
var baz = function spam() {}; // named function expression (only 'baz' gets hoisted)
foo(); // valid
bar(); // valid
baz(); // valid
spam(); // ReferenceError "spam is not defined"
複製程式碼
How to Code With This Knowledge
現在你理解了作用域和變數提升,但是對編寫JavaScript來說意味著什麼呢?最重要的是,宣告你的變數的時候總是使用var語句。我強烈建議你在每個作用域的首位使用var語句。如果你強制自己這樣做,你將永遠不會被提升的問題困擾。然而做這個會使追蹤當前作用域實際宣告瞭哪些變數變得困難。我建議在JSLint中設定onevar選項來控制這個。如果你已經我說的所有工作,那麼你的程式碼有點像下面這樣:
/*jslint onevar: true [...] */
function foo(a, b, c) {
var x = 1,
bar,
baz = "something";
}
複製程式碼
What the Standard Says
我發現直接去查詢ECMAScript Standard(PDF)理解這些東西是如何運作的方式是最有用的。這是我討論的關於變數神經和作用域的段落(section 12.2.2 in the older version):
If the variable statement occurs inside a FunctionDeclaration, the variables are defined with function-local scope in that function, as described in section 10.1.3. Otherwise, they are defined with global scope (that is, they are created as members of the global object, as described in section 10.1.3) using property attributes { DontDelete }. Variables are created when the execution scope is entered. A Block does not define a new execution scope. Only Program and FunctionDeclaration produce a new scope. Variables are initialised to undefined when created. A variable with an Initialiser is assigned the value of its AssignmentExpression when the VariableStatement is executed, not when the variable is created.
我希望這篇文章能幫助到許多那些有著共同困惑的JavaScript程式設計師。我已經很努力盡可能的直接的闡述,避免製造更多的困惑。如果我寫錯了或者遺漏了什麼,請讓我知道。