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

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

以英文名词来讲明,Temporal是"时间的、暂时的"意义,Dead Zone则是"死区",意指"电波达不到的区域"。因此TDZ能够翻为"时间上暂时的没法达到的区域",简称为"时间死区"或"暂时死区"。es6

let/const与var

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

  • 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,有如下的文字说明:babel

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)已经被求值运算后,才可以被访问。ecmascript

注: 这里指的"变量"是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值究竟是一、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会形成的错误是运行期间的错误,对于编译器来讲,在实做上也有必定的难度。

相关文章
相关标签/搜索