ES6 in Depth: Let and Const

ES6 In Depth: let and const

今天我要讨论的特性刚第一眼可能显得很简单,但实际上是很强大的。

当BrenDan Eich在1995年开始设计JS的第一个版本时,它做了很多错误的事情,这些事情还包括之后的语言发展中。例如,当你不小心错误处理Date和其它对象时,会自动转为NaN。但是,有些重要的事情他是正确的,有先见之明:对象、原型、第一类函数有词法作用域(就是函数是有作用域的)、默认是可变的。这语言基础是十分良好的,它优于大家对它的第一印象。

同时,Brendan 创建了个特别的设计并影响到今天的文章 – – 我认为他这决定是可以称为一个错误的。它是个小事情,十分微小的事情,你可能使用JS多年还没注意到它。但是,它是重要的,因为从这语言来看,这个错误的设计部分在今天来看,我们认为它是好的部分。

它不得不与变量一起处理。

问题1:代码块没有作用域

这问题看起来很无辜:在JS函数中进行var声明时,变量作用域是可以在整个函数体中的。其中,有两个方式来看到此问题引起的后果。

一个是在代码块中声明的变量的作用域不仅仅是在此代码块中,它会作用于整个函数。

你之前也许并不会注意到这点,但我担心这一点可能会你并不想看到的。让我们通过一场景来看一下这个狡猾的Bug。

你在某些代码中使用一个名称为t的变量:

1
2
3
4
5
6
7
8
function runTowerExperiment(tower, startTime) {
  var t = startTime;

  tower.on('click', function() {
    ... code that uses t ...
  });
  ... more code ...
}

目前,每件事都工作得很好。现在,你想增加保龄球的测速功能,所以,你在内部的callback函数中增加些if表达式。

1
2
3
4
5
6
7
8
9
10
11
12
function runTowerExperiment(tower, startTime) {
  var t = startTime;

  tower.on('click', function() {
    ... code that uses t ...
    if (bowlingBall.altitude() <= 0 ) {
      var t = readTachymeter();
      ...
    }
  });
  ... more code ...
}

噢,你无意地增加了第二个名称为t的变量。现在,之前正常工作的t,与一个新的内部的变量t关联上了,替代了外部早已存在的那个变量。

JS中var的作用域类似于PS中的桶刷工具,它会从声明、向前、向后各方向得到扩展,直到它碰到函数的边界,否则一直会起作用。因此,此处目前的t的作用域是向后兼容的,它就是我们在开始进行函数时创建的,这称作为变量升域(hoisting)。我几乎认为,JS引擎通过一个微小的代码起重机,将每个varfunction提升到函数闭域的顶部。

现在,升域也是有它好处的。没有这一特性,很多在全局作用域中能很好工作的完美的好的技术将不能在即时调用函数表达式(IIFE) 使用。但与此同时,升域会导致一个恶心的Bug: 当你使用t去计算时可能会得到NaN,这时你很难去追踪到它,特别是当你的代码大于此时的小例子时。

添加一个新的代码块会导致之前的代码块出现意想不到的错误。它只影响到我?它是不是真的怪异?我们并不想影响到之前的结果。

但是,相对于var的第二个问题,这个问题还是小的。

问题2:变量在循环中会过度地分享

你可以先猜测一下,当你运行这代码时会出现什么情况。它很简单:

1
2
3
4
5
var messages = ["Hi!", "I'm a web page!", "alert() is fun!"];

for (var i = 0; i < messages.length; i++;){
  alert(messages[i]);;
}

如果你看了这一系列的文章,你会知道我很喜欢在例子代码中使用alert()。也许,你也知道alert()是个糟糕的API,它是同步操作的。所以,当一个警告提示窗出现时,输入事件是不会传递的。在你的JS代码、实际在你的整个UI界面基本会是暂停的,直到用户点击确定选项。

多数时候,你在网页中使用alert()是个错误的选择,我之所以使用它,是因为我只觉得alert()是个很好的教学工具。

