Skip to main content

深入浅出 ES6(十四):let 和 const



原文

今天我想要谈论的是一个集谦逊与惊人的野心于一身的新特性。

回溯到 1995 年,当 Brendan Eich 在设计第一版 JavaScript 时,他搞错了许多东西,当然这也包括曾属于语言本身的一部分,例如Date对象,对象相乘被自动转换为 NaN 等。然而现在回过头看,语言最重要的部分都是设计合理的:对象、原型、具有词法作用域的一等函数、默认情况下的可变性等。语言的骨架非常优秀,甚至超越了人们对它的初步印象。

话说回来,正是 Brendan 当初的设计错误才诞生了今天这篇文章。我们这次关注的目标非常小,在你使用这门语言多年后可能根本不会注意到这个问题,但是它 又如此重要,因为我们可能会误认为这个错误就是语言设计中的“the good parts”(译者注:请参考《JavaScript 语言精粹》一书中附录 A:毒瘤中有关作用域的描述)。

今天我们一定要把这些与变量有关的问题拿下。

问题 #1:JS 没有块级作用域

请看这样一条规则:在 JS 函数中的 var 声明,其作用域是函数体的全部。乍一听没什么问题,但是如果碰到以下两种情况就不会得到令人满意的结果。

其一,在代码块内声明的变量,其作用域是整个函数作用域而不是块级作用域。

你之前可能没有关注到这一点,但我担心这个问题确实是你不能够轻易忽视的。我们一起重现一下由这个问题引发的bug。

假如你现在的代码使用了一个变量 t

function runTowerExperiment(tower, startTime) {
var t = startTime;
tower.on("tick", function () {
... 使用了变量 t 的代码 ...
});
... 更多代码 ...
}

到目前为止,一切都很顺利。现在你想添加测量保龄球速度的功能,所以你在回调函数内部添加了一个简单的if语句。

function runTowerExperiment(tower, startTime) {
var t = startTime;
tower.on("tick", function () {
... 使用了变量 t 的代码 ...
if (bowlingBall.altitude() <= 0) {
var t = readTachymeter();
...
}
});
... 更多代码 ...
}

哦,亲爱的,之前那段“使用了变量 t 的代码”运行良好,现在你无意中添加了第二个变量t,这里的t指向的是一个新的内部变量t而不是原来的外部变量。

JavaScript 中var声明的作用域像是 Photoshop 中的油漆桶工具,从声明处开始向前后两个方向扩散,直到触及函数边界才停止扩散。你想啊,这种变量t的作用域甚广,所以一进入函数就要马上将它创建出来。这就是所谓的 提升(hoisting)。变量提升就好比是,JS 引擎用一个很小的代码起重机将所有var声明和function函数声明都举起到函数内的最高处。

现在看来,提升特性自有它的优点。如果没有提升的动作,许多在全局作用域范围内看似合理的完美技术在立即调用函数表达式( IIFE )中通通失效。但在上面演示的这种情况下,提升会引发令人不愉快的 bug:所有使用变量t进行的计算最终的结果都是NaN。这种问题极难定位,尤其是当你的代码量远超上面这个玩具一般的示例,你会发狂到崩溃。

在原有代码块 之前 添加新的代码块会导致诡异的错误,这时候我就会想,到底是谁的问题,我的还是系统的?我们可不希望自己搞砸了系统。

而这个问题与接下来这个问题相比就相形见绌了。

问题 #2:循环内变量过度共享

你可以猜一下当执行以下这段代码时会发生什么,非常简单:

var messages = [" 嗨!", " 我是一个 web 页面!", "alert() 方法非常有趣!"];
for (var i = 0; i < messages.length; i++) {
alert(messages[i]);
}

如果你一直跟随这个系列的文章,你知道我喜欢在示例代码中使用 alert()方法。可能你也知道alert()不是一个好的 API,它是一个同步方法,所以当弹出一个警告对话框时,输入事件不会触发,你的 JS 代码,包括你的整个 UI,直到用户点击 OK 确认之前完全处于暂停状态。

请不要轻易使用alert()来实现 web 页面中的功能,我之所以在代码中使用是因为alert()特性使它变成一个非常有教学意义的工具。

而且,如果放弃所有笨重的方法和糟糕的行为就可以做出一只会说话的猫,何乐而不为呢?

