原文链接:http://javascript.info/settimeout-setinterval,translate with ❤️ by zhangbao.

我们有时决定不现在执行函数,而是等一段时间。这称为“调度调用”。

有两个方法供使用:

  • setTimeout 允许在指定的时间间隔之后触发一次函数。

  • setInterval 允许在指定的时间间隔定期触发函数。

setTimeout

语法:

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

参数:

func|code``

要执行的函数和字符串类型的代码。通常是一个函数。因为历史原因,字符串代码也可以被传递,但是并不推荐。

delay

执行前的延迟时间,以毫秒计(1000ms = 1秒)。

arg1,arg2...

传递给函数的参数(IE9- 不支持)。

例如,下面代码在 1 秒后调用 sayHi():

  1. function sayHi() {
  2. alert('Hello');
  3. }
  4. setTimeout(sayHi, 1000);

携带参数:

  1. function sayHi(phrase, who) {
  2. alert( phrase + ', ' + who );
  3. }
  4. setTimeout(sayHi, 1000, "Hello", "John"); // Hello, John

如果第一个参数是字符串,那么 JavaScript 就会用它创建函数。

就是说,下面也是可以的:

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

但是使用字符串并不推荐,用函数吧:

  1. setTimeout(() => alert('Hello'), 1000);

注:我们只传递函数,不执行

许多开发者经常犯的错误是在函数后面使用 () 调用它,这不需要!

  1. // 错误的!
  2. setTimeout(sayHi(), 1000);

这不行,因为 setTimeoiut 期望的是一个函数引用。sayHi() 是执行函数,就是把函数执行之后的结果传递给 setTimeout。在我们这个例子里,sayHi() 返回的结果是 undefined(函数没有返回值),所以没有啥会进入调度。

使用 clearTimeout 取消定时器

调用 setTimeout 后,返回的是“定时器 ID”timerId,我们可以用它去取消定时器。

语法如下:

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

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

  1. let timerId = setTimeout(() => alert("never happens"), 1000);
  2. alert(timerId); // 定时器 id
  3. clearTimeout(timerId);
  4. alert(timerId); // 相同的 id 值(在取消之后没有变成 null)

正如我们从警报输出中看到的,在浏览器中,计时器标识符是一个数字。在其他环境中,这可能是另一种情况。例如,Node.js 返回一个带有附加方法的计时器对象。

同样,这些方法没有通用规范,所以这没什么错。

对于浏览器来说,计时器是在 HTML5 标准的计时器部分中描述的。

setInterval

setInterval 方法有着与 setTimeout 一样的语法:

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

所有的参数都是一个意思。但是不像 setTimeout,他执行函数可不是一次,而是在指定间隔去定时触发的。

为了阻止进一步调用,我们应该调用 clearInterval(timerId)。

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

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

注:在 Chrome/Opera/Safari 中,弹出的模态框会阻止时间进行。

在 IE 和 FireFox 浏览器中,当显示 alert/confirm/prompt 弹框时,内部计时器会继续“计时”,但在 Chrome/Opera/Safair 中,则会阻止计时器计时。

如果你执行上面的代码,不关闭 alert 框的时候,在 FireFox/IE 中,下一个 alert 会立即弹出(距离上次调用已经过去两秒了),在 Chrome/Opera/Safari 中,得等个 2 秒后,才会弹出(在 alert 期间不会继续去计时)。

递归调用 setTimeout

有两种方法可以定期运行函数。

一种是 setInterval,还有一个递归调用 setTimeout,像这样:

  1. /** 不使用:
  2. let timerId = setInterval(() => alert('tick'), 2000);
  3. */
  4. // 而是用
  5. let timerId = setTimeout(function tick() {
  6. alert('tick');
  7. timerId = setTimeout(tick, 2000); // (*)
  8. }, 2000);

上面的 setTimeout 会在 (*) 处之后立即调用下一次调度。

递归调用 setTimeout 是一种比 setInterval 更加灵活的方式。通过这种方式,下一个调用可能会以不同的方式调度,这取决于当前的结果。

例如,我们需要编写一个服务,它每5秒钟向服务器发送一个请求,请求数据,但是如果服务器超载,它应该将间隔增加到 10、20、40 秒。

这是伪代码:

  1. let delay = 5000;
  2. let timerId = setTimeout(function request() {
  3. ...send request...
  4. if (request failed due to server overload) {
  5. // 下次调用时,增加间隔
  6. delay *= 2;
  7. }
  8. timerId = setTimeout(request, delay);
  9. }, delay);

