在之前的开发过程中遇到这样的场景:

页面中有几个功能区或者说模块,他们每个都有一个进度条,在页面加载时会请求数据来渲染这几个进度条,使之独立展示不同的工程进度,于是在一个for循环中给每个进度条绑定了一个定时器setInterval,期待它可以实现预期的效果。然而实际效果出乎意料,只有最后一个定时器实现了渲染正确数据的功能,前面的进度为0。

说到这里,很多小伙伴可能已经猜到这里面大致发生了什么事情。这就是今天要说的,当for循环遇到了定时器,究竟发生了什么事,又和异步、闭包有什么关系。

首先,上述场景,可以简单抽象为下面的一段代码:

for(var i=0;i<4;i++){
    setTimeout(function(){
       console.log(i)
       },1000)
}
//4444复制代码
for(var i=0;i<4;i++){
    setTimeout(function(){
       console.log(i)
       },1000)
}
//4444复制代码

有js基础的小伙伴一定会一眼看出这段代码的执行结果,那就是接连打印4个4。

这里牵涉到js的执行机制,简单的做个说明:

1.js是单线程(single threaded)语言,浏览器只分配给js一个主线程,用来执行任务(函数),但一次只能执行一个任务。这就形成了一个执行栈(execution context stack)。

2.js的宿主环境(比如浏览器,Node)是多线程的,这就使得js具备了异步(asynchronous)的属性。为什么要有异步属性?简单说就是主线程任务排队执行,如果某些事件消耗时间过长,处理效率低下不说,还会导致页面卡顿,所以要开辟“第二战线”。

3.哪些事件是异步的?比如网络请求,定时器和事件监听,这些都是非常耗时的。他们都被放到了异步队列中。这就形成了"任务队列"(task queue)。我们今天的主角,定时器,就在其中。

由于定时器是异步任务,按照js的事件执行机制,主线程即for循环,创建了四个定时器1234,他们所打印的i,由于主线程已经结束,i=4,所以自然而然打印了4个4出来。

如果我们希望有序打印0123这种不同的i值怎么办呢?

解决办法是,引入闭包来保存变量。

闭包小朋友顿时一脸懵逼,for循环和定时器好端端的,怎么就跟我闭包搭上关系了呢?

           

是的,你没听错,中央钦定了你来处理这件事。。。咳,严肃严肃,来看看怎么回事。

我们将代码改进如下:



for (var i = 0; i < 4; i++) {
    (function(a) {//闭包
        setTimeout(function() {
            console.log(a);//操纵变量a,和i无关
        }, 3000);
    })(i) 
}
//0123

复制代码
for (var i = 0; i < 4; i++) {
    (function(a) {//闭包
        setTimeout(function() {
            console.log(a);//操纵变量a,和i无关
        }, 3000);
    })(i) 
}
//0123

复制代码



上面代码将定时器放入一个自调用函数中,自调用函数传入了for循环的i作为实参赋予形参a,所以定时器打印这个a就拿到了每一个i值0,1,2,3。很多博文也将这种自调用函数叫做“立即执行函数”,其实是一回事。

为什么用一个自调用函数就能拿到每个i的值了呢?仔细观察可以发现,这其实是闭包在发挥作用。

在这里,for循环里定义的i变量其实暴露在全局作用域内,于是5个定时器里的匿名函数它们其实共享了同一个作用域里的同一个变量。所以如果想要0,1,2,3,4的结果,就要在每次循环的时候,把当前的i值单独存下来,而我们知道,闭包的特点是可以保存数据,延长作用域链,匿名函数生成了闭包的效果,新建了一个作用域,这个作用域接收到每次循环的i值保存了下来,即使循环结束,闭包形成的作用域也不会被销毁。这就是每个i值能被单独保存下来的原因。

上面代码还可以改写成下面的模样,这也是比较常见的一种写法。

for (var i = 0; i < 4; i++) {
    setTimeout(fn(i), 3000);}

function fn(a){
   return function(){
      console.log(a);
   }
}复制代码
for (var i = 0; i < 4; i++) {
    setTimeout(fn(i), 3000);}

function fn(a){
   return function(){
      console.log(a);
   }
}复制代码

到了ES6中,我们就不用这么麻烦了

使用let关键字声明for循环的i变量,不需要借助闭包,即可实现上述函数效果。

for(let i = 0; i < 4; i++) {
    setTimeout(function () {
        console.log(i);
    });
}
//0,1,2,3复制代码
for(let i = 0; i < 4; i++) {
    setTimeout(function () {
        console.log(i);
    });
}
//0,1,2,3复制代码

我们知道,let关键字是ES6中一个相当大的变化,使用let关键字声明变量,克服了之前使用var声明的内存泄漏、全局污染等问题。

