chapter4-hoisting

You Don't Know JS: Scope & Closures

Chapter 4: Hoisting

到目前为止,你应该对作用域的概念感到相当满意,以及变量如何附加到不同的作用域级别,具体取决于它们的声明位置和方式。在这方面,函数作用域和块作用域都遵循相同的规则:在作用域内声明的任何变量都附加到该作用域。

但是, 对于作用域附属如何与作用域内不同位置出现的声明一起工作有一个微妙的细节, 而这一细节就是我们将在这里进行的研究。

Chicken Or The Egg?

人们很容易认为,在JavaScript程序中看到的所有代码都是在程序执行时逐行、自上而下地解释的。虽然这在本质上是正确的,但这种假设中有一部分可能导致对程序的错误思考。

考虑以下代码:

a = 2;

var a;

console.log( a );

你期望在console.log(..)语句中打印什么?

许多开发人员会期望undefined,因为var a语句出现在a = 2之后,并且假设变量被重新定义并因此分配默认的undefined似乎很自然。但是,输出将为2

考虑另一段代码:

console.log( a );

var a = 2;

你可能会想到这一点,因为前一个片段显示了一些不那么自上而下的行为,也许在这个片段中,也会打印出2。其他人可能会认为,因为在声明变量之前使用了变量,所以这必然会导致抛出ReferenceError

不幸的是,两个猜测都是不正确的。 输出的是undefined

那么,这里发生了什么? 看来我们有一个鸡和蛋的问题。首先发生哪一个?声明(“鸡蛋”)或赋值(“鸡”)?

The Compiler Strikes Again

要回答这个问题,我们需要回顾第1章和我们对编译器的讨论。回想一下,引擎实际上会在解释它之前编译你的JavaScript代码。编译阶段的一部分是查找所有声明并将其与适当的作用域相关联。第2章向我们展示了这是词法作用域的核心。

因此,考虑事物的最佳方式是在执行代码的任何部分之前,首先处理所有声明,包括变量和函数。

当你看到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(); // not ReferenceError, but 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...
    // ...
}

Functions First

函数声明和变量声明都被提升。但是一个微妙的细节(可以在代码中显示多个“重复”声明)是首先提升函数,然后是变量。

考虑下面代码:

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版本中可能会发生更改,因此最好避免在块中声明函数。

Review (TL;DR)

我们可以试着将var a = 2;作为一个声明,但JavaScript引擎不会这样看。它将var aa = 2视为两个单独的语句,第一个是编译器阶段任务,第二个是执行阶段任务。

这导致作用域中的所有声明,无论它们出现在何处,都会在代码本身执行之前首先处理。你可以将其视为“移动”到各自作用域顶部的声明(变量和函数),我们将其称为“提升”。

声明本身被提升,但赋值,甚至函数表达式的赋值都 不会提升

注意重复的声明,尤其是在普通的var声明和函数声明之间的混合声明——如果这样做的话,等待你的将是风险!

Last updated