var messages = [" 喵!", " 我是一只会说话的猫!", " 回调(callback)非常有趣!"];
for (var i = 0; i < messages.length; i++) {
setTimeout(function () {
cat.say(messages[i]);
}, i * 1500);
}

然而一定是哪里不对,这只会说话的猫并没有按照预期连说三条消息,它说了三次“undefined”。

你知道问题出在哪里么?

事实上,这个问题的答案是,循环本身及三次 timeout 回调均共享唯一的变量 i。当循环结束执行时,i 的值为 3(因为messages.length的值为 3),此时回调尚未被触发。

所以当第一个 timeout 执行时,调用cat.say(messages[i]),此时 i 的值为 3,所以猫咪最终打印出来的是messages[3]的值亦即undefined

解决这个问题有很多种方法(这里有一种),但是你想,var作用域规则接连给你添麻烦,如果能在第一时间彻底解决掉这个问题多好啊!

let 是更完美的 var

JavaScript 的设计错误(其它语言也有,奈何 JavaScript 太 突出)多半不能被修复。保持向后兼容性意味着永不改变 JS 代码在 Web 平台上的行为,即使连标准委员会都无权要求修复 JavaScript 中自动插入分号这种怪异的特性;浏览器厂商也从来不会做出突破性的改变,因为如此一来伤害的是他们的忠实用户。

所以大约十年以前,Brendan Eich 决定修复这个问题,但只有唯一的解决方案。

他添加了一个新的关键词:letletvar一样,也可以用来声明变量,但它有着更好的作用域规则。

它看起来是这样的: let t = readTachymeter(); 或者这样的:

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

letvar还是有不同之处的,所以如果你只是在代码中将var全局搜索替换为let,一些依赖var声明的独特特性(可能你不是故意这样写)的代码可能无法正常运行。但对于绝大多数代码来说,在 ES6 的新代码模式下,你应该停止使用var声明变量,能使用let就用吧!从现在起,请记住这句口号:“let是更完美的var”。

那到底letvar有什么不同呢?非常高兴你提出这个问题!

这一规则可以帮助你捕捉 bug,除了NaN错误以外,每一个异常都会在当前行抛出。

  • let声明的变量拥有块级作用域。 也就是说用let声明的变量的作用域只是外层块,而不是整个外层函数。

let声明仍然保留了提升的特性,但不会盲目提升。在runTowerExperiment这个示例中,通过将var替换为let可以快速修复问题,如果你处处使用let进行声明,就不会遇到类似的 bug。

  • let声明的全局变量不是全局对象的属性。 这就意味着,你不可 以通过window. 变量名的方式访问这些变量。它们只存在于一个不可见的块的作用域中,这个块理论上是 Web 页面中运行的所有 JS 代码的外层块。

  • 形如for (let x...)的循环在每次迭代时都为 x 创建新的绑定。

这是一个非常微妙的区别,拿我们的会说话的猫的例子来说,如果一个for (let...)循环执行多次并且循环保持了一个闭包,那么每个闭包将捕捉一个循环变量的不同值作为副本,而不是所有闭包都捕捉循环变量的同一个值。

所以在会说话的猫示例中,也可以通过将var替换为let修复 bug。

这种情况适用于现有的三种循环方式:for-of、for-in、以及传统的用分号分隔的类 C 循环。

  • let声明的变量直到控制流到达该变量被定义的代码行时才会被装载,所以在到达之前使用该变量会触发错误。 举个例子:
function update() {
console.log(" 当前时间:", t); // 引用错误(ReferenceError)
...
let t = readTachymeter();
}

不可访问的这段时间变量一直处于作用域中,但是尚未装载,它们位于 临时死区(Temporal Dead Zone,简称 TDZ) 中。我一直想用科幻小说来类比这个脑洞大开的行话,但是还没想好怎么搞。

(脆弱的性能细节:在大多数情况下,查看代码就可以区分声明是否已经执行,所以事实上,JavaScript 引擎不需要在每次代码运行时都额外执行 一次变量可访问检查来确保变量已经被初始化。然而在闭包内部有时不是透明的,这时 JavaScript 引擎将会做一个运行时检查,也就意味着let相对var而言比较慢。)

