/

一段逆天的js代码

从学长那里看到一段逆天的 js 代码,代码不长,但考察了很多知识点。

上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
const p = Promise.resolve();
let sum = 0;
async function add(i, j) {
sum += await (i + j);
}
setTimeout(() => {
console.log(sum);
});
for (let i = j = 0; i < 3; i++, j++) {
p.then(() => {
add(i, j);
});
}

这段代码考察了很多知识点,哪怕一个知识点答错,都答不出正确答案

单步调试

不懂这段代码的话,我们可以先在浏览器 devtool 中调试一下:

devtool调试

逐步解释

接下来我来按语句执行顺序解释整个执行过程:

在开始之前我们要先明确一个事情:JS 是单线程的

🔴 首先声明语句暂时不解释

🔴 首先看setTimeout这里,这条语句很简单,字面意思就是 0 秒后输出sum,但由于 js 单线程的特性,这条语句的意思就是“当前任务结束后输出sum”,也就是把要执行的代码暂时挂起。

🔴 接下来看到for,重点放在括号内的声明let i = j = 0,请注意这个语句并不是声明了“两个作用域在 for 循环内的变量”。

这条语句做了两件事:

  • 0赋给j,但因为j未声明,所以自动在全局(Global)声明了一个j变量。
  • let声明了一个作用域在 for 循环内的变量,并将表达式j = 0的返回值(即0)赋给i

在 devtool 的调试中可以很明显的看到ij的声明位置不同

所以说这里的i是局部变量,for 循环结束即销毁,而j却是全局变量

🔴 接下来看 for 循环内的语句p.then()

先不管 then 里面干了啥,先看这个p.then()有什么目的。看看前面的声明语句

1
const p = Promise.resolve();

那拼起来就是Promise.resolve().then(),熟悉异步的同学就能看出来,这段代码的意思和之前setTimeout那里一样,就是将 then 里面代码暂时挂起,待当前任务结束后执行。

这里还需注意一个问题:执行时机

Promise会比setTimeout更先执行,详情

🔴 再看 then 里面的代码() => { add(i, j); },就是调用一个add()函数,干了什么先不考虑

🔴 那合起来我们就可以理解整个 for 循环做了什么事:

循环了三次,挂起了三个() => { add(i, j); }

由于add()是外部的一个函数,而i的作用域仅在 for 循环内,这里就产生了一个闭包,而在 同一位置 不同方式 声明的j,被默认定义在了全局(Global),因此不会出现闭包。

因此我们可以列一个表记录 for 循环结束后代码所挂起的三个任务以及他们闭包内可以获取到的参数:

(因为三个任务都不是立即执行,所以执行时获取到的 j 一定为代码执行时 j 的值,即 for 循环结束后的终止)

顺序 任务 i j
1 () => { add(i, j); } 0 3(Global)
2 () => { add(i, j); } 1 3(Global)
3 () => { add(i, j); } 2 3(Global)

循环结束后:

1
2
i = undefined;
j = 3;

🔴 接下来代码运行结束,开始执行之前挂起的任务

之前说过Promise会比setTimeout更先执行,所以挂起任务的执行顺序为:

顺序 任务 闭包变量 全局变量
1 () => { add(i, j); } i = 0 j
2 () => { add(i, j); } i = 1 j
3 () => { add(i, j); } i = 2 j
4 () => { console.log(sum); } 🈚️ sum

🔴 依次用不同“参数”执行三个() => { add(i, j); }

观察add()函数代码

1
2
3
async function add(i, j) {
sum += await (i + j);
}

没错这又是一个异步的代码!

由于await的特性,当执行到await时,此段任务的剩余代码 包括await所在语句会被一个Promise包起来。由于这里await后面表达式返回值不是一个 Promise,所以这里的意思是:

此段任务的剩余代码 包括await所在语句会被Promise包起来并且挂起,等待其他优先级更高的任务执行完再执行

另外这里新建的任务优先级是和之前Promise挂起的任务相同的,所以比setTimeout更先执行,并且与之前的Promise挂起的任务队列一起依次执行

同时这里还有一个考点 ⚠️:

await语句及其后面代码被包进Promise时,会为这些代码用到的外部变量产生一个闭包

🔴 我们来重新梳理一下任务列表:

当第一个() => { add(i, j); }执行后:

是否完成 顺序 任务 闭包变量 全局变量
True 1 () => { add(i, j); } i = 0; j
False 2 () => { add(i, j); } i = 1; j
False 3 () => { add(i, j); } i = 2; j
False 4 () => { sum += await (i + j); } i = 0; sum = 0; j
False 5 () => { console.log(sum); } 🈚️ sum

那么以此类推,当我们的() => { add(i, j); }任务全部执行完毕后

是否完成 顺序 任务 闭包变量 全局变量
True 1 () => { add(i, j); } i = 0; j
True 2 () => { add(i, j); } i = 1; j
True 3 () => { add(i, j); } i = 2; j
False 4 () => { sum += await (i + j); } i = 0; sum = 0; j
False 5 () => { sum += await (i + j); } i = 1; sum = 0; j
False 6 () => { sum += await (i + j); } i = 2; sum = 0; j
False 7 () => { console.log(sum); } 🈚️ sum

🔴 接下来依次执行() => { sum += await (i + j); }任务

顺序 任务 闭包变量 全局变量 执行后 sum 的值
4 () => { sum += await (i + j); } i = 0; sum = 0; j 3
5 () => { sum += await (i + j); } i = 1; sum = 0; j 4
6 () => { sum += await (i + j); } i = 2; sum = 0; j 5

这里终于不用考虑异步了,依次执行就行

🔴 最后一个终于轮到() => { console.log(sum); }了,直接输出全局的sum = 5

坑点梳理

最后让我们梳理一下坑点

  • 执行时机Promise.resolve().then()setTimeout(fn,0)都是立即执行,且都需要等待当前任务执行完后再执行,但是Promise的优先级更高,更先执行。另外await/async语句执行优先级和Promise相同。在任务执行时,同类型任务,也是按先创建先执行顺序执行。
  • 语句理解let i = j = 0语句在解释时,let应从左往右解释,=应从右往左解释(《You Don’t Know JavaScript》第一章便有讲解),也就是说只声明了局部的i而未声明j,所以j被默认安排在了全局。
  • 闭包问题:闭包这个问题算是老生常谈了,不做解释,多练练就会了。但是await这里会有隐藏的闭包要注意,await语句及其后面代码被包进Promise时,会为这些代码用到的外部变量产生一个闭包