同时,我还应该被劝说放弃所有的杂乱和坏的行为,它意味着我可以这样创造一个说话的猫:

1
2
3
4
5
6
7
var messages = ["Meow!", "I'm a taling cat!", "callbacks are fun!");

for (var i=0; i < messages.length; i++) {
  setTimeout(function () {
    cat.say(messages[i]);
  }, i * 1500);
}

但是这会出错。这并不会顺序地说出三个消息,这代码猫会说出三次的”undefined”。

你能指出这个问题吗?

这问题出现在唯一的变量i中,它会作用于循环内部和三个延时的返回函数。但当循环完成时,i的值会变成3,因为messages.length等于3,但任一返回函数并没有被调用。

所以,当第一个延时函数工作时,会调用cat.say(messages[i]),它实际会是messages[3],而这会导致出现undefined

有很多的方法来解决这一问题,但这个是var的作用域规则导致的第二个问题。首先,确保代码不会有这一问题是十分好的。

let 新于 var

在很多时候,JS在设计上的缺陷是不能弥补的(其它语言也会设计缺陷,但JS犹为突出)。向后兼容意味着不能改变已经存在的Web中JS代码行为。甚至,标准委员会也没致力于此,认为解决这些怪异的问题就是自动插入分号(意思是注意细节?)。浏览器制作人并不想对这种破坏性的改变进行实现,因为这些改变会成为其用户的惩罚(也可能会因此失去部分用户)。

所以,大概十年之前,当 Brendan Eich 决定解决这问题时,那时只有一种方式来实现。

他添加了一个新的关键字let,它可以用来声明变量,就像var一样,但其作用域规则更好。

它看起来这样子:

1
let t = readTachymeter();

或者这样:

1
2
3
for (let i = 0; i < messages.length; i++) {
  ...
}

letvar是不一样的。如果你只是需要在你代码中做个全局的搜索和替换,又可以在你的部分代码中进行中断(有可能是无意的),那么你可以使用怪异的var。但是在更多时候,在ES6中,你应该在任何地方都要停止使用var,而使用let。正因为如此,标题为“let 新于 var”。

letvar到底有哪些不一样呢?很高兴你会提问:

  • let变量是代码块作用域的 使用let来声明一个变量,其作用域只会在包含的作用域中,而不是整个函数的内部。

let也会有一定的升域情况,但是它并不是任意的。用let替换var,可以简单地解决runTowerExperiment例子中的问题。如果你在任何的地方都使用let,你将不会再遇到此类问题。

  • 全局的let变量并不会成为全局对象的属性 是的,你不能通过编写window.variableName来访问它们。相反,它们运行在网页中的一个不可见的包含所有JS代码的块中。

  • for (let x ...)的每次循环中会创建新的绑定的变量x。这会有些微小的不同的,意味着for (let ...)循环执行多次时,每次其内部包含一个闭包。就像之前我们的猫例子,每个闭包会得到循环的不同的一个复本,而不是每次闭包得到同一个循环变量。

所以,对于猫这例子说,将var修改为let就可以解决之前的问题。

这作用于所有的三类for循环表达式:for-of, for-in 和老式C语言的分号方式。

  • 在没有遇到let声明之前使用变量会出错 当控制流没有到达变量声明的代码行时,其是未初始化的。例如:
1
2
3
4
5
function update() {
  console.log("current time:", t); //ReferenceError
  ...
  let t = readTachymeter();
}

这一规则可以帮助你抓取到Bug。结果并不是NaN,你将会在出现问题的代码行中得到一个异常。

这个变量在作用域但并没有被初始化的这一区域,被称为 temporal deal zone(临时死区间)。我一直等待更为专业的术语来描述这事情,就像在写科幻小说一样,然而没有。

(性能详情:在多数情况下,(在变量查找方式中)你可以告知变量是否声明,或者不仅仅去查找相关代码。所以,JS引擎实际上并不是每次都会进行额外的检查,以确认变量是否初始化。但是,在闭包中有时是不明确的,在这些情况下,就意味着使用let会比var慢。)

