2022 年 10 月 3 日

计划:setTimeout 和 setInterval

我们可能决定现在不执行某个函数,而是在稍后的某个时间执行。这称为“计划调用”。

有两种方法可以实现

  • setTimeout 允许我们在一段时间后运行一次函数。
  • setInterval 允许我们在一段时间后重复运行一个函数,然后以该间隔连续重复。

这些方法不属于 JavaScript 规范。但大多数环境都有内部计划程序并提供这些方法。特别是,它们在所有浏览器和 Node.js 中都受支持。

setTimeout

语法

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

参数

func|code
要执行的函数或代码字符串。通常情况下,这是一个函数。出于历史原因,可以传递一段代码字符串,但这不推荐。
延迟
运行前的延迟,以毫秒为单位(1000 毫秒 = 1 秒),默认值为 0。
arg1arg2
函数的参数

例如,这段代码在 1 秒后调用 sayHi()

function sayHi() {
  alert('Hello');
}

setTimeout(sayHi, 1000);

带参数

function sayHi(phrase, who) {
  alert( phrase + ', ' + who );
}

setTimeout(sayHi, 1000, "Hello", "John"); // Hello, John

如果第一个参数是字符串,则 JavaScript 会根据该字符串创建一个函数。

因此,以下代码也可以运行

setTimeout("alert('Hello')", 1000);

但建议不要使用字符串,而应使用箭头函数,如下所示

setTimeout(() => alert('Hello'), 1000);
传递函数,但不要运行它

新手开发者有时会犯一个错误,即在函数后添加括号 ()

// wrong!
setTimeout(sayHi(), 1000);

这样做不起作用,因为 setTimeout 需要一个函数引用。而此处 sayHi() 运行了该函数,而其执行结果被传递给了 setTimeout。在我们的例子中,sayHi() 的结果是 undefined(该函数不返回任何内容),因此不会安排任何操作。

使用 clearTimeout 取消

setTimeout 的调用会返回一个“计时器标识符”timerId,我们可以用它来取消执行。

取消的语法

let timerId = setTimeout(...);
clearTimeout(timerId);

在下面的代码中,我们安排了该函数,然后取消它(改变了主意)。结果,什么也没发生

let timerId = setTimeout(() => alert("never happens"), 1000);
alert(timerId); // timer identifier

clearTimeout(timerId);
alert(timerId); // same identifier (doesn't become null after canceling)

alert 输出中可以看到,在浏览器中,计时器标识符是一个数字。在其他环境中,它可能是其他东西。例如,Node.js 返回一个带有附加方法的计时器对象。

同样,对于这些方法没有通用的规范,因此这没关系。

对于浏览器,计时器在 HTML Living Standard 的计时器部分中进行了描述。

setInterval

setInterval 方法与 setTimeout 的语法相同

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

所有参数的含义相同。但与 setTimeout 不同,它不仅运行一次函数,而且在给定的时间间隔后定期运行函数。

要停止进一步的调用,我们应该调用 clearInterval(timerId)

以下示例将每 2 秒显示一条消息。5 秒后,输出停止

// repeat with the interval of 2 seconds
let timerId = setInterval(() => alert('tick'), 2000);

// after 5 seconds stop
setTimeout(() => { clearInterval(timerId); alert('stop'); }, 5000);
在显示 alert 时时间继续流逝

在大多数浏览器中,包括 Chrome 和 Firefox,内部计时器在显示 alert/confirm/prompt 时继续“滴答作响”。

因此,如果你运行上面的代码,并且一段时间内不关闭 alert 窗口,那么在你关闭它时,下一个 alert 将立即显示。警报之间的实际间隔将短于 2 秒。

嵌套 setTimeout

有两种方法可以定期运行某些内容。

一种是 setInterval。另一种是嵌套 setTimeout,如下所示

/** instead of:
let timerId = setInterval(() => alert('tick'), 2000);
*/

let timerId = setTimeout(function tick() {
  alert('tick');
  timerId = setTimeout(tick, 2000); // (*)
}, 2000);

