理解ES6中的暂时死区(TDZ)

13

Temporal Dead Zone(TDZ)是ES6(ES2015)中对作用域新的专用语义。TDZ名词并没有明确地写在ES6的标准文件中,一开始是出现在ES Discussion讨论区中,是对于某些遇到在区块作用域绑定早于声明语句时的状况时,所使用的专用术语。

以英文名词来说明,Temporal是"时间的、暂时的"意义,Dead Zone则是"死区",意指"电波达不到的区域"。所以TDZ可以翻为"时间上暂时的无法达到的区域",简称为"时间死区"或"暂时死区"。

let/const与var

在ES6的新特性中,最容易看到TDZ作用就是在let/const的使用上,let/const与var的主要不同有两个地方:

  • let/const是使用区块作用域;var是使用函数作用域

  • 在let/const声明之前就访问对应的变量与常量,会抛出ReferenceError错误;但在var声明之前就访问对应的变量,则会得到undefined

console.log(aVar) // undefined
console.log(aLet) // causes ReferenceError: aLet is not defined
var aVar = 1
let aLet = 2

根据ES6标准中对于let/const声明的章节13.3.1,有以下的文字说明:

The variables are created when their containing Lexical Environment is instantiated but may not be accessed in any way until the variable’s LexicalBinding is evaluated.

意思是说由let/const声明的变量,当它们包含的词法环境(Lexical Environment)被实例化时会被创建,但只有在变量的词法绑定(LexicalBinding)已经被求值运算后,才能够被访问。

注: 这里指的"变量"是let/const两者,const在ES6定义中是constant variable(固定的变量)的意思。

说得更明白些,当程序的控制流程在新的作用域(module, function或block作用域)进行实例化时,在此作用域中的用let/const声明的变量会先在作用域中被创建出来,但因此时还未进行词法绑定,也就是对声明语句进行求值运算,所以是不能被访问的,访问就会抛出错误。所以在这运行流程一进入作用域创建变量,到变量开始可被访问之间的一段时间,就称之为TDZ(暂时死区)。

以上面解说来看,以let/const声明的变量,的确也是有提升(hoist)的作用。这个是很容易被误解的地方,实际上以let/const声明的变量也是会有提升(hoist)的作用。提升是JS语言中对于变量声明的基本特性,只是因为TDZ的作用,并不会像使用var来声明变量,只是会得到undefined而已,现在则是会直接抛出ReferenceError错误,而且很明显的这是一个在运行期间才会出现的错误。

用一个简单的例子来说明let声明的变量会在作用域中被提升,就像下面这样:

let x = 'outer value'

(function() {
  // 这里会产生 TDZ for x
  console.log(x) // TDZ期间访问,产生ReferenceError错误
  let x = 'inner value' // 对x的声明语句,这里结束 TDZ for x
}())

在例子中的IIFE里的函数作用域,变量x在作用域中会先被提升到函数区域中的最上面,但这时会产生TDZ,如果在程序流程还未运行到x的声明语句时,算是在TDZ作用的期间,这时候访问x的值,就会抛出ReferenceError错误。

在let与const声明的章节13.3.1接着的几句,说明有关变量是如何进行初始化的:

A variable defined by a LexicalBinding with an Initializer is assigned the value of its Initializer’s AssignmentExpression when the LexicalBinding is evaluated, not when the variable is created. If a LexicalBinding in a let declaration does not have an Initializer the variable is assigned the value undefined when the LexicalBinding is evaluated.

这几句比较重点的部份是关于初始化的过程。以let/const声明的变量或常量,必需是经过对声明的赋值语句的求值后,才算初始化完成,创建时并不算初始化。如果以let声明的变量没有赋给初始值,那么就赋值给它undefined值。也就是经过初始化的完成,才代表着TDZ期间的真正结束,这些在作用域中的被声明的变量才能够正常地被访问。

下面这个例子是一个未初始化完成的结果,它一样是在TDZ中,也是会抛出ReferenceError错误:

let x = x

因为右值(要被赋的值),它在此时是一个还未被初始化完成的变量,实际上我们就在这一个同一表达式中要初始化它。

注: TDZ最一开始是为了const所设计的,但后来的对let的设计也是一致的,例子中都用let来说明会比较容易。

