JavaScript中作用域和声明提升

你知道下面这段JavaScript代码执行结果是什么吗?

var foo = 1;  
function bar() {  
    if (!foo) {
        var foo = 10;
    }
    alert(foo);
}
bar();  

如果你对运行结果感到惊讶,那下面这段代码会更让你糊涂。

var a = 1;  
function b() {  
    a = 10;
    return;
    function a() {}
}
b();  
alert(a);  

浏览器会弹出"1",这到底是怎么回事呢?这看起来很奇怪,事实上这恰好是JavaScript语言一个强大而又富有表现力的特性。我不知道这种特别的行为是否有一个标准的术语,但我喜欢把它叫做"hoisting"(声明提升)。接下来我会试着分析一下这种机制,但是我们有必要先理解一下JavaScript的作用域。

JavaScript中的作用域

对于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系语言有块级作用域。当程序运行到一个程序块的时候(比如if语句),在该程序块里定义的新变量不会影响到外部作用域。但在JavsScript中却不相同,请执行下面的脚本:

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

脚本执行后依次输出1,2,2,这是因为JavsScript只有函数级作用域,这和C系语言有着根本的不同,程序块(比如if语句)是不会创建新的作用域的,只有函数才能创建新作用域。 对于一些习惯了C、C++、C#或者Java语言的开发者来说,这简直让人难以接受。还好JavaScript的函数足够灵活,可以用其它变通方法。如果你一定要在函数内创建一个临时的作用域,可以这样做:

function foo() {  
    var x = 1;
    if (x) {
        (function () {
            var x = 2;
            // some other code
        }());
    }
    // x is still 1.
}

这个方法相当灵活,在任何需要的地方都可以使用,不止在块语句里。但是我强烈建议你花一些时间来真正理解和欣赏JavaScript的作用域,这是我最喜欢的语言特性之一,它真的非常强大。如果你理解了作用域,那么对于"hoisting"(声明提升)你会更容易理解。

声明,变量名,声明提前

在JavaScript中,一个变量可以通过以下四种方式之一进入作用域:

1、语言内置:所有作用域都默认包含"this"和"arguments"变量。
2、函数形参:函数可以拥有形参,所属作用域就是该函数体。
3、函数声明:形如"function foo(){}"的声明。
4、变量声明:形如"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;
}

需要注意的是,当声明和赋值一起写的时候,赋值并没有提升,只有声明被提前了。函数声明就有些特别,整个函数体都会被提前。但是不要忘了函数声明有两种方式,请看下面的代码:

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"被提前至顶端,函数体却没有。 上面的例子基本覆盖了声明提升的情况,看起来并不是那么复杂让人迷惑。当然,一些其它比较特别的例子还是有一些复杂的。

变量解析顺序

我们要特别记住变量的解析顺序,前面说过变量名进入作用域有四种方式,我上面列举的顺序就是它们被解析的顺序。通常,如果一个变量名已经定义了,那么它就不会被其它相同名称的变量所覆盖。这意味着函数声明比变量声明优先级高,但这并不影响赋值操作,只是声明部分会被忽略而已。以下是几种特殊情况:

1、内置的变量"arguments"表现比较奇怪,它好像定义在函数形参和函数声明之间。这意味着如果形参中有个变量为"arguments",那么它的优先级将高于内置的"arguments",即使它是undefined。这不是一个好的特性,不要使用"arguments"作为形参变量名。
2、使用"this"作为一个标识符会引起语法错误,这是一个好的特性。
3、如果多个形参中出现同名,那么最后一个将拥有最高的优先级,即使它是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 () {}; // 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"  

编程时如何使用这些知识

现在你已经理解作用域和声明提升这些知识了,那么这些知识对写JavaScript代码中有什么意义呢?最重要的是声明变量时要使用"var"关键字,我强烈建议你在每个作用域的顶端只写一个var语句(多变量的时候,用逗号连接)。如果你强制自己这样做,就不会遇到声明提升产生的相关困惑了。但是这样做也让我们很难跟踪那些在当前作用域中实际上已经声明的变量。我建议使用"JSLint"的"onevar"选项来验证代码,如果你照做了,你的代码看起来会像这样子:

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

看看ECMAScript官方解释

我翻了翻ECMAScript标准,想直接了解这些东西是如何工作的,发现效果不错。这里我不得不说关于变量声明和作用域(第12.2.2节)的内容:

如果在一个函数中声明变量,这些变量就被定义在了在该函数的函数作用域中,见第10.1.3所述。不然它们就是被定义在全局的作用域内(即,它们被创建为全局对象的成员,见第10.1.3所述),当进入执行环境的时候,变量就被创建。一个语句块不能定义一个新的作用域。只有一个程序或者函数声明能够产生一个新的作用域。创建变量时,被初始化为undefined。如果变量声明语句里面带有赋值操作,则赋值操作只有被执行到声明语句的时候才会发生,而不是创建的时候。

我希望这篇文章能够帮助JavaScript开发者理清一些困惑的问题,我已经尽可能的彻底把问题讲清楚,以免造成更多的疑惑。如果你发现我写错了或者遗漏了某些重要的东西,请留言让我知道。

转自 http://www.adequatelygood.com/JavaScript-Scoping-and-Hoisting.html