深入 ES6 是关于 ECMAScript 标准第 6 版(简称 ES6)中添加到 JavaScript 编程语言的新功能的系列文章。
今天我要谈论的功能既谦逊又雄心勃勃。
当 Brendan Eich 在 1995 年设计第一个版本的 JavaScript 时,他有很多地方做错了,包括从那时起就一直存在于语言中的东西,比如 Date
对象,以及当您不小心将它们相乘时,对象会自动转换为 NaN
。然而,从后视镜来看,他做对的那些事情非常重要:对象;原型;具有词法作用域的一等函数;默认可变性。该语言有良好的基础。它比任何人都最初意识到的要好。
尽管如此,Brendan 做了一个特定的设计决策,这个决策与今天的文章相关——我认为这个决策可以被恰当地归类为错误。它很小。很微妙。您可能使用该语言多年,甚至不会注意到它。但它很重要,因为这个错误在语言的“好部分”中,我们现在认为是“好部分”。
它与变量有关。
问题 #1:块不是作用域
这条规则听起来很天真:在 JS 函数中声明的 var
的作用域 是该函数的整个函数体。 但是,这会导致两种令人沮丧的后果。
一个问题是,在块中声明的变量的作用域不仅仅是块。它是整个函数。
您可能以前从未注意到这一点。恐怕这是您无法忽视的事情之一。让我们来看一个会导致棘手错误的场景。
假设您有一些使用名为 t 的变量的现有代码
<pre>
function runTowerExperiment(tower, startTime) {
var t = startTime;
tower.on("tick", function () {
... 使用 t 的代码 ...
});
... 更多代码 ...
}
</pre>
到目前为止,一切正常。现在您想添加保龄球速度测量,因此您在内部回调函数中添加了一个小 if
语句。
<pre>
function runTowerExperiment(tower, startTime) {
var t = startTime;
tower.on("tick", function () {
... 使用 t 的代码 ...
if (bowlingBall.altitude() <= 0) {
var t = readTachymeter();
...
}
});
... 更多代码 ...
}
</pre>
哦,天哪。您无意中添加了第二个名为 t 的变量。现在,在“使用 t 的代码”中,之前工作正常的代码,t
指的是新的内部变量 t,而不是现有的外部变量。
JavaScript 中 var
的作用域就像 Photoshop 中的油漆桶工具。它从声明开始,向前和向后延伸,并且它会一直持续到遇到函数边界为止。由于此变量 t 的作用域向后延伸得如此之远,因此必须在我们进入函数时立即创建它。这称为 提升。我喜欢想象 JS 引擎用一个小代码起重机将每个 var
和 function
提升到封闭函数的顶部。
现在,提升有其优点。没有它,许多在全局作用域中工作正常的完美且有效的技术在 IIFE 内部将无法工作。但在这种情况下,提升会导致一个讨厌的错误:您使用 t 进行的所有计算将开始产生 NaN
。它也很难追踪,尤其是当您的代码比这个示例代码更大时。
添加一个新的代码块导致了该块之前的代码中出现了一个神秘的错误。是我一个人这么认为,还是这真的很奇怪?我们不希望效果先于原因。
但与第二个 var
问题相比,这只是小菜一碟。
问题 #2:循环中的变量过度共享
您可以猜到运行此代码时会发生什么。它非常直接
<pre>
var messages = ["Hi!", "I'm a web page!", "alert() is fun!"];
for (var i = 0; i < messages.length; i++) {
alert(messages[i]);
}
</pre>
如果您一直关注本系列文章,您就会知道我喜欢用 alert()
来作为示例代码。也许您还知道 alert()
是一个糟糕的 API。它是同步的。因此,当警报可见时,不会传递输入事件。您的 JS 代码(实际上是整个 UI)基本上会暂停,直到用户点击“确定”为止。
所有这些都使得 alert()
不适合您要在网页中执行的几乎所有操作。我使用它,因为我认为所有这些都使得 alert()
成为一个很好的教学工具。
尽管如此,如果这意味着我可以制作一只会说话的猫,我可能会被说服放弃所有这些笨拙和不良行为。
<pre>
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);
}
</pre>
但有些地方不对。这只猫没有按顺序说出所有三个信息,而是说了三次“undefined”。
你能发现这个错误吗?

