你不懂JS:作用域与闭包
第四章:提升
至此,你应当对作用域的想法,以及变量如何根据它们被声明的方式和位置附着在不同的作用域层级上感到相当适应了。函数作用域和块儿作用域的行为都是依赖于这个相同规则的:在一个作用域中声明的任何变量都附着在这个作用域上。
但是关于出现在一个作用域内各种位置的声明如何附着在作用域上,有一个微妙的细节,而这个细节正是我们要在这里检视的。
先有鸡还是先有蛋?
有一种倾向认为你在 JavaScript 程序中看到的所有代码,在程序执行的过程中都是从上到下一行一行地被解释执行的。虽然这大致上是对的,但是这种猜测中的一个部分可能会导致你错误地考虑你的程序。
考虑这段代码:
a = 2;
var a;
console.log( a );
你觉得在 console.log(..)
语句中会打印出什么?
许多开发者会期望 undefined
,因为语句 var a
出现在 a = 2
之后,这很自然地看起来像是这个变量被重定义了,并因此被赋予了默认的 undefined
。然而,输出将是 2
。
考虑另一个代码段:
console.log( a );
var a = 2;
你可能会被诱导而这样认为:因为上一个代码段展示了一种看起来不是从上到下的行为,也许在这个代码段中,也会打印 2
。另一些人认为,因为变量 a
在它被声明之前就被使用了,所以这一定会导致一个 ReferenceError
被抛出。
不幸的是,两种猜测都不正确。输出是 undefined
。
那么。这里发生了什么? 看起来我们遇到了一个先有鸡还是先有蛋的问题。哪一个先有?声明(“蛋”),还是赋值(“鸡”)?
编译器再次袭来
要回答这个问题,我们需要回头引用第一章关于编译器的讨论。回忆一下,引擎 实际上将会在它解释执行你的 JavaScript 代码之前编译它。编译过程的一部分就是找到所有的声明,并将它们关联在合适的作用域上。第二章向我们展示了这是词法作用域的核心。
所以,考虑这件事情的最佳方式是,在你的代码的任何部分被执行之前,所有的声明,变量和函数,都会首先被处理。
当你看到 var a = 2;
时,你可能认为这是一个语句。但是 JavaScript 实际上认为这是两个语句:var a;
和 a = 2;
。第一个语句,声明,是在编译阶段被处理的。第二个语句,赋值,为了执行阶段而留在 原处。
于是我们的第一个代码段应当被认为是这样被处理的:
var a;
a = 2;
console.log( a );
……这里的第一部分是编译,而第二部分是执行。
相似地,我们的第二个代码段实际上被处理为:
var a;
console.log( a );
a = 2;
所以,关于这种处理的一个有些隐喻的考虑方式是,变量和函数声明被从它们在代码流中出现的位置“移动”到代码的顶端。这就产生了“提升”这个名字。
换句话说,先有蛋(声明),后有鸡(赋值)。
注意: 只有声明本身被提升了,而任何赋值或者其他的执行逻辑都被留在 原处。如果提升会重新安排我们代码的可执行逻辑,那就会是一场灾难了。
foo();
function foo() {
console.log( a ); // undefined
var a = 2;
}
函数 foo
的声明(在这个例子中它还 包含 一个隐含的、实际为函数的值)被提升了,因此第一行的调用是可以执行的。
还需要注意的是,提升是 以作用域为单位的。所以虽然我们的前一个代码段被简化为仅含有全局作用域,但是我们现在检视的函数foo(..)
本身展示了,var a
被提升至foo(..)
的顶端(很明显,不是程序的顶端)。所以这个程序也许可以更准确地解释为:
function foo() {
var a;
console.log( a ); // undefined
a = 2;
}
foo();
函数声明会被提升,就像我们看到的。但是函数表达式不会。
foo(); // 不是 ReferenceError, 而是 TypeError!
var foo = function bar() {
// ...
};
变量标识符 foo
被提升并被附着在这个程序的外围作用域(全局),所以 foo()
不会作为一个 ReferenceError
而失败。但 foo
还没有值(如果它不是函数表达式,而是一个函数声明,那么它就会有值)。所以,foo()
就是试图调用一个 undefined
值,这是一个 TypeError
—— 非法操作。
同时回想一下,即使它是一个命名的函数表达式,这个名称标识符在外围作用域中也是不可用的:
foo(); // TypeError
bar(); // ReferenceError
var foo = function bar() {
// ...
};
这个代码段可以(使用提升)更准确地解释为:
var foo;
foo(); // TypeError
bar(); // ReferenceError
foo = function() {
var bar = ...self...
// ...
}
函数优先
函数声明和变量声明都会被提升。但一个微妙的细节(可以 在拥有多个“重复的”声明的代码中出现)是,函数会首先被提升,然后才是变量。
考虑这段代码:
foo(); // 1
var foo;
function foo() {
console.log( 1 );
}
foo = function() {
console.log( 2 );
};
1
被打印了,而不是 2
!这个代码段被 引擎 解释执行为:
function foo() {
console.log( 1 );
}
foo(); // 1
foo = function() {
console.log( 2 );
};
注意那个 var foo
是一个重复(因此被无视)的声明,即便它出现在 function foo()...
声明之前,因为函数声明是在普通变量之前被提升的。
虽然多个/重复的 var
声明实质上是被忽略的,但是后续的函数声明确实会覆盖前一个。
foo(); // 3
function foo() {
console.log( 1 );
}
var foo = function() {
console.log( 2 );
};
function foo() {
console.log( 3 );
}
虽然这一切听起来不过是一些有趣的学院派细节,但是它强调了一个事实:在同一个作用域内的重复定义是一个十分差劲儿的主意,而且经常会导致令人困惑的结果。
在普通的块儿内部出现的函数声明一般会被提升至外围的作用域,而不是像这段代码暗示的那样有条件地被定义:
foo(); // "b"
var a = true;
if (a) {
function foo() { console.log( "a" ); }
}
else {
function foo() { console.log( "b" ); }
}
然而,重要的是要注意这种行为是不可靠的,而且是未来版本的 JavaScript 将要改变的对象,所以避免在块儿中声明函数可能是最好的做法。
复习
我们可能被诱导而将 var a = 2
看作是一个语句,但是 JavaScript 引擎 可不这么看。它将 var a
和 a = 2
看作两个分离的语句,第一个是编译期的任务,而第二个是执行时的任务。
这将导致在一个作用域内的所有声明,不论它们出现在何处,都会在代码本身被执行前 首先 被处理。你可以将它可视化为声明(变量与函数)被“移动”到它们各自的作用域顶部,这就是我们所说的“提升”。
声明本身会被提升,但不是赋值,即便是函数表达式的赋值,也 不会 被提升。
要小心重复声明,特别是将一般的变量声明和函数声明混在一起 —— 如果你这么做的话,危险就在眼前!