为了保证可读性,本文采用意译而非直译。
引用 ES6 规范作者 Allen Wirfs-Brock一条最近的推特:
变量提升是一个陈旧且令人困惑的术语。甚至在 ES6
之前:变量提升的意思究竟是“提升至当前作用域顶部”还是“从嵌套的代码块中提升到最近的函数或脚本作用域中”?还是两者都有?
受 Allen 启发,本文提出了一种不同的方法来描述变量声明。
1. 声明:作用域与激活
可以将声明分为两个方面:
作用域:在哪里可以看到声明的变量? 这是一个静态特征。
激活:我什么时候可以访问变量? 这是一个动态特征:有些变量只要我们进入其作用域,就可以访问。 有的,我们必须等到执行到它们的声明。
下表总结了不同声明的方式如何处理上述两个方面。
**“Duplicates”**描述是否可以在同一作用域内声明两次。
**“Global prop.”**表示一个在 script 中的声明,当全局作用域中被执行时,是否会向全局对象添加属性。
TDZ
表示暂时性死区(稍后解释)。 函数声明在严格模式下是块作用域的(例如在模块内部),但在非严格模式下是函数作用域。
2. const 和 let :暂时性死区
对于JavaScript,TC39 需要决定如果在声明之前访问其直接作用域中的常量会发生什么:
{ console.log(x); // 这里会发生什么? const x; }
主要有两种种情况:
打印
undefined
报错
第一种不会出现,因为 x 是一个常量,如果打印 undefined
,在声明前和声明后它将拥有不同的值,x 就不是常量了。
let 和 const 都会出现第二种情况,就是会报错。进入变量作用域与执行声明之间的这段时间被称为该变量的 临时死区(TDZ):
在临时死区中,变量被认为是未初始化的(就像它有一个特殊的值一样)。
如果访问未初始化的变量,将得到
ReferenceError
错误。一旦执行到变量声明,该变量将被设置为初始化器的值(通过赋值符号指定),如果没有初始化,则为
undefined
。
以下代码说明了临时死区:
if (true) { // 进入 `tmp` 的作用域,TDZ 开始 // `tmp` 未被初始化: assert.throws(() => (tmp = 'abc'), ReferenceError); assert.throws(() => console.log(tmp), ReferenceError); let tmp; // TDZ 结束 assert.equal(tmp, undefined); }
下一个例子表明临时死区只是 暂时的
(与时间有关):
if (true) { // 进入 `myVar` 作用域,TDZ 开始 const func = () => { console.log(myVar); // 稍后执行 }; // 我们在 TDZ 中: // 访问 `myVar` 造成 `ReferenceError` let myVar = 3; // TDZ 结束 func(); // OK,在 TDZ 外调用 }
即使 func()
位于myVar
声明之前使用 myVar
变量,但我们也可以调用func(),前提是必须等到myVar
的临时死区结束。
函数声明与提前激活
函数声明总是在进入它的作用域时执行,不管它位于作用域的什么位置。这使得能够在函数foo()
声明之前调用它:
assert.equal(foo(), 123); // ok,相等 function foo() { return 123; }
提前激活 foo()
意味着楼上的代码等价于
function foo() { return 123; } assert.equal(foo(), 123);
如果用 const
或 let
声明一个函数,它就不会被提前激活:在下面的例子中,你只能在 bar()
声明后调用它。
assert.throws( () => bar(), // 声明前 ReferenceError); const bar = () => { return 123; }; assert.equal(bar(), 123); // 声明后
在没有提前激活的情况下提前调用
即使函数g()
没有提前激活,也可以被前面的函数 f()
(在同一作用域内)调用 - 只要遵守以下规则:f()
必须在声明 g()
之后调用
const f = () => g(); const g = () => 123; // g() 声明后调用 f(): assert.equal(f(), 123);
模块中的函数通常在模块执行完后调用。 因此,在模块中,很少需要担心函数的顺序。
最后,注意提前激活是怎样自动执行以维持上述规则的:当进入一个作用域时,在任何函数被调用前,所有的函数声明都会被先执行。
提前激活的一个陷阱
如果依赖于提前激活机制,在函数声明之前调用函数,那么需要注意的是它不会访问未提前激活的变量。如下:
funcDecl(); const MY_STR = 'abc'; function funcDecl() { console.log(MY_STR) }
上述会报错:
如果你在 MY_STR
声明之后调用 funcDecl()
就不会有问题。
提前激活的利弊
我们已经看到提前激活有一个陷阱,你可以在不使用它的情况下获得大部分好处。因此,最好避免提前激活。但我对此说法并非十分认同,如前所述,我经常使用函数声明,因为我喜欢它们的语法。
类声明不会提前激活
类声明不会提前激活:
assert.throws( () => new MyClass(), ReferenceError); class MyClass {} assert.equal(new MyClass() instanceof MyClass, 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() { // 部分提前激活: assert.equal(x, undefined); if (true) { var x = 123; // 赋值已经执行 assert.equal(x, 123); } // 作用域为函数作用域,非块级作用域。 assert.equal(x, 123); }
原文:https://2ality.com/2019/05/unpacking-hoisting.html