JavaScript的作用域和提升機制

2018-06-16 20:16 更新

你知道下面的JavaScript代碼執(zhí)行時會輸出什么嗎?

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”。怎么回事?這似乎看起來是奇怪,未知,讓人混淆的,但這實際上是這門語言一個強大和富有表現(xiàn)力的特性。我不知道這一特性行為是否有標準名字,但我喜歡這個術語“提升(hoisting)”。本文試圖揭示這一特性的機制,但首先讓我們鏈接JavaScript的作用域。

JavaScript中的作用域(scope)

JavaScript初學者最容易混淆的地方是作用域。實際上,不只是初學者。我遇到過許多經(jīng)驗豐富的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語句,新的變量會在塊作用域里聲明,不會對外面作用域產(chǎn)生印象。這不適用于JavaScript。在Firebug里運行下面的代碼:

var x = 1;
console.log(x); // 1
if (true) {
    var x = 2;
    console.log(x); // 2
}
console.log(x); // 2

在這個例子中,F(xiàn)irebug將輸出1,2,2。這是因為JavaScript有函數(shù)級作用域(function-level scope)。這一點和C家族完全不同。語句塊,如if語言,不創(chuàng)建新的作用域。僅僅函數(shù)創(chuàng)建新作用域。

很多程序員,像C,C++,C#或Java,都不知道這點,也不希望這樣。幸運的是,因為JavaScript函數(shù)的靈活性,有一個解決方案。你若你必須要在函數(shù)內部創(chuàng)建一個臨時作用域,像下面這樣做:

function foo() {
    var x = 1;
    if (x) {
        (function () {
            var x = 2;
            // 此處省略一萬個字
        }());
    }
    // x 仍然是 1.
}

這方法實際上相當靈活,可以在你需要臨時作用域的時候隨意使用,不局限于塊級語句內部。然而,我強烈建議你花時間去了解和欣賞JavaScript的作用域。它非常強大,是這門語言中我最喜歡的特性之一。如果你了解作用域,將更容易理解提升。

聲明,名字和提升(Hoisting)

在JavaScript中,作用域中的名字(屬性名)有四種基本來源:

  1. 語言定義:默認所有作用域都有屬性名this和arguments。
  2. 形參:函數(shù)可能有形式參數(shù),其作用域是整個函數(shù)體內部。
  3. 函數(shù)聲明:類似于function foo() {}這種形式。
  4. 變量聲明:var foo;這種形式的代碼。 函數(shù)聲明和變量聲明總是被JavaScript解釋器無形中移動到(提升)包含他們的作用域頂部。函數(shù)參數(shù)和語言定義的名稱明顯總是存在。這意味著像下面的代碼:

    function foo() { bar(); var x = 1; }

實際上被解釋為像下面這樣:

function foo() {
    var x;
    bar();
    x = 1;
}

無論包含聲明的代碼行是否會被執(zhí)行,上面的過程都會發(fā)生。下面的兩個函數(shù)是等價的:

function foo() {
    if (false) {
        var x = 1;
    }
    return;
    var y = 1;
}
function foo() {
    var x, y;
    if (false) {
        x = 1;
    }
    return;
    y = 1;
}

注意變量聲明中賦值的過程不會被提升。僅僅變量名字被提升了。這不適用于函數(shù)聲明,整個函數(shù)體也會提升。但不要忘記有兩種聲明函數(shù)的方法??紤]下面的JavaScript代碼:

function test() {
    foo(); // 類型錯誤 “foo 不是一個函數(shù)”
    bar(); // “這能運行”
    var foo = function () { // 將函數(shù)表達式賦值給本地變量“foo”
        alert("this won't run!");
    }
    function bar() { //  'bar'函數(shù)聲明,分配“bar”名字
        alert("this will run!");
    }
}
test();

在這種情況下,僅僅函數(shù)聲明的函數(shù)體被提升到頂部。名字“foo”被提升,但后面的函數(shù)體,在執(zhí)行的時候才被指派。

