for循环的代价
for循环的可控性比forEach好,但是它的代价却鲜为人知,这篇文章就是简单的探讨下这背后的代价。 先定一个基调,我们从作用域的角度去分析,也会按需讲一些作用域的知识。
作用域是什么?
要解释这个问题,先从程序的角度来看,几乎所有编程语言上来就会介绍自己的变量类型,因为如果没有变量程序只能执行一些简单的任务。但是引入变量之后程序怎么才能准确的找到自己需要的变量。这就需要建立一套规则让程序能够准确的找到需要的变量,这样的规则被称为作用域。
块级作用域
块级作用域如同全局作用域和函数作用域一样,只不过块级作用域由花括号({})包裹的代码块创建的。在块级作用域内声明的变量只能在该作用域内访问,可以使用 let 或 const 关键字声明变量,可以在块级作用域内创建变量。
所以引擎在编译时是通过花括号({})包裹和声明关键字判断是否创建块级作用域,因此绝大多数的语句是没有作用域的,同时从语言设计的角度来说越少作用域的执行环境调度效率也就越高,执行时的性能也就越好。
基于这个原则,switch语句被设计为有且仅有一个作用域,无论它有多少个case语句,其实都是运行在一个块级作用域环境中的。
一些简单的、显而易见的块级作用域包括:
// 例1
try {
// 作用域1
}
catch (e) { // 表达式e位于作用域2
// 作用域2
}
finally {
// 作用域3
}
// 例2
//(注:没有使用大括号)
with (x) /* 作用域1 */; // <- 这里存在一个块级作用域
// 例3, 块语句
{
// 作用域1
除此之外,按上述理解,for语句也可以满足上述的条件。
for循环作用域
并不是所有的for循环都有自己的作用域,有且仅有
for ( <let/const> ...) ...
这个语法有自己的块级作用域。当然,这也包括相同设计的for await和for .. of/in ..。例如:
for await ( <let/const> x of ...) ...
for ( <let/const> x ... in ...)
for ( <let/const> x ... of ...) ...
已经注意到了,这里并没有按照惯例那样列出“var”关键字。简单理解就是不满足创建的条件。Js引擎在编译时,会对标识符进行登记,而为了兼容,将标识符分为了两类varNames 和 lexicalNames。以前 var 声明、函数声明将会登记在varNames,为了兼容varNames只有全局作用域和函数作用域两种,所以编译时会就近登记在全局作用域和函数作用域中且变量有“提升”效果。Es6新增的声明关键词将登记在lexicalNames,编译时会就近创建块级作用或就近登记在函数作用域中。
varNames 和 lexicalNames属性只是一个用于记录标识符的列表,是通过词法作用域分析,在当前作用域中做登记的。它们记录了当前作用域中的变量和函数的名称,以及它们的作用域信息,帮助 JavaScript 引擎在代码执行时正确地解析标识符的作用域。
关于作用域还有一点要说明,JavaScript采用词法作用域,这意味着变量的作用域在代码编写时就确定了,而不是在运行时确定。这与动态作用域不同,动态作用域是根据函数的调用栈来确定变量的作用域。
举个例子:
function foo() {
console.log( a ); // 2
}
function bar() {
var a = 3;
foo();
}
var a = 2;
bar();
词法作用域下log的结果是2,动态作用域下log的是3。
词法作用域是指 JavaScript 引擎在编译时如何确定变量的作用域。在 JavaScript 中,词法作用域是指变量的作用域是由代码的位置(词法)决定的,而不是由运行时的作用域链决定的。
变量的作用域和可见性是由词法作用域和作用域链来决定的,作用域链是基于词法作用域和函数作用域来确定的,这也证明了 JavaScript 采用的是词法作用域。
for循环隐藏作用域
首先,必须要拥有至少一个块级作用域。如之前讲到的,满足引擎创建的条件。但是这一个作用域貌似无法解释下面这段代码
for(let i=0;i<10;i++){
let i=1;
console.log(i) // 1
}
这段代码时可以正常运行的,而我们知道let语句的变量不能重复声明的,所以对for循环来说一个作用域是满足了这个场景的。
但是这段代码依然可以执行,那JS引擎是如何处理的呢?
只能说明循环体又创建了一个块级作用域,事实如你所见,JS引擎确实对for循环的每个循环体都创建了一个块级作用域。
举个栗子,以下代码中使用 let
声明变量 i
:
for (let i = 0; i < 5; i++) {
console.log(i);
}
在编译时,JavaScript 引擎会将循环体包裹在一个块级作用域中,类似于以下代码:
{
let i;
for (i = 0; i < 5; i++) {
console.log(i);
}
}
每次循环都会创建一个新的块级作用域,因此,在循环中声明的变量 i
只能在当前块级作用域中访问,不会污染外部作用域的变量。而通过作用域链每个循环体内都可以访问外层变量i。
而我们知道从语言设计的角度来说越少作用域的执行环境调度效率也就越高,执行时的性能也就越好,所以这也算是代价之一吧。
就算如此设计还是无法解释下面这段代码。
for (let i = 0; i < 5; i++) {
setTimeout(()=>{console.log(i)})
}
如果按上述的理解,那最后log时访问的都是外层的变量i,最后的结果应该都是4,可事实却并非如此。当定时器被触发时,函数会通过它的闭包来回溯,并试图再次找到那个标识符i。然而,当定时器触发时,整个for迭代都已经结束了。这种情况下,访问i,获取到的也是上层作用域中的i,此刻i的值应该是最后一次赋。
之所能按我们预想的输出1,2,3,4,那是因为JavaScript 引擎在创建循环体作用域的时候,会在该作用域中声明一个新的变量 i,并将其初始化为当前的迭代次数,这个新的变量 i 会覆盖外层的变量 i。这个过程是由 JavaScript 引擎自动完成的,我们并不需要手动
来源:juejin.cn/post/7245641209913360445