如果我们经常有一个非常耗费 CPU 的任务,那么我们就可以测量执行所花费的时间,并尽早计划下一次的调用。

递归调用 setTimeout 保证了执行之间的延迟,而 setInterval 不会。

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

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

第二种方法使用递归 setTimeout:

  1. let i = 1;
  2. setTimeout(function run() {
  3. func(i);
  4. setTimeout(run, 100);
  5. }, 100);

对 setInterval 来说,内部的调度器会每隔 100 毫秒就执行下 func(i):

调度:setTimeout 和 setInterval - 图1

注意到了吗?

使用 setInterval 调用 func 时,每次函数之间的延迟比 100 毫秒要小!

这很正常,因为时间间隔被中间的函数执行时间“消费了”。

还有种可能,就是 func 函数的执行时间可能比 100 毫秒还要长呢。

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

在边缘情况下,如果函数的执行时间总是比延迟时间长,那么调用就会在没有暂停的情况下发生。

这是递归 setTimeout 的过程:

调度:setTimeout 和 setInterval - 图2

递归 setTimeout 保证了固定的延迟(这里是100 ms)。

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

注:垃圾收集

当一个函数在 setinterval/settimeout 中传递时,会创建一个内部引用,并保存在调度程序中,它可以避免函数即使在没有其他的引用的情况下,也不会被被垃圾收集器清除。

  1. // 函数保留在内存里知道调度程序调用它
  2. setTimeout(function() {...}, 100);

于 setInterval 来说,函数在内存中停留,直到 clearInterval 被调用。

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

setTimeout(…, 0)

有一个特殊用例:setTimeout(func, 0)。

这将尽快安排 func 的执行。但是调度器只有在当前代码完成之后才会调用它。

以这个函数被调度在当前代码之后运行。换句话说,就是异步

例如,这个输出“Hello”,然后立即“world”:

  1. setTimeout(() => alert("World"), 0);
  2. alert("Hello");

第一行“在0ms之后将调用放入日历中”。但是,在当前代码完成之后,调度程序只会“检查日历”,所以“Hello”先打印出来,之后打印“world”。

分离强 CPU 消耗任务

这里有一个技巧,使用 setTimeout 来分离消耗 CPU 的任务。

例如,语法高亮脚本(用于在页面上着色代码示例)是非常耗费 CPU 的。为了高亮代码,它执行分析、创建许多有色元素,并将它们添加到文档中——对于大文本来说就比较耗费资源。它甚至可能会导致浏览器“挂起”,这是不可接受的。

因此我们可以将长文本分离成一个个片段。使用 setTimeout(…, 0) 先执行前 100 行,然后在执行后续的 100 行代码等等。

为了清晰起见,我们来看一个更简单的例子。我们有一个函数从 1 计数到 1000000000。

如果你执行这个脚本,CPU 就会挂起。在服务器端 JS 上会表现的很明显,如果实在浏览器中执行,尝试点击页面中的按钮不会有响应——整个 JavaScript 脚本都会暂停,在它完成之前,没有其他的操作可以进行。

  1. let i = 0;
  2. let start = Date.now();
  3. function count() {
  4. // do a heavy job
  5. for (let j = 0; j < 1e9; j++) {
  6. i++;
  7. }
  8. alert("Done in " + (Date.now() - start) + 'ms');
  9. }
  10. count();

浏览器时是甚至会显示“脚本花费太长时间”的警告(但希望它不会,因为这个数字不是很大)。

我们内嵌 setTimeout 函数来分离任务:

  1. let i = 0;
  2. let start = Date.now();
  3. function count() {
  4. // do a piece of the heavy job (*)
  5. do {
  6. i++;
  7. } while (i % 1e6 != 0);
  8. if (i == 1e9) {
  9. alert("Done in " + (Date.now() - start) + 'ms');
  10. } else {
  11. setTimeout(count, 0); // schedule the new call (**)
  12. }
  13. }
  14. count();

现在,浏览器用户界面在“计数”过程中是完全功能的。

我们在 (*) 处完成部分工作:

  1. 第一次执行:i=0…1000000。

  2. 第二次执行:i=1000001…2000000。

  3. 以此类推,while 检查 i 能否被 1000000 整除。

如果不是的话,就继续执行 (*) 处执行下一次调用进行工作。

在每次 count 执行期间,JavaScript 得以有“喘息”的机会去做别的事情,响应用户的其他操作。

