返回首页 深度解析 ECMAScript 6

let 和 const

下面我想要介绍的是一个谦卑的惊世之作。

在 1995 年布兰登·艾克设计 JavaScript 的第一个版本时,他得到了许多错误,包括有些从那以后已经成为语言的一部分的东西,比如如果你不慎把 Date object 和 objects 相乘,它会自动转换成 NaN。然而,他所做对的事情在事后来看是极其重要的:对象、原型、有词法作用域的一流功能、可变性默认。语言具有良好的骨骼框架。这比以前实现的任何语言都要好。

不过,布兰登做了一个特殊的决策,也是本章的重点内容——一个我认为可以被公开定性为错误的决定。这是一个小事情,一个微妙的事情。你可能已经使用这个语言多年但从未注意到它。但是它很重要,因为这个错误存在于现在我们所认为的“好的部分”里面。

它必须与变量一起工作。

问题#1:块不是作用域

这个规则听起来很无辜。在一个 JS 函数内声明的一个 var 的作用域是那个函数的整个主体。但是这里有两个方式可能让它产生无病呻吟的效果。

一是在块中声明的变量的作用域不只是这个块。它是整个函数。

你可能从未意识到这个。我恐怕这是你容易忽略的东西之一。我们来看一个场景,它导致了一个棘手的问题。

你现在的代码中有一个变量,名为 t:

function runTowerExperiment(tower, startTime) {
  var t = startTime;
  tower.on("tick", function () {
    ... code that uses t ...
  });
  ... more code ...
}

到目前为止一切都很好。现在你想要添加保龄球速度测量,所以你在内部回调函数中加入了一个if-语句。

function runTowerExperiment(tower, startTime) {
  var t = startTime;
  tower.on("tick", function () {
    ... code that uses t ...
    if (bowlingBall.altitude() <= 0) {
      var t = readTachymeter();
      ...
    }
  });
  ... more code ...
}

哦,亲爱的。你在不知不觉中添加了第二个名为 t 的变量。现在来看,之前工作地好好的“变量 t”指向新的内部变量,而不再是现有的外部变量。

JavaScript 中 var 的作用域就像是 Photoshop 中的油漆桶工具。它同时向声明的前后两个方向延伸,直到到达一个函数边界。因为这个变量 t 的作用域向后延伸的太长了,所以我们在输入函数时必须尽快创建它。这就叫做变量提升(hoisting)。我喜欢把这想象成 JS 引擎利用一个微小的代码起重机把每个 var 和函数提升到封闭函数顶。

现在,变量提升有了它的优点。没有了它,许多在全球作用域内工作良好的 cromulent 技术就无法在IIFE内工作。但在这种情况下,变量提升会带来一个讨厌的错误:你所有用了 t 的计算都会产生 NaN。这很难追查,特别是当你的代码比这个玩具例子更长的时候。

添加一个新的代码块会给该块之前的代码带来一个难以理解的错误。只有我有这样的问题,还是这真的很奇怪?我们不想影响到前面的原因。

但是与第二个 var 问题相比,这真的是小菜一碟。

问题#2:循环中的变量过度分享

你可以猜猜你运行这个代码时会发生什么。这非常容易想。

var messages = ["Hi!", "I'm a web page!", "alert() is fun!"];
for (var i = 0; i < messages.length; i++) {
  alert(messages[i]);
}

如果你一直关注 ES6,你会知道我喜欢用 alert() 作为示例代码。也许你也知道 alert() 是一个糟糕的 API。它是同步的,所以当一个 alert 可见时,输入的事件不会传递。你的 JS 代码——其实是你的整个界面——在用户点击 OK 之前基本暂停了。

你在 web 页面上做的所有的操作,都可以使用 alert() 作为错误提示。我使用它是因为我认为所有这些相同的事情会让 alert() 成为一个有效的调试工具。

不过,我可能会被说服去放弃所有不良行为…如果这意味着我可以做一只会说话的猫。

var messages = ["Meow!", "I'm a talking cat!", "Callbacks are fun!"];
for (var i = 0; i < messages.length; i++) {
  setTimeout(function () {
    cat.say(messages[i]);
  }, i * 1500);
}

来看看这个代码在操作中的不正确工作!

但有些事情是错误的。猫会说三次“未定义”,而不是依次说出这三条信息。

你能找出下图的一个 “bug” 吗?

图片来源:内维尔·赛沃瑞"

这里的问题是只有一个变量 i。它被循环本身和三个超时回调共享。当循环完成运行,i 的值是 3(因为 messages.length 是 3)而且所有的回调函数都还没有被回调。

所以当第一个超时触发并调回 cat.say(messages[i]) 是用了未被定义的 messages[3]

解决这个问题的方法有许多(这里有一个),但这是一个 var 作用域规则带来的二次问题。如果从一开始就没有过这种问题,那便真是太好了。

let 是新型 var

在大多数情况下,JavaScript(还有其他编程语言,但尤其是 JavaScript)中的设计错误无法被修复。向后兼容性意味着永远不改变现有的 JS 代码在网络上的行为。即使是标准委员会也没有办法修复 JavaScript 自动插入分号这件怪事。浏览器制造商根本无法实现突破性改革,因为那种改变根本就是虐待他们的用户。

所以大约十年前 Brendan Eich 决定解决这个问题时,也就只有一个方法。

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

它看起来是这样的:

let t = readTachymeter();

let 和 var 是不同的,如果你只在你的代码中做了一个全局搜索替换,可能就会由于 var 的原因破坏了你的部分代码(可能是无意地)。但在大多数情况下,在新的 ES6 代码中你应该使用 let 代替 var。所以我们的口号是:“let 是新型 var”。

