当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引擎用一个小小的代码起重机将每个var
和function
提升到外围函数的顶部。
变量提升
有它的优点。如果没有它,许多在全局作用域中工作良好的完美的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++) {
...
}
let
和var
是不同的,所以如果你只是做一个全球搜索替换整个代码,可以破坏部分的代码(可能是无意中)。但在大多数情况下,在新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-of
、for-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)
在幕后,嵌套作用域是编程语言构建的核心概念之一。从什么时候开始就这样了,ALGOL?大概57年吧。今天更是如此。
在ES3之前,JavaScript只有全局作用域
和函数作用域
。(让我们忽略with
语句。)ES3引入了try-catch
语句,这意味着添加了一种新的作用域,仅用于catch块中的异常变量。ES5添加了一个由strict eval()
使用的作用域。ES6添加了块作用域
、for-loop作用域
、新的全局let作用域
、模块作用域
以及在计算参数的默认值时使用的附加作用域
。
从ES3开始添加的所有额外作用域都是必要的,以使JavaScript的面向过程和面向对象特性像闭包一样流畅、精确和直观地工作,并与闭包无缝合作。也许你在今天之前从未注意过这些范围规则。如果是这样的话,JS语言正在默默完成它的工作。