(脆弱的平行宇宙作用域细节:在一些编程语言中,一个变量的作用域始于声明之处,而非前后覆盖整个封闭代码块。标准委员会曾考虑过将这种作用域准则赋予let关键词,但是一旦使用这种准则,原本提前使用变量的语句会导致引用错误(ReferenceError),现在该语句不位于let t的声明作用域中,根本不会引用此处的变量t,而是引用外层作用域的相应变量。但是这个方法无法与闭包和函数提升很好得结合,所以该提案最终被否决了。)

  • 用let重定义变量会抛出一个语法错误(SyntaxError)。

这一条规则也可以帮助你检测琐碎的小问题。诚然,这亦是var与let的不同之处,当你全局搜索var替换为let时也会导致let重定义语法错误,因为这一规则对全局let变量也有效。

如果你的多个脚本中都声明了相同的全局变量,你最好继续用var声明这些变量。如果你换用了let,后加载的脚本都会执行失败并抛出错误。

或者你可以考虑使用 ES6 内建的模块机制,后面的文章中会详细讲解。

(脆弱的语法细节:let是一个严格模式下的保留词。在非严格模式下,出于向后兼容的目的,你仍可以用let命名来声明变量、函数和参数,虽然你不会犯傻,但是你确实可以编写var let = 'q';这样的代码!不过let let;无论如何都是非法的。)

在那些不同之外,let和var几乎很相似了。举个例子,它们都支持使用逗号分隔声明多重变量,它们也都支持解构特性。

note

注意, class类声明的行为与var不同而与let一致。如果你加载一段包含同名类的脚本,后定义的类会抛出重定义错误。

const

是的,还有一个新的关键词!

ES6 引入的第三个声明类关键词与let类似:const。

const声明的变量与let声明的变量类似,它们的不同之处在于,const声明的变量只可以在声明时赋值,不可随意修改,否则会导致SyntaxError(语法错误)。

const MAX_CAT_SIZE_KG = 3000; // 正确

MAX_CAT_SIZE_KG = 5000; // 语法错误(SyntaxError)
MAX_CAT_SIZE_KG++; // 虽然换了一种方式,但仍然会导致语法错误

当然,规范设计的足够明智,用const声明变量后必须要赋值,否则也抛出语法错误。

const theFairest; // 依然是语法错误,你这个倒霉蛋## 神秘的代理命名空间

“命名空间是一种绝妙的理念,我们应当多加利用!”——Tim Peters,“这是 Python 之禅”

嵌套作用域是编程语言背后的核心理念之一,这个理念始于大约 57 年前的 ALGOL ,现在回过头看当时的决定无比正确。

在 ES3 之前,JavaScript 中只有全局作用域和函数作用域。(让我们忽略with语句吧。)ES3 中引入了try-catch语句,意味着语言中诞生一种新的作用域,只用于 catch 块中的异常变量。ES5 添加了用于严格的eval()方法的作用域。ES6 添加了块作用域,for 循环作用域,新的全局let作用域,模块作用域,以及求参数的默认值时使用的附加作用域。

所有自 ES3 开始添加的其它作用域非常重要,它们的加入使得 JavaScript 面向过程与面向对象的特性运行得犹如闭包一样平稳、精准,当然闭包也可以无 缝衔接这些作用域实现各种功能。或许你在阅读这篇文章之前从未注意到这些作用域规则的存在,如果真的这样,那这门语言就恰如其分地完成了它的本职工作。 我现在可以使用 let 和 const 了么?

是的。如果要在 web 上使用let和const特性,你需要使用一个诸如 Babel 、 Traceur 或 TypeScript 的 ES6 转译器。(Babel 和 Traceur 暂不支持临时死区特性。)

io.js 支持let和const,但是只在严格模式下编码可以使用。Node.js 同样支持,但是需要启用--harmony选项。

九年前,Brendan Eich 在Firefox 中实现了初版的 let关键词。这个特性在随后的标准化进程中彻底地被重新设计了。Shu-yu Guo 正在按照新标准对原有实现进行升级,该项目由 Jeff Walden 和其他人做代码审查。

好的,我们正处于冲刺阶段,漫长而艰难的 ES6 特性之旅的终点离我们不远了,成功就在眼前。两周后,我们将实现大家最期待的 ES6 特性(译者注:作者 7 月 31 日发文,根据推算目前应该已经完成)。下一次,我们继续拓展 ES6 中类的特性super,记得回来加入我们跟随 Eric Faust 一起《深入浅出 ES6:子类》。

感谢刘振涛对本文的策划, 徐川对本文的审校。