上面的 setTimeout 在当前一个 (*) 的末尾立即安排下一个调用。

嵌套 setTimeout 是比 setInterval 更灵活的方法。这样,下一个调用可能会根据当前调用的结果而安排不同。

例如,我们需要编写一个服务,每 5 秒向服务器发送一个请求以请求数据,但如果服务器过载,则应将间隔增加到 10、20、40 秒……

以下是伪代码

let delay = 5000;

let timerId = setTimeout(function request() {
  ...send request...

  if (request failed due to server overload) {
    // increase the interval to the next run
    delay *= 2;
  }

  timerId = setTimeout(request, delay);

}, delay);

如果我们正在安排的函数是 CPU 密集型的,那么我们可以测量执行所花费的时间,并尽早或稍后计划下一次调用。

嵌套 setTimeout 允许比 setInterval 更精确地设置执行之间的延迟。

让我们比较两个代码片段。第一个使用 setInterval

let i = 1;
setInterval(function() {
  func(i++);
}, 100);

第二个使用嵌套 setTimeout

let i = 1;
setTimeout(function run() {
  func(i++);
  setTimeout(run, 100);
}, 100);

对于 setInterval,内部调度程序将每 100 毫秒运行 func(i++)

你注意到了吗?

对于 setIntervalfunc 调用之间的实际延迟小于代码中的延迟!

这是正常的,因为 func 执行所花费的时间“消耗”了部分间隔。

func 的执行可能比我们预期的要长,并且花费的时间超过 100 毫秒。

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

在极端情况下,如果函数始终执行时间超过 delay 毫秒,那么调用将完全不间断地发生。

以下是嵌套 setTimeout 的图片

嵌套 setTimeout 保证了固定的延迟(此处为 100 毫秒)。

这是因为新调用是在前一个调用的末尾计划的。

垃圾回收和 setInterval/setTimeout 回调

当一个函数传递到 setInterval/setTimeout 中时,会创建一个内部引用并保存在调度器中。它防止函数被垃圾回收,即使没有其他引用指向它。

// the function stays in memory until the scheduler calls it
setTimeout(function() {...}, 100);

对于 setInterval,函数会一直保存在内存中,直到调用 clearInterval

有一个副作用。一个函数引用外部词法环境,因此,只要它存在,外部变量也存在。它们可能比函数本身占用更多内存。因此,当我们不再需要计划的函数时,最好取消它,即使它很小。

零延迟 setTimeout

有一个特殊用例:setTimeout(func, 0),或只是 setTimeout(func)

这会尽快安排执行 func。但是调度器只会在当前执行的脚本完成后才调用它。

因此,该函数被安排在当前脚本“之后”运行。

例如,这会输出“Hello”,然后立即输出“World”

setTimeout(() => alert("World"));

alert("Hello");

第一行“在 0 毫秒后将调用放入日历”。但是调度器只有在当前脚本完成后才会“检查日历”,所以 "Hello" 在前,"World" 在后。

还有零延迟超时的浏览器相关高级用例,我们将在 事件循环:微任务和宏任务 一章中讨论。

零延迟实际上不是零(在浏览器中)

在浏览器中,嵌套计时器可以运行的频率受到限制。HTML Living Standard 中说:“在五个嵌套计时器之后,间隔被强制为至少 4 毫秒。”。

让我们通过下面的示例演示它的含义。其中的 setTimeout 调用以零延迟重新安排它自己。每个调用都会在 times 数组中记住上一个调用的真实时间。实际延迟看起来如何?我们来看看

let start = Date.now();
let times = [];

setTimeout(function run() {
  times.push(Date.now() - start); // remember delay from the previous call

  if (start + 100 < Date.now()) alert(times); // show the delays after 100ms
  else setTimeout(run); // else re-schedule
});

// an example of the output:
// 1,1,1,1,9,15,20,24,30,35,40,45,50,55,59,64,70,75,80,85,90,95,100

第一个计时器立即运行(就像规范中写的那样),然后我们看到 9, 15, 20, 24...。调用之间强制的 4 毫秒以上延迟开始发挥作用。

