首先来看一段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”。虽然,这看起来有些奇怪,甚至是迷惑,但这确是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以及其他C家族的语言拥有块级别作用域。当控制进入一个块内,例如if语句,就可以在这个块内声明新的变量,而不会影响到外部变量。但是在JavaScript中却不是这样的。看下面的程序:
var x = 1; console.log(x); // 1 if (true) { var x = 2; console.log(x); // 2 } console.log(x); // 2
在这个例子中,结果显示1,2,2。这是因为JavaScript拥有函数级别作用域。这从根本上不同于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作用域。它很强大,是这门语言中我最喜欢的功能之一。理解了作用域,预解析也就容易理解了。
声明,标识符和预解析
在JavaScript中,标识符进入作用域有4中方式:
- 语言定义——默认,所有的作用域都有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; }
注意,变量声明的赋值部分没有预解析,只有标识符预解析。函数声明则会将整个函数体都预解析。但是请记住有2种方式声明函数。思考下面的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'得到了预解析。上面例子预解析后的代码如下:
function test() { var foo; var bar = function bar() { // function declaration, given the name 'bar' alert("this will run!"); } foo(); // TypeError "foo is not a function" bar(); // "this will run!" foo = function () { // function expression assigned to local variable 'foo' alert("this won't run!"); } } test();
上面的内容涵盖了预解析的基本知识,现在看起来不是那么复杂和迷惑了。当然,在一些特殊的情况下还会有一些更复杂的东西。
标识符解析顺序
需要铭记于心的最重要的特殊情况就是标识符的解析顺序。记住,标识符进入一个给定的作用域有4种方式。前面我列举它们的顺序就是解析的顺序。通常,一个标识符已经定义了,它就不会被另一个同名的属性覆写。这就是说一个函数声明优先于变量声明。但不意味着对那个标识符的赋值无效,只是声明部分被忽略了。有一些例外:
- 内置标识符arguments的古怪行为。它看起来是在形参之后声明的,其实是在函数声明之前。也就是说arguments的形参会优先于内置的,即使它是undefined。这是一个糟糕的特性。不要使用标识符arguments作为形参。
- 任何地方使用this作为标识符都会引起SyntaxError。这是一个好的特性。
- 如果多个形参有相同的名字,出现在最后的优先,即使它是undefined的。
命名函数表达式
可以给函数表达式的函数一个名字,语法类似函数声明,但不是函数声明,如:var baz=function spam(){}; spam就是给定的名字,但是spam没有纳入作用域,函数体也没有预解析。下面的代码表达了我的意思:
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"
结束语
本篇文章翻译自:http://www.adequatelygood.com/JavaScript-Scoping-and-Hoisting.html ,文中只讲解了作用域,建议自行阅读JavaScript作用域链(scope chain)的相关知识。