这里的问题是只有一个变量 i。它由循环本身和所有三个超时回调共享。当循环完成运行时,i 的值为 3(因为 messages.length
为 3),并且尚未调用任何回调。
因此,当第一个超时触发并调用 cat.say(messages[i])
时,它使用的是 messages[3]
。当然,它是 undefined
。
有很多方法可以修复此问题(这里有一种),但这是由 var
作用域规则引起的第二个问题。如果一开始就不存在这种问题,那将非常棒。
let
是新的 var
在大多数情况下,JavaScript 中的设计错误(其他编程语言也是如此,但尤其是 JavaScript)是无法修复的。向后兼容性意味着永远不会更改 Web 上现有 JS 代码的行为。即使是标准委员会也无权修复 JavaScript 自动分号插入中的怪异问题。浏览器制造商根本不会实施重大更改,因为这种更改会惩罚他们的用户。
因此,大约十年前,当 Brendan Eich 决定修复这个问题时,实际上只有一种方法可以做到。
他添加了一个新的关键字 let
,它可以用来声明变量,就像 var
一样,但具有更好的作用域规则。
它看起来像这样
<pre>
let t = readTachymeter();
</pre>
或这样
<pre>
for (let i = 0; i < messages.length; i++) {
...
}
</pre>
let
和 var
是不同的,因此,如果您只是在整个代码中执行全局搜索和替换,这可能会破坏您代码中(可能是无意地)依赖于 var
特性的部分。但在大多数情况下,在新的 ES6 代码中,您应该停止使用 var
,而应该在所有地方使用 let
。因此,口号是:“let
是新的 var
”。
let
和 var
之间到底有什么区别?很高兴您问到这个问题!
-
let
变量是块级作用域的。 用let
声明的变量的作用域只是封闭的块,而不是整个封闭函数。let
仍然有提升,但它不像以前那样不加区分。runTowerExperiment
示例可以通过简单地将var
更改为let
来修复。如果您在所有地方都使用let
,您将永远不会遇到这种类型的错误。 -
全局
let
变量不是全局对象的属性。 也就是说,您不会通过编写window.variableName
来访问它们。相反,它们存在于一个虚拟块的作用域中,该块虚拟地包围着在网页中运行的所有 JS 代码。 -
形式为
for (let x...)
的循环在每次迭代中都会为 x 创建一个新的绑定。这是一个非常微妙的差异。这意味着,如果一个
for (let...)
循环执行多次,并且该循环包含一个闭包(就像我们的会说话的猫示例一样),每个闭包都会捕获循环变量的不同副本,而不是所有闭包都捕获同一个循环变量。因此,会说话的猫示例也可以通过简单地将
var
更改为let
来修复。这适用于所有三种
for
循环:for
–of
、for
–in
和使用分号的传统 C 风格。 -
在到达
let
变量的声明之前尝试使用它会导致错误。 在控制流到达声明该变量的代码行之前,该变量是 未初始化的。例如<pre>
function update() {
console.log("current time:", t); // ReferenceError
...
let t = readTachymeter();
}
</pre>这条规则是为了帮助您捕获错误。您不会得到
NaN
结果,而是在出现问题的那行代码上得到一个异常。变量在作用域中但未初始化的这段时间称为 暂时性死区。我一直期待着这个充满灵感的术语能够跃入科幻小说中。还没有。
(性能细节:在大多数情况下,您只需查看代码就能判断出声明是否已运行,因此 JavaScript 引擎实际上不需要在每次访问变量时执行额外的检查以确保它已被初始化。然而,在闭包内部,有时不清楚。在这些情况下,JavaScript 引擎会执行运行时检查。这意味着
let
可能比var
稍慢。)(关于语法细节的补充说明:在某些编程语言中,变量的作用域从声明点开始,而不是回溯到包含整个块的范围。标准委员会曾考虑对 `let` 使用这种作用域规则。这样,这里导致 `ReferenceError` 的 `t` 的使用就不会在后面的 `let t` 的作用域内,因此它根本不会引用该变量。它可以引用包含作用域内的 `t`。但这种方法在闭包或函数提升方面效果不佳,因此最终被放弃了。)
-
使用 `let` 重新声明变量将导致 `SyntaxError`。
这条规则也是为了帮助你检测简单的错误。不过,如果你尝试进行全局 `let` 到 `var` 的转换,这条规则最有可能导致一些问题,因为它甚至适用于全局 `let` 变量。
如果你有几个脚本都声明了相同的全局变量,最好继续使用 `var` 来声明。如果你切换到 `let`,第二个加载的脚本将会报错。
或者使用 ES6 模块。但那是另一个故事了。
(关于语法细节的补充说明:`let` 是严格模式代码中的保留字。在非严格模式代码中,为了向后兼容,你仍然可以声明名为 `let` 的变量、函数和参数——你可以写 `var let = 'q';`!当然你不会这么做。而且 `let let;` 是完全不允许的。)
除了这些差异之外,`let` 和 `var` 基本上是一样的。它们都支持用逗号隔开多个变量的声明,例如,它们都支持 解构赋值。
请注意,`class` 声明的行为类似于 `let`,而不是 `var`。如果你多次加载包含 `class` 的脚本,第二次加载时你会得到一个重新声明类的错误。
const
好了,还有一件事!
ES6 还引入了一个第三个关键字,你可以与 `let` 一起使用:`const`。
用 `const` 声明的变量就像 `let` 一样,除了你不能给它们赋值,除了在它们被声明的地方。这是一个 `SyntaxError`。
<pre>
const MAX_CAT_SIZE_KG = 3000; // 🙀
MAX_CAT_SIZE_KG = 5000; // SyntaxError
MAX_CAT_SIZE_KG++; // 尝试一下,但仍然是 SyntaxError
</pre>
很明智地,你不能在没有给它赋值的情况下声明一个 `const`。
<pre>
const theFairest; // SyntaxError,你个捣蛋鬼
</pre>
秘密特工命名空间
“命名空间是一个非常棒的想法——让我们多做一些这样的事情!”——蒂姆·彼得斯,“Python 之禅”
在幕后,嵌套作用域是编程语言构建的基础概念之一。从什么时候开始的呢?从 ALGOL?差不多 57 年了。而且今天比以往任何时候都更加重要。
在 ES3 之前,JavaScript 只有全局作用域和函数作用域。(让我们忽略 `with` 语句。)ES3 引入了 `try`–`catch` 语句,这意味着添加了一种新的作用域类型,只用于 `catch` 块中的异常变量。ES5 添加了一个由严格的 `eval()` 使用的作用域。ES6 添加了块作用域、for 循环作用域、新的全局 `let` 作用域、模块作用域以及在评估参数的默认值时使用的其他作用域。
从 ES3 开始添加的所有额外作用域都是为了使 JavaScript 的过程式和面向对象特性能够像闭包一样流畅、精确和直观地工作,并与闭包无缝配合。也许你以前从未注意到过这些作用域规则。如果是这样,那么语言正在发挥作用。
我现在可以使用 `let` 和 `const` 吗?
是的。要在 web 上使用它们,你必须使用 ES6 编译器,例如 Babel、Traceur 或 TypeScript。(Babel 和 Traceur 还不支持时间性死区。)
io.js 支持 `let` 和 `const`,但只在严格模式代码中。Node.js 的支持相同,但还需要 `--harmony` 选项。
布兰登·艾奇在九年前就在 Firefox 实现 了第一个版本的 `let`。在标准化过程中,这个特性经过了彻底的重新设计。郭树宇正在升级我们的实现以匹配标准,代码评审由杰夫·沃尔登等人完成。
好了,我们已经进入最后的冲刺阶段了。我们对 ES6 特性的史诗之旅即将结束。两周后,我们将完成对 ES6 最受欢迎的特性之一的介绍。但首先,下周我们将发布一篇帖子,它将扩展我们之前对一个新的功能的覆盖范围,该功能刚刚被 `super` 替代。所以请加入我们,埃里克·福斯特将带着对 ES6 子类化深入分析的视角回归。
9 条评论