[ES6深度解析]13:let const

当Brendan Eich在1995年设计了JavaScript的第一个版本时,他犯了很多错误,包括从那时起就成为该语言一部分的一些错误,比如Date对象和当你不小心将它们相乘时对象会自动转换为NaN。然而,事后看来,他做对的事情都是非常重要的事情:对象;原型;具有词法作用域的一级函数;默认可变性。这种语言很好。比大家一开始意识到的要好。

尽管如此,Brendan还是做出了一个与今天的文章相关的特殊设计决定——我认为这个决定可以被定性为一个错误。这是一件小事。一种微妙的东西。你可能用了好几年,甚至都没注意到它。但这很重要,因为这个错误出现在我们现在认为是“好的部分”的语言方面。

它和变量有关。

问题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的代码”中(之前运行良好),t指向新的内部变量t,而不是现有的外部变量。

JavaScript中的var的作用域就像Photoshop中的油漆桶工具。它从声明开始,在两个方向上扩展,向前和向后,一直扩展到函数边界({})。由于变量t的作用域向后扩展了这么多,所以必须在我们一进入函数时就创建它。这叫做变量提升(hoisting)。我喜欢想象JS引擎用一个小小的代码起重机将每个varfunction提升到外围函数的顶部。

变量提升有它的优点。如果没有它,许多在全局作用域中工作良好的完美的cromulent技术将无法在IIFE(立即执行函数)中工作。但是在上面的代码中,变量提升会导致一个严重的错误:使用t的所有计算将开始产生NaN。它也很难跟踪,特别是如果你的代码比这个demo更大。

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

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

你可以猜到运行这段代码时会发生什么。很简单:

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

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

运行这段代码,浏览器会顺序弹出3次alert框,消息内容分别为”Hi!”, “I’m a web page!”, “alert() is fun!”。现在我们把代码稍微改动一下:

var messages = ["Meow!", "I'm a talking cat!", "Callbacks are fun!"];

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

再次运行发现,结果出乎预料。浏览器没有按顺序说出打印三条信息,而是打印了三次undefined。你能发现漏洞吗?

这里的问题是只有一个变量i。它由循环本身和所有三个setTimeout回调函数共享。当循环运行结束时,i的值为3(因为messages.length为3),并且此时还没有调用任何回调函数。(异步,事件循环)

因此,当第一个setTimeout回调函数触发并调用console.log(messages[i])时,它使用的是messages[3](messages[3]肯定是undefined)

有很多种解决的方法,下面是一种:

var messages = ["Meow!", "I'm a talking cat!", "Callbacks are fun!"];

for (var i = 0; i < messages.length; i++) {
  setTimeout((function (index) {
    return function() {console.log(messages[index])};
  })(i), i * 1500);
}

如果一开始就没有这种问题,那就太好了。

let, const是新的var

在大多数情况下,JavaScript(也包括其他编程语言,尤其是JavaScript)中的设计错误是无法修复的。向后兼容性意味着永远不会改变Web上现有JS代码的行为。即使是标准委员会也没有能力,比如说,解决JavaScript自动分号插入的奇怪问题。浏览器制造商不会实现破坏性的更改,因为这种更改会惩罚用户。大约十年前,当Brendan Eich决定解决这个问题时,只有一种方法。

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

let t = readTachymeter();

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

letvar是不同的,所以如果你只是做一个全球搜索替换整个代码,可以破坏部分的代码(可能是无意中)。但在大多数情况下,在新ES6代码,你应该停止使用var,并在之前使用var的位置使用let。因此有这样的口号:“let是新的var”。

let和var之间到底有什么区别?

  • let变量是块作用域的。
    用let声明的变量的作用域只是封闭的块,而不是整个封闭的函数。使用let还是会有变量提升,但不是不分青红皂白。runTowerExperiment示例可以通过简单地将var更改为let来修复。如果你在任何地方都使用let,你就不会有那种bug了。

  • 全局let变量不是全局对象的属性
    也就是说,您不会通过写入window.variableName来访问它们。相反,它们存在于一个无形的块的范围内,该块理论上包含了在网页中运行的所有JS代码。

  • for (let x…)形式的循环在每次迭代中为x创建一个新的绑定。
    这是一个非常微妙的差别。这意味着,如果for (let…)循环执行多次,并且该循环包含一个闭包,就像在我们正在讨论的console.log示例中那样,每个闭包将捕获循环变量的不同副本,而不是所有闭包捕获相同的循环变量。所以上面那个例子可以用let替换var就可以解决错误:

