你知道下面的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);
這里瀏覽器會彈出“1”。怎么回事?這似乎看起來是奇怪,未知,讓人混淆的,但這實際上是這門語言一個強大和富有表現力的特性。我不知道這一特性行為是否有標准名字,但我喜歡這個術語“提升(hoisting)”。本文試圖揭示這一特性的機制,但首先讓我們鏈接JavaScript的作用域。
JavaScript中的作用域(scope)
JavaScript初學者最容易混淆的地方是作用域。實際上,不只是初學者。我遇到過許多經驗豐富的JavaScript程序員,卻不完全明白作用域。JavaScript的作用域如此容易混淆的原因是它看起來很像C家族的語言(類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語言,不創建新的作用域。僅僅函數創建新作用域。
很多程序員,像C,C++,C#或Java,都不知道這點,也不希望這樣。幸運的是,因為JavaScript函數的靈活性,有一個解決方案。你若你必須要在函數內部創建一個臨時作用域,像下面這樣做:
function foo() { var x = 1; if (x) { (function () { var x = 2; // 此處省略一萬個字 }()); } // x 仍然是 1. }
這方法實際上相當靈活,可以在你需要臨時作用域的時候隨意使用,不局限於塊級語句內部。然而,我強烈建議你花時間去了解和欣賞JavaScript的作用域。它非常強大,是這門語言中我最喜歡的特性之一。如果你了解作用域,將更容易理解提升。
聲明,名字和提升(Hoisting)
在JavaScript中,作用域中的名字(屬性名)有四種基本來源:
- 語言定義:默認所有作用域都有屬性名this和arguments。
- 形參:函數可能有形式參數,其作用域是整個函數體內部。
- 函數聲明:類似於function foo() {}這種形式。
- 變量聲明:var foo;這種形式的代碼。
函數聲明和變量聲明總是被JavaScript解釋器無形中移動到(提升)包含他們的作用域頂部。函數參數和語言定義的名稱明顯總是存在。這意味着像下面的代碼:
function foo() { bar(); var x = 1; }
實際上被解釋為像下面這樣:
function foo() { var x; bar(); x = 1; }
無論包含聲明的代碼行是否會被執行,上面的過程都會發生。下面的兩個函數是等價的:
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(); // 類型錯誤 “foo 不是一個函數” bar(); // “這能運行” var foo = function () { // 將函數表達式賦值給本地變量“foo” alert("this won't run!"); } function bar() { // 'bar'函數聲明,分配“bar”名字 alert("this will run!"); } } test();
在這種情況下,僅僅函數聲明的函數體被提升到頂部。名字“foo”被提升,但后面的函數體,在執行的時候才被指派。
這是全部的基本提升,看起來並不復雜和讓人混淆。當然,這是JavaScript,在某些特殊性況下會更復雜一點。
名字解析順序
需要記住的最重要的特殊情況是名字的解析順序。記住作用域中的名字有四種來源。上面我列出他們的順序是他們被解析的順序。一般來說,如果一個名字已經被定義過,那么它不會在被其他有相同名字的屬性重寫。這意味着函數聲明優先於變量聲明。這並不意味着為名字賦值的過程將不工作,僅僅聲明的過程會被忽略。有幾個例外情況:
- 函數的內置變量arguments比較奇怪。它看起來是在普通的函數參數之后才聲明,其實是在函數聲明之前。如果參數里面有名稱為arguments的參數,它會比內置的那個優先級高,即使它是undefined。所以不要使用arguments作為為函數參數的名稱。
- 嘗試使用this作為標示符的地方都會造成一個語法錯誤。這是一個很好的特性。
- 如果多個參數具有相同的名字,那么最后一個參數會優先於先前的,即使它是undefined。
命名函數表達式
你可以在函數表達式給中給函數命名,用這樣的語法不能完成一個函數聲明,下面有一些代碼來說明我的意思:
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 () {}; // 匿名函數表達式(“foo”會被提升) function bar() {}; // 函數聲明(“bar”和函數體會被提升) var baz = function spam() {}; // 命名函數表達式(僅“baz”會被提升) foo(); // valid bar(); // valid baz(); // valid spam(); // ReferenceError "spam is not defined"
編碼時如何使用這些知識
現在你應該理解了作用域和提升(hoisting),那么我們在編寫JavaScript的時候應該怎么做呢?最重要的事情就是始終用var表達式來聲明你的變量。我強烈建議你使用單var模式(single var)。如果你強迫自己做到這一點,你將永遠不會遇到任何與變量提升相關的混亂的問題。但是這樣做也讓我們很難跟蹤那些在當前作用域中實際上已經聲明的變量。我建議你使用JSLint和聲明一次原則來進行實際操作,如果你這樣做了,你的代碼應該會看起來像這樣:
/*jslint onevar: true [...] */ function foo(a, b, c) { var x = 1, bar, baz = "something"; }
標准給出的解釋
我翻了翻ECMAScript標准,想直接了解這些東西是如何工作的,發現效果不錯。這里我不得不說關於變量聲明和作用域(第12.2.2節)的內容:
如果在一個函數中聲明變量,這些變量就被定義在了在該函數的函數作用域中,見第10.1.3所述。不然它們就是被定義在全局的作用域內(即,它們被創建為全局對象的成員,見第10.1.3所述),當進入執行環境的時候,變量就被創建。一個語句塊不能定義一個新的作用域。只有一個程序或者函數聲明能夠產生一個新的作用域。創建變量時,被初始化為undefined。如果變量聲明語句里面帶有賦值操作,則賦值操作只有被執行到聲明語句的時候才會發生,而不是創建的時候。
我希望這篇文章闡明了對JavaScript程序員來說最常見的迷惑問題,我試圖講的盡可能詳盡,以避免造成更多的迷惑,如果我說錯了或者有大的遺漏,請通知我。
注
原文 http://www.adequatelygood.com/JavaScript-Scoping-and-Hoisting.html
相關閱讀
- 了解JavaScript的執行上下文
- 在JavaScript中什么時候使用==是正確的?
- 我希望我知道的七個JavaScript技巧
- 每一個JavaScript開發者應該了解的浮點知識
- 揭秘javascript中謎一樣的this