let 和 var 之间的真正区别是什么?很高兴你问了这个问题!

  • let 变量是有块作用域的。用 let 定义的变量作用域只是封闭块,而不是整个封闭函数。现在仍有 let 变量提升,但是不再是随意提升。在 TherunTowerExperiment 例子中,可以用把 var 替换成 let 的方法来进行简单修复。如果你在每个地方都是使用的 let,你就不会有这种错误。

  • 全局 let 变量不是全局对象的属性。也就是说,你不能通过写 window.variableName 访问它们。相反,它们存在于一个无形块作用域内,我们可以想象成一个网页运行的所有的 JS 代码都被封闭在了一起。

  • for (let x...)循环形式在每个迭代中都为 x 创建了一个新鲜的捆绑。

这是一个非常微妙的差异。这意味着如果一个 for(let...)循环执行多次并且该循环包含一个闭包,就像我们之前举的会说话的猫的例子,每个闭包都会捕获一个不同副本的循环变量而不是每个闭包都捕获相同的循环变量。所以示例会说话的猫也可以通过用 let 替换 var 来完成修复。

这适用于所有三种 for 循环:for–of,for–in 和加上分号的老式 C 类。

  • 在声明之前使用一个 let 变量是错误的。在控制流到达有声明的代码行之前变量是未初始化的。例如:
function update() {
  console.log("current time:", t);  // ReferenceError
  ...
  let t = readTachymeter();
}

这个规则是帮助你定位错误。你会在有问题的代码上获得一个提醒而不是结果 NaN。

未初始化变量在作用域内的时期称为暂时性死区。我一直在等待这个激发性的术语可以跃位到科幻小说。但是什么都还没有发生。

(脆脆的性能细节:在大多数情况下,你可以说出是否声明已经运行了或只是在查看代码,所以 JavaScript 引擎真的不需要在每次变量访问时都进行一次额外检查来确保它已经初始化了。然而有时在闭包内这并不清楚。在这种情况下 JavaScript 引擎会进行运行时检查。这代表与 var 相比 let 需要更长的访问时间。)

(交替全局作用域细节:在某些程序设计语言中,变量的作用域在声明点开始,而不是到达后面覆盖整个封闭块。标准委员考虑过为 let 使用这个作用域规则。但这样使用 t 就会导致引用错误,随后的 let t 就不在作用域中了,所以它根本不会再查阅那个变量。它可以在一个封闭作用域中查阅 t,但这个方法在有闭包或有函数变量提升的情况下不好用,所以它最终被抛弃了。)

  • 用 let 重新声明变量是语法错误。

这条规则也有助于你发现微小错误。但这是在你尝试 let-to-var 转换时容易导致问题的不同之处,因为它提供了甚至全局的 let 变量。

如果你有一些都是声明相同全局变量的脚本,你最好使用 var。如果你用了 let,任何脚本在二次加载的时候都会出现错误无法运行。

或者运用 ES6 模块。但这又是另一个故事了。

(语法细节:let 是一个严格模式下的保留词。在非严格模式的代码中,为了向后兼容,你仍然可以声明变量、函数和名为 let 的实参——你可以写 var let = 'q';!,而不是你原本想写的那样。let let; 也是不允许的。)

除了这些差异,let 和 var 几乎是一样的。它们都支持声明多个用逗号隔开的变量,例如他们都支持解构。

注意,类声明的行为和 let 很像而和 var 不像。如果你加载了一个包含多次类的脚本,在第二次时你会因为没有重新声明类而得到一个错误。

Const

对了,还有一件事!

ES6 还引入了第三级关键字,你可以在 let 后使用:const。

用 const 声明的变量就像 let,除非你不能指定它们或除非你在它们的声明点进行了声明,这样会产生语法错误。

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

秘密代理命名空间

命名空间是一个杰出的想法——“让我们来做更多吧!”——蒂姆·皮特斯。

在幕后,嵌套的作用域是一个核心概念,编程语言围绕着它来建立。从ALGOL之后就是这样了吗?就像57年前一样。今天它已经变得更加真实。

在 ES3 之前,JavaScript 只有全局作用域和函数作用域。(我们先忽略语句。)ES3 退出了 try–catch 语句,这意味着增加了一种新的作用域,它仅用于异常变量未捕捉块。ES5 添加了供 strict eval() 使用的作用域,ES6 添加了块作用域,for-循环作用域,新全局 let 作用域,模块作用域和评估实参默认值时使用的附加其他作用域。

ES3 之前添加的所有额外作用域都有必要使 JavaScript 的程序相关的、面向对象的功能能够稳定地、准确地、像闭包一样直观地、能够与闭包无缝衔接地工作。

也许在今天之前你从未意识到任何这些作用域规则。如果是这样,这门语言就是正在执行它的工作。

我现在能用 let 和 const 了吗?

是的。为了在网络上使用他们,你必须用像Babel, TraceurTypeScript这样的 ES6 编译器。(Babel 和 Traceur 现在还不支持暂时性死区。)

io.js 只在严格模式代码中支持 let 和 const。Node.js 同样,但是还需要 —harmony 这一项。

九年前布兰登·艾克发行了使用 let 的第一个版本的火狐浏览器。在标准化过程中这个功能被彻底地重新设计了。郭书宇正在升级我们的成果以达到标准,代码审查工作由杰夫·瓦尔登等人完成。

好了,我们已经在终点直道上了。我们的 ES6 特性的史诗之旅即将结束。接下来,我们将完成所有特性中最值得期待的 ES6 特性。

上一篇: 代理 下一篇: 模块