注: 在ES6标准中,对于const所声明的识别子仍然也经常为variable(变量),称为constant variable(固定的变量)。以const声明所创建出来的常量,在JS中只是不能再被赋(can't re-assignment),并不是不可被改变(immutable)的,这两种概念仍然有很大的差异。

函数的传参预设值

TDZ作用在ES6中,很明确的就是与区块作用域(block scope),以及变量/常量的要如何被初始化有关。实际上在许多ES6新特性中都有出现TDZ作用,而另一个常会被提及的是函数的传参预设值中的TDZ作用。

下面的例子可以看到在传参预设值的识别名称,在未经初始化(有赋到值)时,它会进入TDZ而产生错误,而这个错误是只有在函数调用时,要使用到传参预设值时才会出现:

function foo(x = y, y = 1) {
  console.log(y)
}

foo(1) // 这不会有错误
foo(undefined, 1) // 错误 ReferenceError: y is not defined
foo() // 错误 ReferenceError: y is not defined

从这个例子可以知道TDZ的作用,实际上在ES6中到处都有类似的作用。

传参预设值有另一个作用域的议题会被讨论,就是对于传参预设值的作用域,到底是属于"全局作用域"还是"函数中的作用域"的议题,目前看到比较常见的说法是,它是处于"中介的作用域",夹在这两者之间,但仍然会互相影响。中介的作用域的一个例子,是使用其他函数作为传参的预设值,这通常会是一个callback(回调、回呼)函数,一般的情况没什么特别,但涉及作用域时互相影响的情况下会不易理解。下面这个例子来自这里:

let x = 1

function foo(a = 1, b = function(){ x = 2 }){
  let x = 3
  b()
  console.log(x)
}

foo()

console.log(x)

这个例子中的最后结果,在函数foo中输出的x值到底是1、2还是3?另外,在最外围作用域的x最后会被改变吗?

函数中的x输出结果不可能是1,这是很明确的,因为函数区块中有另一个x的声明与赋值let x = 3语句,这两个都有可能被运行产生作用。剩下的是传参预设值中的那个函数,是不是会变量到函数区块中的x值的问题。另一个是,在全局中的那个x变量,会不会被改变,这也是一个问题。

按照这个例子的出处文档的说明,作者认为答案是3与1。但是根据我的实验,下面的几个浏览器与编译器并不是这样认为:

  • babel编译器: 2与1

  • Closure Compiler: 3与2

  • Google Chrome(v55): 3与2

  • Firefox(v50): 2与1

  • Edge(v38): 3与2

实际测试的结果,怎么都不会有3与1的答案,要不就3与2,要不就2与1。

3与2的答案是让b传参的x = 2运行出来,但因为受到中介作用域的影响,因此干扰不到函数中的原本区块中的作用域,但会影响到全局中的x变量。也就是基本上认定函数预设值中的那个callback中的作用域与全局(或外层)有关系。

2与1的答案则是倒过来,只会影响到函数中的区块,对全局(或外层)没有影响。

所以除非中介作用域,有自己独立的作用域,完全与函数区块中的作用域与全局都不相干,才有可能产生3与1的结果,这是这篇文档的作者所认为的。

这个函数预设值的作用域因为实作不同,造成两种不同的结果,但如果以Chrome(v55)与Firefox(v50)来实验,在TDZ期间的抛出错误的行为基本上会一致,但Firefox有两种不同的错误消息,例如下面的几个例子:

// Chrome: ReferenceError: x is not defined
// Firefox: ReferenceError: x is not defined
function foo(a = 1, b = function(){ let x = 2 }){
  b()
  console.log(x)
}
foo()
// Chrome: ReferenceError: x is not defined
// Firefox: ReferenceError: can't access lexical declaration `x' before initialization
function foo(a = 1, b = function(){ x = 2 }){
  b()
  console.log(x)
}
foo()
let x = 1
// Chrome: ReferenceError: x is not defined
// Firefox: ReferenceError: can't access lexical declaration `x' before initialization
function foo(a = 1, b = function(){ x = 2 }){
  b()
  console.log(x)
  let x = 3
}
foo()

不管如何,这个作用域的影响仍然是有争议的,目前并没有统一的答案。这代表ES6虽然标准定好了,但里面的一些新特性仍然有实作细节的差异,未来有可能这些差异才会慢慢一致。但对一般的开发者来说,因为知道了有这些情况,所以要尽量避免,以免产生不兼容的情况。

要如何避免这种情况?最重要的就是,"不要在传参预设值中作有副作用的运算",上面的function(){ x = 2 }是有副作用的,它有可能会改变函数区块中,或是全局中的同名称变量,而在整个代码中,可能会互相影响的作用域彼此间,避免使用同样识别名称的变量,这也是一个很基本的撰写规则。

注: 本节的内容可以参考这几篇文档TEMPORAL DEAD ZONE (TDZ) DEMYSTIFIEDES6 Notes: Default values of parameters与这个Default parameters intermediate scope讨论文。

TDZ的其它议题(陷阱)

typeof语句

对TDZ期间中的变量/常量作任何的访问动作,一律会抛出错误,使用typeof的语句也一样。如下面的例子:

typeof x // "undefined"

{
  // TDZ
  typeof x // ReferenceError
  let x = 42
}

但有些开发者会认为像typeof这样的语句,需要被用来判断变量是否存在,不应该是导致抛出错误,所以有部份反对的声音,认为它让typeof语句变得不安全,会造成使用上的陷阱。实际上这原本就是TDZ的设计,变量本来就不该在没声明完成前访问,这是为了让JS运行更为合理的改善设计,只是之前JS在这一部份是有缺陷的作法,实际上会用typeof与undefined来判别变量/常量存在与否的方式,通常是对于全局变量的才会作的事情。

TDZ期间抛出的错误是运行阶段的错误

TDZ期间所抛出的错误,是一种运行阶段的错误,因为TDZ除了作用域的绑定过程外,还需要有变量/常量初始化的过程,才会创建出TDZ的期间。下面两个例子就可以看到TDZ的错误需要真正运行到才会出现:

// 这个例子会有因TDZ抛出的错误
function f() { return x }
f() // ReferenceError
// 这个例子不会有错误
function f() { return x }
let x = 1

那这会有什么问题出现?因为要能侦测出代码中的因TDZ造成的错误,唯有透过静态的代码分析工具,或是要真正调用到函数运行里面的代码,才会产生错误,这将会让TDZ在编译工具中实作变得困难。

不过只要你理解TDZ的设计,就知道只能这样设计,初始化过程原本就只会在调用运行阶段作这事,这部份还是只能靠其它工具来补强。

支持ES6的浏览器上的运行效能

ES Discussion上对于let/const的效能很早以前就已经有些批评的,认为在浏览器上实作的结果,由于TDZ的设计,会让let相较于var的效能至少要慢5%。

上面这篇贴文是在4年前所发表,就算是当时的实验性质的实作在JS引擎上,没有经过优化,实际上真的效能有差这么大也不得而知。加上let本身在for回圈上有另外的花费,与var的设计不同,这两个比较当然会有所不同,是不是都是TDZ影响的也不知道。

以最近在讨论区中的let与var的效能比较议题来看,let的运行效率只有在某些情况下(for回圈中)会慢var很多,在基本的内部作用域测试反而是快过var的,当然这也是要视不同的浏览器与版本而定。

题外话是,在其它的回答中就有明确的指出,会促使加入TDZ的主因是针对const,而不是let。但最后TC39的决议是让let与const都有一致的TDZ设计。

ES6到ES5的编译

ES6中的许多新式的设计仍然是很新的JS语言特性,目前ES6仍然需要依赖如babel之类的编译器,将ES6语法编译到ES5,来进行在浏览器上运行前的最后编译。

这些编译器对于TDZ是会如何编译?答案是目前"并不会直接编译"。

以babel来说,它预设不会编译出具有TDZ的代码,它需要额外使用babel-plugin-transform-es2015-block-scoping或编译时的选项es6.blockScopingTDZ,才会将TDZ与区域作用域的功能编译出来。基本上这应该属于实验性质的,而且现在在使用上还有满多问题的。ES5标准中原本就没这种设计,所以说实在硬要使用也是麻烦,TDZ会造成的错误是运行期间的错误,对于编译器来说,在实作上也有一定的难度。

你可能感兴趣的

隐秘通途 · 2017年04月09日

hi!文章的“函数的传参预设值”小节,第二个示例代码,原作者已经修改了。即:当中介作用域有变量屏蔽了外部作用域的同名变量时,不会修改外部的变量

+1 回复

0

好的,感谢补充说明

eyesofkids 作者 · 2017年04月10日
polunzh · 2017年02月03日

觉得typeof抛出ReferenceError是合理的.

回复

MinJieLiu · 2017年02月14日

抛弃 var 是最好的

回复

载入中...