如果我们使用 setInterval 代替 setTimeout,也会发生类似的事情:setInterval(f) 以零延迟运行 f 几次,然后以 4 毫秒以上的延迟运行。

这种限制来自远古时代,许多脚本依赖于它,所以它出于历史原因而存在。

对于服务器端 JavaScript,不存在这种限制,并且还有其他方法可以安排一个立即的异步作业,例如 Node.js 的 setImmediate。因此,此说明是特定于浏览器的。

总结

  • 方法 setTimeout(func, delay, ...args)setInterval(func, delay, ...args) 允许我们在 delay 毫秒后运行一次/定期运行 func
  • 要取消执行,我们应使用 setTimeout/setInterval 返回的值调用 clearTimeout/clearInterval
  • 嵌套 setTimeout 调用是 setInterval 的更灵活的替代方法,它允许我们更精确地设置执行之间的时间
  • 使用 setTimeout(func, 0)(与 setTimeout(func) 相同)进行零延迟调度,用于调度“尽快在当前脚本完成后”的调用。
  • 对于 setTimeoutsetInterval(在第 5 次调用后)的五个或更多嵌套调用,浏览器将最小延迟限制为 4 毫秒。这是出于历史原因。

请注意,所有调度方法均不保证确切的延迟。

例如,浏览器计时器可能会因很多原因而变慢

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

所有这些都可能将最小计时器分辨率(最小延迟)增加到 300 毫秒,甚至 1000 毫秒,具体取决于浏览器和操作系统级别的性能设置。

任务

重要性:5

编写一个函数 printNumbers(from, to),从 from 开始到 to 结束,每秒输出一个数字。

制作两个解决方案变体。

  1. 使用 setInterval
  2. 使用嵌套 setTimeout

使用 setInterval

function printNumbers(from, to) {
  let current = from;

  let timerId = setInterval(function() {
    alert(current);
    if (current == to) {
      clearInterval(timerId);
    }
    current++;
  }, 1000);
}

// usage:
printNumbers(5, 10);

使用嵌套 setTimeout

function printNumbers(from, to) {
  let current = from;

  setTimeout(function go() {
    alert(current);
    if (current < to) {
      setTimeout(go, 1000);
    }
    current++;
  }, 1000);
}

// usage:
printNumbers(5, 10);

请注意,在两个解决方案中,在第一次输出之前都有一个初始延迟。第一次调用函数后 1000 毫秒

如果我们还希望函数立即运行,则可以在单独的行上添加一个附加调用,如下所示

function printNumbers(from, to) {
  let current = from;

  function go() {
    alert(current);
    if (current == to) {
      clearInterval(timerId);
    }
    current++;
  }

  go();
  let timerId = setInterval(go, 1000);
}

printNumbers(5, 10);
重要性:5

在下面的代码中,有一个 setTimeout 调度调用,然后运行一个繁重的计算,完成需要 100 毫秒以上的时间。

调度的函数何时运行?

  1. 在循环之后。
  2. 在循环之前。
  3. 在循环开始时。

alert 将显示什么?

let i = 0;

setTimeout(() => alert(i), 100); // ?

// assume that the time to execute this function is >100ms
for(let j = 0; j < 100000000; j++) {
  i++;
}

任何 setTimeout 仅在当前代码完成后才运行。

i 将是最后一个:100000000

let i = 0;

setTimeout(() => alert(i), 100); // 100000000

// assume that the time to execute this function is >100ms
for(let j = 0; j < 100000000; j++) {
  i++;
}
教程地图

评论

在评论之前请先阅读此内容…
  • 如果您有改进建议 - 请 提交 GitHub 问题 或提交拉取请求,而不是评论。
  • 如果您无法理解文章中的内容 – 请详细说明。
  • 要插入一些代码,请使用 <code> 标记,对于多行代码 – 将其包装在 <pre> 标记中,对于 10 行以上的代码 – 使用沙盒 (plnkrjsbincodepen…)