由一道JS题引发的思考

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 例子1
for (var i = 0; i < 10; ++i) {
setTimeout(function () {
console.log(i) // 打印出10个10
}, 0);
}
for (var i = 0; i < 10; ++i) {
(function(i){
setTimeout(function () {
console.log(i) // 打印出10个10
}, 0);
}(i));
}
// 例子2
function func(){
for (var i=0; i<5; i++) {
setTimeout(function timer() {
console.log(new Date(),i);
}, 1000*i );
}
console.log("end",new Date(),i);
}
func();
/**
打印结果:
end Sun Oct 28 2018 13:36:43 GMT+0800 (中国标准时间) 5

Sun Oct 28 2018 13:36:43 GMT+0800 (中国标准时间) 5
Sun Oct 28 2018 13:36:44 GMT+0800 (中国标准时间) 5
Sun Oct 28 2018 13:36:45 GMT+0800 (中国标准时间) 5
Sun Oct 28 2018 13:36:46 GMT+0800 (中国标准时间) 5
Sun Oct 28 2018 13:36:47 GMT+0800 (中国标准时间) 5
/

原因其实都是类似的,setTimeout是采取异步执行机制,它会等主线程先执行完再执行,而等到主线程执行完,也就是循环已经结束了,此时i的值就是让循环终止的那个临界值,setTimeout拿到这个i值执行相应的回调函数,打印结果

那如果想把每个i的值都打印出来呢?闭包就可以解决这个问题

思路是这样的:循环的时候把每次i值存起来,供异步执行回调时使用

本质就是:虽然程序执行完毕,但它的作用域中的变量不会被销毁,保存在内存中,这样回调就使用

继续思考,变量什么时候被销毁?这就涉及到JS垃圾收集机制

最常用的垃圾收集方式是标记清除。也就是当变量进入执行环境后,就将这个变量标记为进入环境;当变量离开环境时,就将变量标记为离开环境。然后将在环境中的变量、被环境中变量所引用的变量的标记去掉,这样还带有标记的变量就是准备删除的,垃圾收集器就会对它们进行内存清除

换言之,当变量不存在与环境中,或者不被环境中变量所引用时,它就会被清除

回到刚才,我们想要的是循环程序执行完毕,每次循环所创建的环境中的变量i保存下来,那就需要让这个i值被处在环境中的程序所引用,处在环境中的程序也就是这个异步回调,那问题就变成如何让这个异步回调引用每次的i值?

只需要给每次循环创建一个块级作用域就行了。最简单的就是使用ES6的let关键字,它会创建一个块级作用域

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 例子1
for (let i = 0; i < 10; ++i) {
setTimeout(function () {
console.log(i) // 依次打印出0 1 2 3 ...
}, 0);
}
// 例子2
function func(){
for (let i=0; i<5; i++) {
setTimeout(function timer() {
console.log(new Date(),i); // 这里也可以依次打印出i值
}, 1000*i );
}
console.log("end",new Date(),i); // 这里取不到i值,因为此时i是块级作用域,外部程序获取不到
}
func();

是否还有其他解决方法呢?当然有,它就是闭包。

闭包是什么?理论上任何函数都是闭包,当在函数内返回函数时,内层闭包就能够获取到外层函数作用域上的变量,当外层函数将内层函数返回,并且内层函数还被外部环境所引用时,我们就实现了外部环境获取函数内部作用域变量。外部环境任何时候都能够引用外层函数内部的变量,也就是外层函数的内部变量被外部环境所引用,这时候即使外层函数执行完销毁了,但是存在于它作用域被引用的变量不会被销毁,会一直保存在内存中。

按照这个思路,我们就可以在异步回调的外层在套上一层函数,将每次循环的i值作为参数传递进去,使得i值作为包围函数作用域的变量,这样异步回调执行时,就可以到包围函数的作用域上获取到i值了,这里涉及到作用域链的知识,接下来会单独写一篇来讲作用域链。

现在的情况就是:异步回调引用了外层函数的i值,因此当外层函数随着循环结束而完毕时,内部的i值仍然可以被异步回调所访问。所以我们的程序就变成下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 例子1
for (var i = 0; i < 10; ++i) {
(function(i){
setTimeout(function () {
console.log(i) // 依次打印出0 1 2 3 ...
}, 0);
}(i));
}
// 例子2
function func(){
for (var i=0; i<5; i++) {
(function(i){
setTimeout(function timer() {
console.log(new Date(),i); // 这里也可以依次打印出0 1 2 ...
}, 1000*i );
}(i));
}
console.log("end",new Date(),i); // 这里自然就是循环完毕i的临界值,5
}
func();

当然我们还可以将包裹定时器的函数单独抽离出来,然后在循环时调用,这里一方面利用了闭包,另一方面也利用了函数的参数是按值传递而不是按引用传递这个原理,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 例子1
let log = function(i){
setTimeout(()=>{
console.log(i)
}, 1000)
}
for (var i = 0; i < 10; ++i) {
log(i);
}
// 例子2
let log2 = function(i){
setTimeout(function timer() {
console.log(new Date(),i);
}, 1000*i );
}
function func(){
for (var i=0; i<5; i++) {
log2(i);
}
console.log("end",new Date(),i);
}
func();
/*
end Mon Oct 29 2018 10:39:56 GMT+0800 (中国标准时间) 5
Mon Oct 29 2018 10:39:56 GMT+0800 (中国标准时间) 0
Mon Oct 29 2018 10:39:57 GMT+0800 (中国标准时间) 1
Mon Oct 29 2018 10:39:58 GMT+0800 (中国标准时间) 2
Mon Oct 29 2018 10:39:59 GMT+0800 (中国标准时间) 3
Mon Oct 29 2018 10:40:00 GMT+0800 (中国标准时间) 4
*/

