你知道下面这段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);
复制代码
浏览器会alert("1")。那么,到底发生了什么?这看起来可能很陌生、很古怪并且令人困惑,但这正是这个语言强而有力的表现特征。我不清楚这个特征的专有名词,但是我更愿意用“hoisting”来表达。这篇文章将试着去揭开这种机制的面纱,但是我们先着重理解JavaScript的作用域。
Scoping in JavaScipt
对于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系的语言都有块级作用域(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有函数作用域(function-level scope)。这完全不同与C系语言的块级作用域,例如下面的if语句块里面不会创建一个新的作用域。只有function才能创建新的作用域。
对于许多熟悉像C,C++,C#或者Java的程序员来,这个设定是超出预期并且不友好的。幸运的是,鉴于JavaScript中函数的灵活性,有个曲线救国的方法。如果你一定要在function中创建临时的作用域,可以做如下尝试:
function foo() {
var x = 1;
if (x) {
(function () {
var x = 2;
// some other code
}());
}
// x is still 1.
}
复制代码
这个方法确实很灵活,并且能在任何时候需要临时作用域时使用,不仅仅局限在块语句中。但是,我强烈的建议你真的需要应该花些时间去理解和正确的认识JavaScript的作用域。它真的很有用,也是这个语言吸引我的特色之一。如果你理解了作用域,hoisting对你来说也将变得容易许多。
Declarations, Names, and Hoisting
在JavaScript,以一个名称存在于作用域有以下4中方法:
1.语言本身定义(Language-defined):所有作用域默认包含this和arguments。
2.形式参数(Formal parameters):函数能带入形式参数,使其能从函数外部作用域进入函数内部作用域。
3.函数声明(Function declarations):这是函数声明的形式 function foo(){}。
4.变量声明(Variable declarations):声明的形式 var foo;
函数的声明和变量的声明总是被JavaScript编译器偷偷的提升(“hoisted”)到它们所在作用域的顶部。函数参数和语言本身定义的已经明显的存在在那里。这种形式像下面这段代码:
function foo() {
bar();
var x = 1;
}
复制代码
事实上被编译成下面这样:
function foo() {
var x;
bar();
x = 1;
}
复制代码
结论是声明的那行是否被执行都是无关紧要的。下面两个function是等价的:
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();
复制代码
这种情况下,只有函数声明的形式才会带着函数体一起提升。函数表达式形式:“foo”被提升了,但是它的函数体部分被遗留在赋值的时候执行。
以上涵盖了基本的提升(“hoisting”),并不是看起来那么复杂和令人迷惑。当然,作为JavaScript,在特殊情况下是会有那么一些复杂的东西。
Name Resolution Order
在大多数重要特殊的时候应时刻铭记在心是名称解析的顺序。牢记一个名称进入作用域有四种方法。我在上面列举的例子就是他们解析的顺序。总的来说,如果一个名称已经被定义,它永远不会被另一个同名的不同属性覆盖。这意味着函数的声明要优先于变量的声明。但这并不代表对着名称的复制不起作用,仅仅只是声明部分被忽略。这里有一些例外:
- 内置的arguments有些古怪,它似乎在形式参数之后声明,但是有是在函数声明之前。这一位置形式参数比arguments拥有更高的优先级,即使这个参数是undefined。这是个坏的特性。不要使用arguments作为形式参数。
- 试图使用this作为会造成SyntaxError错误。这是个好的特性。
- 如果有多和形式参数名称一模一样,对优先使用最后一个,及时这个参数是undefined。
Named Function Expressions
你可以在函数表达式中给函数定义名称,就像函数声明的语句一样。这样并不能使它成为函数的声明,并且这个名称没有被带入到作用域,函数体也没有被提升。下面是一些代码来阐明我的意思:
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"
复制代码
How to Code With This Knowledge
现在你理解了作用域和变量提升,但是对编写JavaScript来说意味着什么呢?最重要的是,声明你的变量的时候总是使用var语句。我强烈建议你在每个作用域的首位使用var语句。如果你强制自己这样做,你将永远不会被提升的问题困扰。然而做这个会使追踪当前作用域实际声明了哪些变量变得困难。我建议在JSLint中设置onevar选项来控制这个。如果你已经我说的所有工作,那么你的代码有点像下面这样:
/*jslint onevar: true [...] */
function foo(a, b, c) {
var x = 1,
bar,
baz = "something";
}
复制代码
What the Standard Says
我发现直接去查询ECMAScript Standard(PDF)理解这些东西是如何运作的方式是最有用的。这是我讨论的关于变量神经和作用域的段落(section 12.2.2 in the older version):
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程序员。我已经很努力尽可能的直接的阐述,避免制造更多的困惑。如果我写错了或者遗漏了什么,请让我知道。