(作用域详情:在某些编程语言中,变量的作用域开始于其声明的地方,而不是在整个闭合的代码中向后查找。标准委员会认为可以通过let来使用这作用域规则。如此,当tlet t的语句后面调用时,会简单得到一个引用错误,所以,它也不会关联到任一变量。它应该在闭合的作用域中关联到变量t上,但这方法对于闭包或者函数式的升域都是无效的,所以它实际上会被忽略掉。)

  • 重复声明变量会引起语法错误 这一规则也是利于检测细小的错误。同时,它的不同点还有:当你试图将全局的let转化为var,会导致些问题,因为它曾经是个全局let的变量。

如果你的某些脚本声明了相同的全局变量,你最好保持使用var。如果你试图转化为let时,那些第二个用到此变量的脚本会出错的。

或者,使用ES6的模块,但是那是之后某天的故事了。

(语法详情:let是在严格的代码模式下是保留关键它。在非严格代码模式下,为了向后兼容,你依然可以声明变量、函数和参数的名称为let。你可以编写var let = 'q';,但你最好不要这让做。同时,let let;也是不允许的。)

除了这些不一样的地方,letvar其实是一样的。它们可以通过逗号来分开声明多个变量,例如,他们也都支持解构(释构?destructuring)。

注意,类class的声明类似于let,而不var。如果你在加载的脚本中多个声明一个类class,在第二次时你会因为重复声明类而得到一个错误。

常量(const)

是的,新的东西。

ES6介绍了一个三方的关键字,它可以在let的旁边使用: const

使用const声明变量类似于let,但不能在声明之外对它进行赋值,这会引起一个语法错误。

1
2
3
4
const MAX_CAT_SIZE_KG = 3000; //

MAX_CAT_SIZE_KG = 5000; // SyntaxError
MAX_CAT_SIZE_KG++; // nice try, but still a SyntaxError

明显够了,你不能只声明一个const变量而不赋值:

1
const theFairest; // SyntaxError, you troublemaker

命名空间的秘密

“命名空间是一个非常好的主意 – 让我们更多地使用它” – Tim Peters,”Python 的秘决”

在私底下,内嵌式的作用域是始终围绕编程语言的一个核心概念。自从ALGOL 出来后一直就是这样,已经大概有57年了,但对今天来说,此结论更为正确。

在ES3之前,JS只有全局的任务域和函数作用域。(让我们忽略掉with表达式。)ES3介绍了try-catch的表达式,它会增加一个新类型的作用域,此作用域只用来操作异常的变量并作用于catch代码块。ES5通过调用eval()会产生一个新的作用域。ES6添加了代码块作用域,for循环作用域,新的全局let作用域,模块作用域,还有用来解释参数中默认参数的额外作用域。

所有在ES3之后添加的作用域都是必要的,它们使得JS在程序上和面向对象特性上更合理、精确、直观,就如闭包一样;同时,可以和闭包无缝协作。也许你之前并没有注意到这些作用域的规则,如果是这样,说明JS做的工作并没有困扰你。

现在我能使用letconst吗?

可以。为了在Web中使用它们,你不得不使用ES6的编译器,如Babel, Traceur, 或者 TypeScript。(Babel 和 Traceur 并不支持 temporal deal zone(临时死区间)。)

io.js 支持letconst,但只能在严格模式下使用,Node.js 在支持上是一样的,但参数--harmony也是必要的。

Brendan Eich 在九年前实现了Firefox中let的第一个版本,这一特性在其标准化进程中进行了彻底的重新设计。为了符合标准,Shu-yu Guo 更新了此实现方式,代码审核由Jeff Walden和其它人完成。

好了,我们展开双手来欢迎新特性。ES6史诗级的特性到现在结束了。在之前两周中,我们完成了多数人渴望在ES6看到的特性的文章。但是首先要说明,下周我们将对之前早些时间提到的新特性:super。所以,和Eric Faust 一块加入我们,深入了解ES6的子类化。

Comments