var messages = ["Meow!", "I'm a talking cat!", "Callbacks are fun!"];

for (let i = 0; i < messages.length; i++) {
  setTimeout(function () {
    console.log(messages[i]);
  }, i * 1500);
}

这适用于所有三种for循环:for-offor-in和带有分号的老式C类型循环。

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

这条规则是用来帮助你捕捉bug的。你将在问题所在的代码行上得到一个异常,而不是NaN

当变量在作用域内但未初始化时,这个时间段称为临时死区(temporal dead zone)。我一直在期待这句有灵感的行话能一跃成为科幻小说。还没有。

一个琐碎的性能细节:在大多数情况下,你可以通过查看代码来判断声明是否已经运行,因此JavaScript引擎实际上不需要在每次访问变量时执行额外的检查,以确保它已初始化。然而,在一个封闭的内部,有时是不清楚的。在这些情况下,JavaScript引擎将执行运行时检查。这意味着let比var要慢。

一个复杂的交替域作用域细节:在一些编程语言中,变量的作用域从声明点开始,而不是向后覆盖整个封闭块。标准委员会考虑对let使用这种范围规则。这样的话,t的使用导致这里的ReferenceError不会在后面的let t的范围内,所以它根本不会引用那个变量。它可以指封闭作用域中的t。但这种方法不适用于闭包或函数提升,因此最终被放弃。

  • 用let重新声明变量是一个SyntaxError错误。
    这条规则也可以帮助你发现微小的错误。不过,如果你尝试全局的let-to-var转换,这种差异很可能会给你带来一些问题,因为它甚至适用于全局的let变量。

如果你有几个脚本都声明了相同的全局变量,你最好继续使用var。如果切换到let,那么无论第二次加载哪个脚本都会失败并出现错误。

或者使用ES6模块。

一个的语法细节let是严格模式代码中的保留字。在非严格模式的代码中,为了向后兼容,你仍然可以声明变量、函数和名为let的参数——你可以写var let = 'q'! let let = 1这是不允许的。

除了这些区别之外,let和var几乎是相同的。例如,它们都支持声明用逗号分隔的多个变量,并且都支持解构。注意,类声明的行为类似于let,而不是var。如果你多次加载一个包含类的脚本,第二次重新声明类时就会得到一个错误。

const

ES6还引入了第三个可与let一起使用的关键字:const

用const声明的变量就像let一样,你只能在它们被声明的地方赋值。否则是一个SyntaxError。

const MAX_CAT_SIZE_KG = 3000; // 🙀

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

很明显,不能在没有赋值的情况下声明const。

const theFairest;  // SyntaxError, you troublemaker

秘密特工:命名空间(namespace)

“Namespaces are one honking great idea—let’s do more of those!” —Tim Peters, “The Zen of Python”

在幕后,嵌套作用域是编程语言构建的核心概念之一。从什么时候开始就这样了,ALGOL?大概57年吧。今天更是如此。

在ES3之前,JavaScript只有全局作用域函数作用域。(让我们忽略with语句。)ES3引入了try-catch语句,这意味着添加了一种新的作用域,仅用于catch块中的异常变量。ES5添加了一个由strict eval()使用的作用域。ES6添加了块作用域for-loop作用域新的全局let作用域模块作用域以及在计算参数的默认值时使用的附加作用域

从ES3开始添加的所有额外作用域都是必要的,以使JavaScript的面向过程和面向对象特性像闭包一样流畅、精确和直观地工作,并与闭包无缝合作。也许你在今天之前从未注意过这些范围规则。如果是这样的话,JS语言正在默默完成它的工作。