js 基础之 setTimeout 与 setInterval 原理分析
setTimeout 与 setInterval 概述
setTimeout 与 setInterval 是 JavaScript 引擎提供的两个定时器方法,分别用于函数的延时执行和循环调用。前者的主要思想是通过一个定时器,让函数在计时结束后再执行;后者则是每隔一定的时间,就启动一次函数的执行。
从原理来看,两者似乎并不复杂。但由于 JavaScript 引擎是单线程的,这就让上述两个定时器的实际执行变得稍微复杂了一些。下面我们来看一下两者的运行机制与需要注意的问题。
基本原理
知识铺垫
单线程模型:由于 JavaScript 被设计为用在浏览器环境,而该环境下存在大量可能发生冲突的 DOM 操作,为了避免进行复杂的冲突处理(可能存在的冲突数量几乎不可预测),JavaScript 的设计者舍弃了 java 的多线程模型(该模型下,执行引擎同时可以做几件事,但要进行线程同步),将其设计成了一门单线程语言(执行引擎在同一时间只做一件事)。
注意:这里的单线程是指 JavaScript 的主线程只有一个。除了这个主线程,JavaScript 还有一个 I/O 线程,通过事件循环来处理 I/O 问题,但两者之间相对独立,不需要进行状态同步,因此我们仍然可以把 JavaScript 看成一门单线程语言。
任务队列:所谓任务队列,就是用于存储等待执行的任务的队列。由于 JavaScript 是一门单线程语言,如果当前有一个任务需要执行,但 JavaScript 引擎正在执行其他任务,那么这个任务就需要放进一个队列中进行等待。等到线程空闲时,就可以从这个队列中取出最早加入的任务进行执行(类似于我们去银行排队办理业务。单线程相当于说这家银行只有一个服务窗口,一次只能为一个人服务,后面到的就需要排队,而任务队列就是排队区,先到的就优先服务)。
注意:如果当前线程空闲,并且队列为空,那每次加入队列的函数将立即执行。
setTimeout 与 setInterval
setTimeout(func, delay, args) :设置超时调用。如对于 setTimeout(func, 100, args),js 引擎会为 func 函数设置一个计时器,100 毫秒后,将 func 添加到任务队列等待执行。
setInterval(func, interval, args) :设置循环调用。对于语句 setInterval(func, 100, args),js 引擎每隔 100 毫秒就会把 func 添加到任务队列一次。
相同点:
两者都会加入同一个队列,等待线程空闲时执行。
两者都无法保证在何时执行回调,因为无法知道线程何时空闲。
不同点
setTimeout 只会将函数添加到任务队列一次,而 setInterval 则是循环往队列中添加函数。
setTimeout 可以保证函数在指定的时间间隔内不会执行,而 setInterval 无法保证(有可能出现接近连续执行的情况,后面会分析原因)。
运行机制
setTimeout
setTimeout 的运行机制相对简单,即在执行该语句时,设置一个定时器,定时时间置为所设置的延时,当计时结束后,将传入的函数加入任务队列,之后的执行就交给任务队列负责。
setTimeout 函数本身会返回一个句柄,我们可以在函数执行前通过向 clearTimeout 传入该句柄取消函数的执行。示例代码如下:
上述代码将在 100 毫秒后执行 func 函数,弹出一个内容为"你好"的对话框。如果在 100 毫秒内调用了 cancel,就可以取消 func 函数的执行。
setInterval
setInterval 本质上就是每隔一定的时间向任务队列添加回调函数。但 setInterval 有一个原则:在向队列中添加回调函数时,如果队列中存在之前由其添加的回调函数,就放弃本次添加(不会影响之后的计时)。另外也可以通过 clearInterval 方法移除定时器,使用方法同 clearTimeout。
由于 setInterval 只负责定时向队列中添加函数,而不考虑函数的执行,那么我们考虑一下下面的情况:
假设线程执行完 setInterval(func, 100, args)后处于完全空闲状态(即只要向任务队列添加函数就会立即执行)。而 func 是一个相对复杂的函数,执行该函数需要 90 毫秒。那么函数的执行过程就会变成下图所示:
从图中可以看到,从上次函数执行完毕,到下次开始执行,之间只间隔了 10 毫秒,而不是我们所希望的每隔 100 毫秒执行一次(因为 setInterval 只关注任务添加,不关注任务执行)。
由于上述机制,在很多情况下,setInterval 都会遇到一些性能问题。就拿上面的例子来说,我们的本意可能是每隔 100 毫秒执行一次函数,结果只等待了 10 毫秒就又执行了一次。另外,对于复杂的实际情况,setInterval 经常出现两次的执行间隔相差甚远的情况,对于用户能感知到的操作,这会带来很不好的用户体验。因此在实际编码中,开发者通常会使用 setTimeout 来模拟实现 setInterval 效果(下面会有举例)。
而如果线程一开始是繁忙的,直到 150 毫秒处才进入空闲状态(假设 func 执行时长为 10 毫秒),那么实际的运行将变成下图所示:
这里在 100 毫秒处向队列添加 func 时,由于线程繁忙,上次添加的 func 还在队列中等待,因此直接丢弃本次要添加的函数,但在 200 毫秒时仍然重新向队列中添加 func。
应用场景
setTimeout
setTimeout 主要用于需要进行延时调用的场景中。如之前一篇文章介绍的js基础之函数的节流与防抖,就是 setTimeout 典型的应用场景。此外,由于 setInterval 存在的性能问题,在实际的编码中,开发人员通常会使用 setTimeout 来模拟 setInterval,以防止出现函数连续执行的情况。如对于下面的代码:
我们可以通过以下代码来实现:
利用 setTimeout 保证在指定的时间内不会执行的特点,我们可以在执行完上次的回调函数后,重置定时器,实现循环执行 func 的效果,并且从上次执行完毕到下次执行开始,至少会经过 100 毫秒。这在实际的编码中通常会带来较大的性能提升,同时函数的执行间隔也会相对稳定。
setInterval
尽管存在上述性能问题,setInterval 的使用场景相对较少,但当所使用的接口来自外部(即回调函数本身无法修改)时,就必须通过 setInterval 来实现循环执行了。此外,对于动画效果来说,我们通常会希望动画运行的更加平滑(也就是希望函数运行得更频繁),这时使用 setInterval 往往更加流畅,具体请参考之前的文章使用原生js实现简单动画效果。
除了这类情况,开发者一般不会使用 setInterval 方法进行循环调用。
补充说明
setTimeout 与 setInterval 的第一个参数可以是一个匿名函数,也可以是一个函数名,或者是一个字符串,如下面的写法都是合法的:
但是传入如下的格式就可能报错:
因为这种写法实际上是先调用 func 函数,然后再将返回值添加到任务队列。如果 func 的返回值不是函数(或可执行的字符串),那么程序就会报错;如果返回值是函数,则会将返回的函数添加到任务队列。该情况可以写成下面的形式:
此外,当给 setTimeout 传入的延迟时间为 0 时,并不代表回调函数会立即执行。实际上浏览器规定的有一个默认的最短计时时间,对于现代浏览器,这个时间一般为 4 毫秒(老版本的浏览器则会更长一些)。也就是说,即使传入的延迟时间为 0,浏览器也会至少在 4 毫秒后才会执行。
上述补充说明同样适用于 setInterval。
总结
setTimeout 与 setInterval 都是通过一个定时器控制回调函数的执行,但由于 javascript 单线程的特点,两者都不能准确控制函数的执行时间点,这点还请开发者注意。如果函数只需要执行一次,很显然我们会使用 setTimeout 来实现;如果是循环执行的情况,如果我们希望函数执行频率不那么高,并且间隔更稳定,通常是使用 setTimeout 模拟实现 setInterval 效果。
总的来说,虽然都被用于函数延迟执行,但两者的运行机制有本质上的区别,所以在使用的时候请注意区分。
版权声明: 本文为 InfoQ 作者【京东科技开发者】的原创文章。
原文链接:【http://xie.infoq.cn/article/cdf8e09c9fa6d952ca31cb41f】。文章转载请联系作者。
评论