setTimeout()引发的一些思考 | 循环延时和作用域、闭包

起源

会研究起setTimeout()这个方法,还是来源于一次开发要求。这次开发的需求是要求让一些动作/动画依次延时执行,简单来说,比如让页面滚动到底部停留2秒滚回顶部,然后停留2秒点击某个按钮触发某个选项的弹出层,然后停留两秒,进入下一个页面。
看起来不就是多设几个setTimeout不就行了?

诶,这也是我一开始觉得简单的原因,我觉得不过是延时嘛,然后延时之后执行一下动作/动画就好了。

关键点来了。需求中有一点比较重要:给多个页面写一套通用的方案,用于展示页面。先滚动到页面底部,后滚动到目的地点,点击按钮->,然后跳转下一个页面。由于事先不知道每个页面需要的按钮数目是多少,就必须得定义一个JSON数组存放动作。然后前端用for循环来读取这个动作列表,进而执行动画。

问题

我们假设一下这个动作的JSON数组列表是performance:[...],每个页面的performance的长度不一样。一开始我简单写了个循环。

1
2
3
4
5
6
7
for (i = 0; i < performance.length; i++){
var target = performance[i].target,
action = performance[i].action;
setTimeout(function(){
$(target).trigger(action)
},1000);
}

你猜猜这会发生什么?反正绝对不是我们字面上所想要表达出来的意思。
实际上,假如总共有n个动作需要执行的话,那么上面那串代码只会执行最后一个动作。也许你会觉得很奇怪,为什么不应该是在每个循环体内延时1秒执行动作然后再进行下一个动作呢?

分析

要解决这个问题,首先我们必须明确几个概念:

  • JavaScript是单线程的
  • 浏览器不是单线程的
  • 同步和异步的区别

首先,JS是单线程的。它需要执行的任务是需要排队执行的。也就是,如果所有的任务都是以同步的情况下书写的,在主线程里,就会出现在某个任务耗时很长的情况下,它的下一个任务就迟迟无法执行。然而异步的概念是,将某些耗时的任务暂时先挂起->任务队列,主线程得以快速执行任务。直到
主线程空了以后,就会去任务队列里找第一个事件来执行。浏览器不是单线程的,这点很好理解。你用浏览器肯定会打开多个窗口,如果只有单线程的话,那就无法做到多窗口同时浏览啦;浏览器除了有执行js代码的一条线程,还有渲染网页效果的线程等。OK,简单明确一下这几个概念之后,我们就开始回到上面那个问题中去。

上面那个问题中,setTimeout(function(),*)里面的那个function实际上是异步回调的函数了。这么理解比较容易,由于setTimeout()这个方法会让function()里的代码延时*s执行,那么实际上它就被挂起到任务队列中了。for循环体内setTimeout内的function将会在1s后执行。为了方便,我们将n设成10。而让i从0跑到10,花费1s都不需要,所以等setTimeout()的时间到时之后,for循环中的i已经跑到9了。而setTimeout的function这才被执行。也就是:

  • for先循环10次->直到setTimeout的时间结束
  • setTimeout内的function执行

既然这样,为什么只执行了最后一个动作n次呢?

作用域和闭包在此的简单描述

这里我们需要了解一下作用域和闭包。为什么只执行了最后一次动作,是因为function里performance[i].target的这个i并没有保留每次循环时候的i而是留下了循环到最后一次的i。之所以没有把i留在function内部,是因为我们没有将每个i作为一个参数传入留在function内部。因此console.log(i)实际上是引用着外部作用域的i->而i因为循环地迅速,早已自加到了9。所以只能执行起最后一个动作了。为了让i能够留在function内部存下来,我们需要构造一个闭包。

仔细看看下面的代码跟上面的代码的区别:

1
2
3
4
5
6
7
8
9
for (i = 0; i < performance.length; i++){
(function(i){
var target = performance[i].target,
action = performance[i].action;
setTimeout(function(){
$(target).trigger(action)
},1000);
})(i)
}

在这里,我们引入了一个匿名函数,这个函数需要一个参数传入,也就是i。通过传入的这个参数,我们将i成功传入function内部作用域保存了起来。然而这样做还是有个问题:那就是,所有的动作都在同一个时刻触发了。也就是延迟1s之后同时触发了所有的动作。这又是为什么?

解决

分析中有个地方不知道有没有说清楚,那就是:

  • for先循环10次->直到setTimeout的时间结束
  • setTimeout内的function执行

这块地方的东西实际上是这样的。直到setTimeout结束实际上是指第一个setTimeout结束后,开始触发内部function。然而所有的setTimeout都是在1s后触发,那就会出现同时触发的情况。为了解决这个问题,我们需要手动进行延时计算。其实也很简单,由于这里我们是希望每个动作都是在上一个动作延时1秒后发生。那么就可以这样:

1
2
3
4
5
6
7
8
9
for (i = 0; i < performance.length; i++){
(function(i){
var target = performance[i].target,
action = performance[i].action;
setTimeout(function(){
$(target).trigger(action)
},1000*i);
})(i)
}

这样的话就能解决动作\动画延时循环的问题了。

实际上,还可以采用promise的方法,下一次我会将promise带入这个话题,在循环中进行更精确的延时操作。

Author: Molunerfinn
Link: https://molunerfinn.com/something-about-settimeout/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.
支付宝打赏
微信打赏
~超快便宜好用的SSR机场点我注册~