不要使用声明提升

不要使用声明提升

引用 ES6 规范的作者 Allen Wirfs-Brock 的推文

Hoisting is old and confused terminology. Even prior to ES6: did it mean “moved to the top of the current scope” or did it mean “move from a nested block to the closest enclosing function/script scope”? Or both?

这篇博客文章提出了一种描述声明的不同方法(受 Allen 的建议启发)。

声明:作用域和激活

我建议区分声明的两个方面:

  • 作用域:在哪里可以看到声明的实体?这是一个静态特性。
  • 激活:我什么时候可以访问实体?这是一个动态特性:一旦我们进入其作用域,就可以访问这些实体。其它方面,我们必须等到执行到达他们的声明。

下表总结了各种声明如何处理这些方面。“声明”描述是否允许在同一作用域内声明两次名称。“全局属性”描述了一个声明是否在全局作用域内执行脚本(模块的前身)时向全局对象添加属性。TDZ 表示"temporal dead zone(时间死区)"(稍后解释)。函数声明在严格模式下是块作用域的(例如在模块内部),但在非严格模式下是函数作用域。

作用域激活重复声明全局属性
const块级decl. (TDZ)
let块级decl. (TDZ)
function块级(严格模式)start
class块级decl. (TDZ)
import模块export 相同
var函数start, partially

下面的章节将详细说明一部分这些构造的行为。

constlet:时间死区

在 JavaScript 中,TC39 需要决定如果在声明之前访问其直接作用域中的常量会发生什么:

{
  console.log(x); // What happens here?
  const x;
}

一些可能的方法:

  1. 该名称在当前作用域的作用域内解析。
  2. 你得到 undefined
  3. 抛出一个错误。

(1) 被拒绝了,这一提议在该语言中没有先例。JavaScript 工程师不能直观理解该提议。

(2) 被拒绝了,如果是这样的话,x 就不能作为一个常量 - 它在声明前后拥有不同的值。

let 采用与 const 相同的提议 (3),所以两者的行为相似,它们两者之间很容易替换。

变量进入作用域和执行声明之间的时间称作变量的"temporal dead zone(TDZ)"(译作 - 时间死区):

  • 在此时间内,变量还没有初始化(就像它是一个特殊值)。
  • 如果访问未初始化的变量,将返回异常(ReferenceError)。
  • 一旦变量完成声明,变量设置为初始值(具体值通过赋值运算符决定)或 undefined(如果没有初始化值) 中的一个。

下面举例说明暂时死区:

if (true) {
  // entering scope of `tmp`, TDZ starts
  // (进入 `tmp` 的作用域, TDZ 开始)
  // `tmp` is uninitialized(`tmp` 没有初始化):
  tmp = 'abc'; // throws error(抛出 ReferenceError 异常)
  console.log(tmp); // throws error(抛出 ReferenceError 异常)

  let tmp; // TDZ ends(TDZ 结束)
  console.log(tmp); // output: undefined
}

下面的例子展示的时间死区是正确的:

if (true) {
  // entering scope of `myVar`, TDZ starts
  const func = () => {
    console.log(myVar); // executed later
  };

  // We are within the TDZ:
  // Accessing `myVar` causes `ReferenceError`

  let myVar = 3; // TDZ ends
  func(); // OK, called outside TDZ
}

函数 func() 位于变量 myVar 的声明之前并且使用了它,我们能够执行 func() 。但是我们必须等到 myVar 的时间死区过去。

函数声明和声明提前

函数声明总是被执行,只要在它的作用域内,无论在作用域的什么地方。这将允许执行函数在函数 foo() 声明之前:

console.log(foo()); // output: 123
function foo() {
  return 123;
}

函数 foo() 声明提前意味着上面的代码等价于:

function foo() {
  return 123;
}
console.log(foo());

如果通过 constlet 声明函数,它将不会声明提前:在下面的例子,你只能 bar() 的声明之后使用它。

bar(); // before declaration(声明之前调用,将抛出异常)

const bar = () => {
  return 123;
};

bar(); // after declaration(声明之后,将返回正确的结果)

没有声明提前就提前执行

即使函数 g() 没有使用声明提前,它也能在在它之前函数 f() 内执行(在相同的作用域内) - 如果我们遵守下面的规则:函数 f() 的执行必须在函数 g() 的定义之后。

const f = () => g();
const g = () => 123;

// We call f() after g() was declared:
console.log(f()); // output: 123

模块的函数通常在执行完整的主体后调用。因此,在模块中,很少需要担心函数的顺序。

最后,请注意声明提前如何自动保留上述规则:进入作用域时,在进行任何调用之前,首先执行所有函数声明。

声明提前的缺陷

如果依赖于声明提前在声明之前调用函数,那么需要注意它不会访问未声明提前的数据。

funcDecl();

const MY_STR = 'abc';
function funcDecl() {
  console.log(MY_STR); // 将抛出异常
}

跳过这一问题的方法是在 MY_STR 的声明之后执行函数 funcDecl()

声明提前的利弊

我们已经看到声明提前的一个陷阱,你可以在不使用它的情况下获得大部分好处。因此,最好避免声明提前。但我对此并不十分强烈,并且如前所述,经常使用函数声明,因为我喜欢它们的语法。

类声明不会提前

类的声明不会提前:

new MyClass(); // 将抛出异常

class MyClass {}

console.log(new MyClass() instanceof MyClass); // output: true

为什么是这样?考虑下面的函数声明:

class MyClass extends Object {}

extends 是可选的。它的操作数是一个表达式。如此,可以这样做:

const identity = (x) => x;
class MyClass extends identity(Object) {}

评估这样的表达式必须在提到它的位置进行。其它任何做法都会令人困惑。这就解释了为什么类声明不会提前。

var:声明提前

var 是在 const 和 let 之前旧的方式声明变量。考虑下面的 var 声明:

var x = 123;

这种声明有两部分:

  • 声明 var x: var 声明对于大多数其它声明,变量的作用域是最里面的函数作用域,而不是最里面的块作用域。因此变量在其作用域的开始处已经激活并且使用 undefined 初始化。
  • 赋值 x = 123: 赋值总是在定义的位置。

以下代码演示 var

function f() {
  // Partial early activation:
  // 声明提前:
  console.log(x); // output: undefined
  if (true) {
    var x = 123;
    // The assignment is executed in place:
    // 在此处赋值
    console.log(x); // output: 123
  }
  // Scope is function, not block:
  // 作用域是函数作用域而不是块作用域:
  console.log(x); // output: 123
}

本文作者 Axel Rauschmayer,转载请注明来源链接:

原文链接:https://2ality.com/2019/05/unpacking-hoisting.html

本文链接:https://tie.pub/2019/07/unpacking-hoisting/