setTimeout 和 setInterval

JavaScript 中最典型的 schedule(调度执行)就是 setTimeout 和 setInterval。所谓调度(scheduling a call),就是有时候我们不想立即执行函数,而是等到特定的一段时间之后执行。

这两个方法并不是 JavaScript 的规范,但是大多数运行环境,浏览器、Nodejs 平台都支持这两种方式的调度。

setTimeout

语法:

js
1let timerId = setTimeout(func|code, [delay], [arg1], [arg2], ...)

参数说明:

func|code

想要执行的函数或者代码字符串。一般传入的是函数,由于某些历史原因,也支持传入代码字符串。

delay

执行前的延时,以毫秒为单位,默认值为 0

arg1,arg2...

要传入被执行函数的参数列表

setTimeout 在调用时会返回一个定时器标识符(timer identifier),我们可以用它来取消执行。

js
1let timerId = setTimeout(...);
2clearTimeout(timerId);

setInterval

setInterval 方法和 setTimeout 的语法相同:

js
1let timerId = setInterval(func|code, [delay], [arg1], [arg2], ...)

所有参数和返回值的意义也是相同的。不过setTimeout只执行一次,setInterval是每间隔给定的时间周期执行。

想要阻止后续调用,我们需要调用clearInterval(timerId)

js
1let timerId = setInterval(...);
2clearInterval(timerId);

下面的例子将每间隔 2 秒就会输出一条消息。5 秒之后,输出停止:

js
1// 每 2 秒重复一次
2let timerId = setInterval(() => alert('tick'), 2000)
3
4// 5 秒之后停止
5setTimeout(() => {
6  clearInterval(timerId)
7  alert('stop')
8}, 5000)

如果我们在 2 秒后才关闭 Alert 弹窗,那么下一个弹窗会马上显示。

因为在显示 alert/confirm/prompt 弹窗时,内部的定时器仍旧会继续“嘀嗒”。

说明 setInterval的计算时间是从给定函数开始执行时算起的。

这也导致很多时候我们不得不使用嵌套的setTimeout来更精确地模拟setInterval。

用嵌套的 setTimeout 替代 setInterval

setInterval 并不能够精确地设置两次执行之间的延时。

以下面的代码为例:

js
1let i = 1
2setInterval(function () {
3  func(i++)
4}, 100)

对 setInterval 而言,内部的调度程序会每间隔 100 毫秒执行一次 func(i++):

image-20221222224301871

注意到了吗?

使用 setInterval 时,func 函数的实际调用间隔要比代码中设定的时间间隔要短!

因为func的执行所花费的时间消耗了一部分的分隔时间。

也可能出现这种情况,就是 func 的执行所花费的时间比我们预期的时间更长,并且超出了 100 毫秒。

在这种情况下,JavaScript 引擎会等待 func 执行完成,然后检查调度程序,如果时间到了,则 立即 再次执行它。

极端情况下,如果函数每次执行时间都超过 delay 设置的时间,那么每次调用之间将完全没有停顿。

我们用嵌套的 setTimeout 来完成类似 setInterval 循环间隔执行任务的功能

js
1let i = 1
2let timer = setTimeout(function run() {
3  func(i++)
4  timer = setTimeout(run, 100) // (*)
5}, 100)

上面这个 setTimeout 在当前这一次函数执行完时 (*) 立即调度下一次调用。

嵌套的 setTimeout 要比 setInterval 灵活得多。采用这种方式可以根据当前执行结果来调度下一次调用,因此下一次调用可以与当前这一次不同。

这是嵌套的setTimeout的示意图:

image-20221222230727637

嵌套的setTimeout就能确保延时的固定。

这是因为下一次调用时在前一次调用完成时再调度的。

垃圾回收与 setTimeout/setInterval

当一个函数传入 setInterval/setTimeout 时,将为其创建一个内部引用,并保存在调度程序中。这样,即使这个函数没有其他引用,也能防止垃圾回收器(GC)将其回收。

js
1// 在调度程序调用这个函数之前,这个函数将一直存在于内存中
2setTimeout(function() {...}, 100);

这里还要提到一个副作用。如果函数引用了外部变量(闭包),那么只要这个函数还存在,外部变量也会随之存在。它们可能比函数本身占用更多的内存。因此,当我们不再需要调度函数时,最好取消它,即使这是个(占用内存)很小的函数。

零延时的 setTimeout

有一种特别的用法setTimeout(func,0)或者setTimeout(func)。

这种调度可以让 func 尽快执行。但是只有当前脚本执行完毕后,调度程序才会调用它。

例如,下面的代码会先输出Hello,再输出World

js
1setTimeout(() => alert('World'))
2
3alert('Hello')

零延时实际上不为零(在浏览器中)

在浏览器环境下,嵌套定时器的运行频率是受到限制的。

HTML5 标准这样说:经过 5 重嵌套定时器之后,时间间隔被强制限制为至少 4 毫秒。

下面有一段代码示例。其中setTimeout调用会以零延时重新调度自身的调用。

每次调用都会在times数组中记录上一次调用与start的时间差值。

js
1let start = Date.now()
2let times = []
3
4setTimeout(function run() {
5  times.push(Date.now() - start) // 保存前一个调用的延时
6
7  if (start + 100 < Date.now()) alert(times)
8  // 100 毫秒之后,显示延时信息
9  else setTimeout(run) // 否则重新调度
10})
11// [2,2,2,2,6,11,16,21,27,33,37,41,45,50,54,59,63,67,72,76,81,85,90,94,99,103]

从结果上看,前面的 4 次,每次调用花费的时间减去start记录的开始时间都为 2 毫秒。这说明前四次延时为 0。

从第五次开始,也就是6,11,16,21 ...,他们的差额都大于等于 4 毫秒。

这说明五次嵌套定时器后,会有一个经过 4 毫秒以上的强制延时。

如果我们使用 setInterval 而不是 setTimeout,也会发生类似的情况:setInterval(f) 会以零延时运行几次 f,然后以 4 毫秒以上的强制延时运行。

这个机制由于历史原因而存在,并且许多脚本也都依赖这个机制而运行。所以这个机制也一直存在。

但是在 Nodejs 平台,就没有这个限制,并且还新增了其他调度即时异步任务的方式。例如setImmediate。因此,这个机制只是针对浏览器环境。

总结

  • setTimeout和setInterval都运行我们在给定 delay 毫秒后运行回调函数。
  • 使用clearTimeout和clearInterval可以取消函数的执行
  • 嵌套的setTimeout要比setIntetval来得精确且灵活
  • 零延时的调度用来尽快执行调用,但是也会在当前脚本执行完成后才进行调用。
  • 浏览器环境会将setTimeout和setInterval的五层或更多的最小延时限制在 4ms

注意:任何调度方法都不能保证确切的延时。

浏览器内的计时器可能由于许多原因变慢:

  • CPU 过载
  • 浏览器页处于后台模式
  • 笔记本电脑省电模式

所有这些因素,可能会将定时器的最小计时器分辨率(最小延迟)增加到 300ms 甚至 1000ms,具体以浏览器及其设置为准。