值得注意的是,这两种变体都是通过 setTimeout 将作业分割开——在速度上不相上下。总的计数时间没有太大的差别。

为了让他们更接近,让我们做一个改进。

我们移动调度在开始时执行 count():

  1. let i = 0;
  2. let start = Date.now();
  3. function count() {
  4. // move the scheduling at the beginning
  5. if (i < 1e9 - 1e6) {
  6. setTimeout(count, 0); // schedule the new call
  7. }
  8. do {
  9. i++;
  10. } while (i % 1e6 != 0);
  11. if (i == 1e9) {
  12. alert("Done in " + (Date.now() - start) + 'ms');
  13. }
  14. }
  15. count();

现在,当我们开始 count() 并且知道我们需要计算更多 count 时,我们会在做作业之前立即安排它。

如果你运行它,很容易注意到它花费的时间要少得多。

⚠️浏览器内嵌套计时器的最小延迟

在浏览器中,内嵌计时器执行频率是有限制的。HTML 标准里说道:“在五个嵌套的计时器之后,这个间隔至少需要 4 毫秒。”

让我们用下面的例子来演示它的含义。setTimeout 调用在 0ms 之后重新调度自己。

每一个调用都能记住在 times 数组中前一个调用的真实时间。真正的延迟是什么样子的?让我们来看看:

  1. let start = Date.now();
  2. let times = [];
  3. setTimeout(function run() {
  4. times.push(Date.now() - start); // remember delay from the previous call
  5. if (start + 100 < Date.now()) alert(times); // show the delays after 100ms
  6. else setTimeout(run, 0); // else re-schedule
  7. }, 0);
  8. // an example of the output:
  9. // 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…。

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

对于服务器端 JavaScript 来说,这种限制并不存在,并且存在其他方法来安排即时的异步作业。像 Node.js 的 process.nextTick 和 setImmediate。所以这个概念只针对于浏览器。

允许浏览器渲染

浏览器脚本的另一个好处是,它们可以向用户显示进度条或其他东西。这是因为浏览器通常在脚本完成后进行所有的“重新绘制”。

所以如果我们做一个巨大的函数,即使它改变了一些东西,这些变化也不会在文档中反映出来。

这是 demo:

  1. <div id="progress"></div>
  2. <script>
  3. let i = 0;
  4. function count() {
  5. for (let j = 0; j < 1e6; j++) {
  6. i++;
  7. // put the current i into the <div>
  8. // (we'll talk more about innerHTML in the specific chapter, should be obvious here)
  9. progress.innerHTML = i;
  10. }
  11. }
  12. count();
  13. </script>

如果您运行它,那么对 i 的更改将在整个计数结束后显示出来。

如果我们使用 setTimeout 将其分割成块,那么在运行之间应用更改,这样看起来更好:

  1. <div id="progress"></div>
  2. <script>
  3. let i = 0;
  4. function count() {
  5. // 繁重任务分隔成一个个小任务 (*)
  6. do {
  7. i++;
  8. progress.innerHTML = i;
  9. } while (i % 1e3 != 0);
  10. if (i < 1e9) {
  11. setTimeout(count, 0);
  12. }
  13. }
  14. count();
  15. </script>

现在,

显示增长的 i 值。

总结

  • setInterval(func, delay, …args) 和 setTimeout(func, delay, …args) 允许让函数在 delay 毫秒之后执行一次或者定期规律执行。

  • 取消执行,我们应该调用 clearInterval/clearTimeout 来清除对应 setInterval/setTimeout 返回的值。

  • 嵌套 setTimeout 是比 setInterval 更加灵活的一个可选方案。他能保证两次执行之间的最小时间。

  • 0 暂停(Zero-timeout)调度 setTimeout(…, 0) 用来“尽可能快的,在当前代码完成之后”调度的意思。

一些使用 setTimeout(…, 0) 的场景:

  • 分离耗费 CPU 的任务成一个个小任务,避免脚本“挂起”。

  • 在进程进行的时候让浏览器做其他的事情(绘制进度条)。

请注意,所有的调度方法都不能保证准确的延迟,我们不应该在调度代码中依赖它。

例如,浏览器内的计时器可能会慢下来,原因有很多:

  • CPU 超载了。

  • 浏览器选项卡在后台模式中。

  • 笔记本在充电。

所有这些可能会增加最小的计时器分辨率(最小的延迟)到300毫秒,甚至1000毫秒,这取决于浏览器和设置。

(完)