更重要的是,let可以和{ }代码块结合形成块级作用域。在for循环中使用let声明计数变量i,i 只在本轮循环有效,相当于每一轮都会重新声明一个 i。而且JS引擎会记住上一轮的 i,随后的每个循环都会使用上一个循环结束时的值来初始化这个变量i。

所以在for循环中使用let是相当不错的选择。

说完了setTimeout,我们再来说说他的孪生兄弟,setInterval

这俩兄弟其实非常相似,只不过一个是一次性的,一个是循环执行。

通常我们使用setInterval时,传递的参数都是两个,一个是回调函数callback,一个是延迟或者间隔时间delay,事实上,它是可以传递多个参数的,举例如下:

setInterval(function(msg1,msg2,...){},1000,'回调参数1','回调参数2',...);复制代码
setInterval(function(msg1,msg2,...){},1000,'回调参数1','回调参数2',...);复制代码

定时器延迟时间delay后面的参数,都会作为前面回调函数的实参传入。利用这个特点,我们结合前面的闭包和异步的话题,稍加延伸。

当在for循环中传入计数参数i给定时器的回调函数,会发生什么事情?

下面这段代码,打印结果如何呢?

function fn2() {
    for (var i = 0; i < 4; i++) {
        var tc = setInterval(function (i) {
            console.log(i);
        }, 1000, i);
     }
}
//打印结果 012301230123循环复制代码
function fn2() {
    for (var i = 0; i < 4; i++) {
        var tc = setInterval(function (i) {
            console.log(i);
        }, 1000, i);
     }
}
//打印结果 012301230123循环复制代码

为什么会打印出这个结果呢?

简单说来,这仍然是一个闭包,这个闭包形成的关键,就是for循环计数参数i作为定时器定时器的回调函数实参,传入了回调函数。从这个角度看,这仍然是一个典型的闭包,即内部函数拿到了外部函数中定义的变量,并且一旦拿到,这个变量就会被保存。

for循环创建四个定时器后,主线程结束,开始处理异步任务,此时四个定时器1234处于“任务队列”(task queue)中,主线程空闲,异步任务立即被推入主线程开始处理(这只是大致过程,实际过程还可以细分,这里不再赘述)。此时四个定时器各自保存了一个i值分别是0123,他们遵循先后顺序,每隔一秒各自打印自己保存的i值。这就是上面结果的由来。

至此,小伙伴们是不是已经明白了for循环、定时器相结合时发生了什么事情呢?相信你已经小鸡啄米般点头,“懂了”“懂了”。好的,那,

来一波骚操作,当我们在打印 i 值后面立即清除定时器,会发生什么事情?


代码如下:

function fn2() {
    for (var i = 0; i < 4; i++) {
        var tc = setInterval(function (i,tc) {
            console.log(i);
            clearInterval(tc)
        }, 1000, i,tc);
     }
}
//打印结果:0123333333   3无限循环复制代码
function fn2() {
    for (var i = 0; i < 4; i++) {
        var tc = setInterval(function (i,tc) {
            console.log(i);
            clearInterval(tc)
        }, 1000, i,tc);
     }
}
//打印结果:0123333333   3无限循环复制代码

????发生了什么事情??刚刚建立的定时器大厦好像出现了一丝颤动。但是,这好像又是常规操作,定时器总是要清的,至于在哪清,那是另一回事了。

很多同学一看,这不和上面刚说的i形成闭包的原理一样嘛,tc传入作为实参,所以每个tc都被清除了,但是最后一个没被清除是怎么回事??

这里更多的是考察对js执行机制和运算的理解。

定时器是有id的,在这里依次为1234 。当第一个定时器 tc=1 开始执行时,打印i为0,接着清除定时器,问题来了,这个tc,是哪个tc?这就是整个问题的关键。

事实上,  = 是赋值运算符,先计算右边,右边计算时,这个tc值是多少呢?是  =  左边刚刚新鲜热辣var出来的tc1吗?答案显然是否定的。如果我们此时打印查看这个tc值,会发现,它是

undefined。

原因很简单,此时还不存在左边的tc,在整个作用域内,找不到tc可清理。

接下来就简单了,当第二个定时器开始执行时,情况有变,此时 i=1,清理定时器tc,这个tc是哪个?很显然,它是第一个 tc=1

至此,水落石出,当第四个定时器 tc=4 开始工作时, i=3 ,清除的是上一个定时器 tc=3  ,而它本身,没有下一个定时器清除了,所以它会一直打印3。


本文由一个工程实际问题,抽象出函数模型,目的是探讨一些在for循环中定时器带来的一些问题和现象。其中涉及到事件循环(Event Loop)的部分简单带过,说的可能不够严谨,建议有兴趣的童鞋参阅相关文章做更全面的了解。文中如有错误,恳请指正。


x