這是全部的基本提升,看起來并不復雜和讓人混淆。當然,這是JavaScript,在某些特殊性況下會更復雜一點。

名字解析順序

需要記住的最重要的特殊情況是名字的解析順序。記住作用域中的名字有四種來源。上面我列出他們的順序是他們被解析的順序。一般來說,如果一個名字已經(jīng)被定義過,那么它不會在被其他有相同名字的屬性重寫。這意味著函數(shù)聲明優(yōu)先于變量聲明。這并不意味著為名字賦值的過程將不工作,僅僅聲明的過程會被忽略。有幾個例外情況:

  • 函數(shù)的內置變量arguments比較奇怪。它看起來是在普通的函數(shù)參數(shù)之后才聲明,其實是在函數(shù)聲明之前。如果參數(shù)里面有名稱為arguments的參數(shù),它會比內置的那個優(yōu)先級高,即使它是undefined。所以不要使用arguments作為為函數(shù)參數(shù)的名稱。
  • 嘗試使用this作為標示符的地方都會造成一個語法錯誤。這是一個很好的特性。
  • 如果多個參數(shù)具有相同的名字,那么最后一個參數(shù)會優(yōu)先于先前的,即使它是undefined。

命名函數(shù)表達式

你可以在函數(shù)表達式給中給函數(shù)命名,用這樣的語法不能完成一個函數(shù)聲明,下面有一些代碼來說明我的意思:

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 () {}; // 匿名函數(shù)表達式(“foo”會被提升)
function bar() {}; // 函數(shù)聲明(“bar”和函數(shù)體會被提升)
var baz = function spam() {}; // 命名函數(shù)表達式(僅“baz”會被提升)

foo(); // valid
bar(); // valid
baz(); // valid
spam(); // ReferenceError "spam is not defined"

編碼時如何使用這些知識

現(xiàn)在你應該理解了作用域和提升(hoisting),那么我們在編寫JavaScript的時候應該怎么做呢?最重要的事情就是始終用var表達式來聲明你的變量。我強烈建議你使用單var模式(single var)。如果你強迫自己做到這一點,你將永遠不會遇到任何與變量提升相關的混亂的問題。但是這樣做也讓我們很難跟蹤那些在當前作用域中實際上已經(jīng)聲明的變量。我建議你使用JSLint和聲明一次原則來進行實際操作,如果你這樣做了,你的代碼應該會看起來像這樣:

/*jslint onevar: true [...] */
function foo(a, b, c) {
    var x = 1,
        bar,
        baz = "something";
}

標準給出的解釋

我翻了翻ECMAScript標準,想直接了解這些東西是如何工作的,發(fā)現(xiàn)效果不錯。這里我不得不說關于變量聲明和作用域(第12.2.2節(jié))的內容:

如果在一個函數(shù)中聲明變量,這些變量就被定義在了在該函數(shù)的函數(shù)作用域中,見第10.1.3所述。不然它們就是被定義在全局的作用域內(即,它們被創(chuàng)建為全局對象的成員,見第10.1.3所述),當進入執(zhí)行環(huán)境的時候,變量就被創(chuàng)建。一個語句塊不能定義一個新的作用域。只有一個程序或者函數(shù)聲明能夠產(chǎn)生一個新的作用域。創(chuàng)建變量時,被初始化為undefined。如果變量聲明語句里面帶有賦值操作,則賦值操作只有被執(zhí)行到聲明語句的時候才會發(fā)生,而不是創(chuàng)建的時候。

我希望這篇文章闡明了對JavaScript程序員來說最常見的迷惑問題,我試圖講的盡可能詳盡,以避免造成更多的迷惑,如果我說錯了或者有大的遺漏,請通知我。

原文 http://www.adequatelygood.com/JavaScript-Scoping-and-Hoisting.html

以上內容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號