首先来看一段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)的相关知识。