原文链接:http://www.adequatelygood.com/JavaScript-Scoping-and-Hoisting.html
JavaScript作用域与声明提升
你知道下面JavaScript执行后alert的值吗?
1 var foo = 1; 2 function bar() { 3 if (!foo) { 4 var foo = 10; 5 } 6 alert(foo); 7 } 8 bar();
如果因为结果为“10”而吃惊,那么可能你需要好好看看这篇文章:
1 var a = 1; 2 function b() { 3 a = 10; 4 return; 5 function a() {} 6 } 7 b(); 8 alert(a);
这个呢?浏览器会alert为“1”,那么这些结果为何如此呢?确实这些看起来有点奇怪,危险,并且混乱,但这恰恰是说明JavaScript是一个强大和富有表现力的语言。我不知道这种特性的标准名称,但是我习惯叫它"提升“,这篇文章将尝试阐明这一机制,但首先我们需要先了解JavaScript的作用域。
JavaScript的作用域
许多初学者最容易混淆的就是“作用域”,实际上,它不只初学者容易混淆,我见过很多有经验的Javascript程序员也不能完全理解“作用域”。之所以弄不清JavaScript的作用域,是因为把它理解成了类C语言的作用域。思考下面的C程序:
1 #include <stdio.h> 2 int main() { 3 int x = 1; 4 printf("%d, ", x); // 1 5 if (1) { 6 int x = 2; 7 printf("%d, ", x); // 2 8 } 9 printf("%d\n", x); // 1 10 }
这个程序的输出结果为1,2,1.这是因为C,和类C语言有块级作用域。当代码执行到一个块内,例如一对大括号内,新的变量声明在这个作用域内,不会影响大括号的外部。这是不同于JavaScript的。在Firebug下尝试以下代码:
1 var x = 1; 2 console.log(x); // 1 3 if (true) { 4 var x = 2; 5 console.log(x); // 2 6 } 7 console.log(x); // 2
在这种情况中,Firebug会显示1,2,2.这是因为JavaScript为函数作用域,这完全不同于类C语言的块级,比如在大括号内,它是不会创建新的作用域的。只有在函数才会。
而很多语言都使用的块级作用域,比如C,C++,C#,和Java,所以这很容易让刚学JavaScript的程序员无法理解,幸好,JavaScript的函数定义非常灵活,如果逆需要在一个函数内创建一个临时的作用域,你可以这样:
1 function foo() { 2 var x = 1; 3 if (x) { 4 (function () { 5 var x = 2; 6 // some other code 7 }()); 8 } 9 // x is still 1. 10 }
声明变量和提升
在JavaScript中,一个变量进入作用域有四种基本途径:
- 语言定义:全局作用域,默认情况下,有变量this和arguments。
- 形参:函数可以有形参,它的作用域为整个函数内。
- 函数声明:例如这种形式 function foo() {}
- 变量声明:例如这种形式 var foo;
函数声明和变量声明会在解析JavaScript程序是进行内部“提升”,形参和全局变量已经存在了,意味着提升的是3,4两种变量类型,意味着代码在解析后会像这样:
1 function foo() { 2 bar(); 3 var x = 1; 4 } 5 实际上解释后会像这样: 6 7 function foo() { 8 var x; 9 bar(); 10 x = 1; 11 }
事实证明,不管是否包含变量声明它都是存在的。下面两个函数是等价的:
1 function foo() { 2 if (false) { 3 var x = 1; 4 } 5 return; 6 var y = 1; 7 } 8 function foo() { 9 var x, y; 10 if (false) { 11 x = 1; 12 } 13 return; 14 y = 1; 15 }
注意:当函数作为变量定义的值时声明不会被提升,这种情况只有变量名会被提升,这导致函数名提升了,但函数体没有被提升,但请记住函数声明有两种形式,考虑以下JavaScript:
1 function test() { 2 foo(); // TypeError "foo is not a function" 3 bar(); // "this will run!" 4 var foo = function () { // function expression assigned to local variable ‘foo‘ 5 alert("this won‘t run!"); 6 } 7 function bar() { // function declaration, given the name ‘bar‘ 8 alert("this will run!"); 9 } 10 } 11 test();
在这个例子中,只有包含函数体的函数声明会被提升到顶部,而变量"foo"被提升了,但是它的主体在右边,只有在语句执行到此时才会被分配。
这就是提升的基本概念,这样看起来也不是那么复杂和容易混淆了吧。当然在写JavaScript时,会遇到一些特殊情况会稍微复杂点。
变量名解析顺序
最重要的是记住在特殊情况下的变量名解析顺序。它们有四种方式进入命名空间,这个顺序根据列表从上到下一次进行,这个顺序列表在下面列出,在一般情况下,如果一个命名已经被定义,那么它不会被另一个同名的所覆盖,这就意味着一个函数声明要优先于变量声明,这并不等于分配新的命名无效,只是声明将被屏蔽。他们也有些例外:
- 内置arguments的怪异情况,它看起来在函数声明之前形参已经被声明,这意味着一个形参名arguments将优先于内置的arguments,即使它没有被定义,这是一个不好的特征。不要使用arguments作为一个形参。
- 如果定义this这个命名在一些地方,会导致语法错误。这是一个好的特性。
- 如果多个形参有相同的名字,那么形参中最后一个同名的将被优先,哪怕它没有被定义。
函数命名表达式
你可以使用函数表达式的形式将函数定义赋给一个函数名,语法像函数定义,但它不同于函数声明,这个命名没有进入作用域,函数体也没有提升。这里有一些代码作为例子来说明其含义:
1 foo(); // TypeError "foo is not a function" 2 bar(); // valid 3 baz(); // TypeError "baz is not a function" 4 spam(); // ReferenceError "spam is not defined" 5 6 var foo = function () {}; // anonymous function expression (‘foo‘ gets hoisted) 7 function bar() {}; // function declaration (‘bar‘ and the function body get hoisted) 8 var baz = function spam() {}; // named function expression (only ‘baz‘ gets hoisted) 9 10 foo(); // valid 11 bar(); // valid 12 baz(); // valid 13 spam(); // ReferenceError "spam is not defined"
如何使用这个知识来写代码
现在你了解了作用域与命名提升,但如何写JavaScript代码呢?一个非常重要的事情是在任何声明变量的时候都使用var.我强烈建议你在每一个作用域内的头部使用var定义变量,如果你总是如此,你将不会因为提升的特性而混乱。然而,这样做很难区分当前作用域下实际被声明的变量和以有的变量。我建议使用JSLint的onevar选项来执行这些。如果你准备这样做,那么你的代码应该看起来是这样:
1 /*jslint onevar: true [...] */ 2 function foo(a, b, c) { 3 var x = 1, 4 bar, 5 baz = "something"; 6 }
规范怎么说明的
我发现经常查阅ECMAScript Starndard去理解这些特性是如何工作的是非常有用的。这里是它说明变量声明和作用域(最新版的12.2.2部分)
如果变量声明在函数声明内部,那么这个变量被定义在当前函数作用域内,由10.1.3节所述。另外,他们都被定义在全局作用域(即,他们创建的是全局对象的成员,由10.1.3所述)的属性上,并具有属性的特性。变量被创建在当前执行的作用域内,一个块没法产生一个新的作用空间,只有程序和函数声明产生新的作用空间。变量被初始化时创建成一个undefined。一个变量真正被初始化是在使用表达式给变量分配一个存在的值。不是在变量被创建时。
我希望这篇文章解释清楚了JavaScript代码中的一些易混淆的特性。我试图全面的解释这些问题,避免产生新的问题,如果产生了更多的混淆,如果我翻了任何错误或重大遗漏,请让我知道。