从闭包说起

经典面试题

1
2
3
4
5
6
for (var i = 0; i < 5; i++) {
setTimeout(function () {
console.log(i);
}, 1000);
}
console.log(i);

输出结果为:

1
2
3
4
5
6
5
5
5
5
5
5

只要对同步和异步的区别、变量块级作用域、闭包等关键知识理解到位,得出正确答案就不难,但这道题远没有这么简单,来看面试官的继续追问。

追问1

如果我们约定,用箭头(->)表示其前后的两次输出之间有1秒的时间间隔,而逗号(,)表示其前后的两次输出之间的时间间隔可以忽略,代码实际运行的结果该如何描述?

  • A: 5->5->5->5->5->5
  • B: 5->5,5,5,5,5

选A还是选B呢,这就要求面试者对定时器的工作机制非常熟悉,在循环执行过程中,几乎同时设置了 5 个定时器,一般情况下,这些定时器都会在 1 秒之后同时触发,而循环完的输出是立即执行的,显而易见,正确的描述是 B 。

追问2

如果期望代码的输出变成:5->0,1,2,3,4,该怎么改造代码?熟悉闭包的同学很快能给出下面的解决办法:

1
2
3
4
5
6
7
8
for (var i = 0; i < 5; i++) {
(function(j) {
setTimeout(function() {
console.log(j);
}, 1000);
})(i);
}
console.log(i);

巧妙的利用 IIFE(Immediately Invoked Function Expression:声明即执行的函数表达式)来解决闭包造成的问题。但这对于初学者来说可能不太好懂,有没有更直观的做法?当然有,我们只需要对循环体稍做手脚,让负责输出的那段代码能拿到每次循环的 i 值即可。该怎么做呢?利用 JS 中基本类型(Primitive Type)的参数传递是按值传递(Pass by Value)的特征,不难改造出下面的代码:

1
2
3
4
5
6
7
8
9
var output = function (i) {
setTimeout(function() {
console.log(i);
}, 1000);
};
for (var i = 0; i < 5; i++) {
output(i);
}
console.log(i);

追问3

还是最初的那道题,如果将var改成let,会发生什么?

let是ES6块级作用域中的概念,用let代替var后,会发现代码在实际运行中报错,这是因为最后那个输出使用的 i 在其所在的作用域中并不存在,i 只存在于for循环内部。

追问4

如果期望代码的输出变成 0->1->2->3->4->5,并且要求原有的代码块中的循环和两处console.log不变,该怎么改造代码?新的需求可以精确的描述为:代码执行时,立即输出 0,之后每隔 1 秒依次输出 1,2,3,4,循环结束后在大概第 5 秒的时候输出 5(这里使用大概是为了避免钻牛角尖的同学陷进去,因为 JS 中的定时器触发时机有可能是不确定的)。

看到这里,有些同学会给出下面的可行解:

1
2
3
4
5
6
7
8
9
10
for (var i = 0; i < 5; i++) {
(function(j) {
setTimeout(function() {
console.log(j);
}, 1000 * j)); // 这里修改 0~4 的定时器时间
})(i);
}
setTimeout(function() { // 这里增加定时器,超时设置为 5 秒
console.log(i);
}, 1000 * i);

不得不承认,这种做法虽然粗暴有效,但是不算是能额外加分的方案。如果把这次的需求抽象为:在系列异步操作完成(每次循环都产生了 1 个异步操作)之后,再做其他的事情,代码该怎么组织?聪明的你是不是想起了什么?对,就是 Promise,来看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const tasks = [];
for (var i = 0; i < 5; i++) { // 这里 i 的声明不能改成 let,如果要改该怎么做?
((j) => {
tasks.push(new Promise((resolve) => {
setTimeout(() => {
console.log(j);
resolve(); // 这里一定要 resolve,否则代码不会按预期 work
}, 1000 * j); // 定时器的超时时间逐步增加
}));
})(i);
}
Promise.all(tasks).then(() => {
setTimeout(() => {
console.log(i);
}, 1000);

当然,上面的代码还能进一步简化,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const tasks = []; // 这里存放异步操作的 Promise
const output = (i) => new Promise((resolve) => {
setTimeout(() => {
console.log(i);
resolve();
}, 1000 * i);
});
// 生成全部的异步操作
for (var i = 0; i < 5; i++) {
tasks.push(output(i));
}
// 异步操作完成之后,输出最后的 i
Promise.all(tasks).then(() => {
setTimeout(() => {
console.log(i);
}, 1000);
});

这样看是不是逻辑清楚多了,使用 Promise 处理异步代码比回调机制让代码可读性更高,但是使用 Promise 的问题也很明显,即如果没有处理 Promise 的 reject,会导致错误被丢进黑洞,这显然是我们不愿看到的。

追问5

既然Promise已经被拿下,能否使用 ES7 中的 async/await 特性来让这段代码变的更简洁?答案是肯定的,来看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
// 模拟其他语言中的 sleep,实际上可以是任何异步操作
const sleep = (timeountMS) => new Promise((resolve) => {
setTimeout(resolve, timeountMS);
});
(async () => { // 声明即执行的 async 函数表达式
for (var i = 0; i < 5; i++) {
await sleep(1000);
console.log(i);
}
await sleep(1000);
console.log(i);
})();

好了,到这里,这道经典面试题我们已经剖析完毕了,相信读到这里,你收获的不仅仅是用 JS 精确控制代码输出的各种技巧,更是对于前端工程师的成长期许:扎实的语言基础、与时俱进的能力、强大的技术自驱力。生命不息,学习不止,让我们用知识不断充实丰富自己,如果你有任何的问题,也欢迎评论区留言,你的支持是我前进的最大动力!

Donate
  • Copyright: Copyright is owned by the author. For commercial reprints, please contact the author for authorization. For non-commercial reprints, please indicate the source.
  • Copyrights © 2020-2021 Sanmu
  • Visitors: | Views:

请我喝杯咖啡吧~

支付宝
微信