JavaScript 作用域和变量提升

本文是这篇文章的简单翻译。

如果按照下面的代码按照JavaScript程序的执行方式执行,alert函数会显示什么?

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。那么,这里到底发生了什么呢?当然,它看起来有点奇怪、危险同时让人迷糊。但事实上它是JavaScript这门语言一个强大的富有表现力的特性(powerful and expressive feature)。我不知道这个具体的行为的标准名字,但我倾向于”提升(hoisting)“这一术语(term)。这篇文章试图阐明这个机制,但首先我们要先绕一个必要的弯路,了解JavaScript作用域(scoping)。

JavaScript的作用域

对于JavaScript初学者来说,最混乱的来源之一就是作用域。事实上,许多我遇到的有经验的JavaScript程序员都不能完全理解作用域(scoping)。JavaScript作用域之所以让人困惑,是因为它看起来像C-family语言。考虑如下C程序:

#include <stdio.h>
int main() {
    int x = 1;
    printf("%d, ", x); // 1
    if (1) {
        int x = 2;
        printf("%d, ", x); // 2
    }
    printf("%d
", x); // 1
}

程序的输出是1,2,1。这是因为C以及其他C family语言拥有块级作用域(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有函数级作用域(functions-level scope)。这从根本上不同于C-family语言。代码块,例如if语句,不会产生新的作用域。只有函数可以产生新的作用域。

对于大多数使用C、C++、C# 或者Java的程序员来说,这很让人意外而不受欢迎。幸运的是,由于JavaScript的灵活性,我们有变通的办法。如果你需要在一个函数产生临时的作用域,可以按照下面的做法做:

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

这个方法很灵活(原作者在if语句中写了一个立即执行函数),你可以在任何需要临时作用域的地方使用这个方法,而不仅仅是块语句。但是,我强烈建议你花时间好好理解和欣赏JavaScript 作用域。它十分强大,是我最喜欢的特性之一。如果你理解了作用域,你就可以比较容易的理解提升(hoisting)了。

声明(declarations),名字(names)和提升(hoisting)

在JavaScript中一个名字进入作用域有四个方法(a name enters a scope in one of four basic ways):

1).Language -defined:所用的作用域默认含有thisarguments

2).Formal parameters:函数可以具名的形参,它的作用域在这个函数中;

3).Function declaration:形式类似于function foo() {};

4).Variable declaration:形式类似于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(); // 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();

在这个情况下,只有包含了函数体的函数声明被提升到了顶部(指的是bar),名字foo虽然被提升了,剩下的赋值要等到执行时才会执(其实foo这种叫做赋值式函数声明,类似于变量声明,而bar这种声明叫做声明式函数声明)。

这就是提升的基本概念,似乎不是那么复杂和让人迷惑。当然,在有些特殊的情况下还有一些复杂。

名称解析顺序(Name Resolution Order)

我们需要谨记的最重要的特殊情况是名称解析顺序。记住,让一个名字进入一个给定的作用域有四种方法。上面列出来的顺序就是他们被解析的顺序。大体上,如果一个名字被定义,它永远不会被拥有相同名字的属性覆盖掉。这意味着函数声明的优先级高于变量声明。这不意味这无法给那个名字赋值,只是声明部分会被忽略。这也解释了第二个例子中为什么没有最后的a值为什么还是1。因为函数声明优先级高于变量声明,所以函数经过提升之后相当于声明了一个名字为a的函数,然后又重新赋值10给a,这时a就有函数变成了数值,这样相当于a成为了函数b内的局部变量了。所以并没有改变外部全局变量a的值。

这有几个例外:

1) 内建的名字 arguments 行为很奇怪。它看起来似乎在形参之后声明,但却在函数声明之前。这就意味着如果形参中有名为arguments的参数,它将优先于内建的,即使它是undefined。这是一个不好的特性。不要使用arguments作为形参的名字。

2)试图使用this作为标识符会导致语法错误(syntaxError)。

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";
}

标准怎么说

查看标准是理解事情如何工作的最好办法。在12.2.2中谈到了关于变量声明和作用域的事:
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执行顺序的链接:javascript运行机制之执行顺序详解

原文地址:https://www.cnblogs.com/muzinian/p/3644199.html