不要使用声明提升
引用 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 | ✔ | ✔ |
下面的章节将详细说明一部分这些构造的行为。
const
和 let
:时间死区
在 JavaScript 中,TC39 需要决定如果在声明之前访问其直接作用域中的常量会发生什么:
{
console.log(x); // What happens here?
const x;
}
一些可能的方法:
- 该名称在当前作用域的作用域内解析。
- 你得到
undefined
。 - 抛出一个错误。
(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())
如果通过 const
或 let
声明函数,它将不会声明提前:在下面的例子,你只能 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
声明:
// eslint-disable-next-line no-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
}
文章由 吳文俊 翻译,原文地址 Unpacking hoisting,转载请注明来源。