还没结束,从代码的组织上看,我们想要实现的效果是循环打印后,在打印最后的end。可是这里由于end这行代码属于同步任务,所以直接进入主线程中执行,那要如何改成我们想要的结果呢?让所有异步任务执行完毕后,再执行同步任务?

最简单的办法就是让最后这个同步任务也变成异步任务,让它在i*1000后执行,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let log2 = function(i){
setTimeout(function timer() {
console.log(new Date(),i);
}, 1000 * i);
}
function func(){
for (var i=0; i<5; i++) {
log2(i);
}
setTimeout(() => {
console.log("end",new Date(),i);
}, i * 1000);
}
func();

但是这种实现不够优雅,其实我们可以用ES6的Promise来解决,代码如下:

Promise接收两个匿名函数作为参数,这两个函数都接收一个参数,第一个函数接收的参数resolve表示当异步代码执行完,执行resolve();第二个函数接收参数reject,表示异步代码执行不成功后,执行reject()

问题是promise中,异步代码的参数如何传递呢?直接在promise外层包一个函数,传递进去就行了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
let tasks = [];
let log = (i) => new Promise((resolve) => {
// promise中只处理异步逻辑,其他无关的逻辑都可以放到外面
// 这个resolve回调应该放在哪里?resolve的位置应该在异步代码中
// 下面这种写法是错的,这一点要注意
// setTimeout(() => {
// console.log(new Date(), i);
// }, i * 1000);
// resolve();
setTimeout(() => {
console.log(new Date(), i);
resolve();
}, i * 1000);
}, (reject) => {
reject();
});
for(var i = 0; i < 5; i++){
tasks.push(log(i));
}
Promise.all(tasks).then(() => {
console.log('end', new Date(), i);
})
/*
Mon Oct 29 2018 11:05:44 GMT+0800 (中国标准时间) 0
Mon Oct 29 2018 11:05:45 GMT+0800 (中国标准时间) 1
Mon Oct 29 2018 11:05:46 GMT+0800 (中国标准时间) 2
Mon Oct 29 2018 11:05:47 GMT+0800 (中国标准时间) 3
Mon Oct 29 2018 11:05:48 GMT+0800 (中国标准时间) 4
end Mon Oct 29 2018 11:05:48 GMT+0800 (中国标准时间) 5
*/

Promise方案不仅解决了我们代码执行次序的问题,还让我们的异步代码逻辑变成线性的,更加清晰了

最后,让我们来总结一下。这类题目主要考察我们对于JavaScript的异步执行机制、作用域、闭包的理解与运用,是每个前端开发者应该掌握的基础知识。