一、有时我们并不想立即执行一个函数,而是等待特定一段时间之后再执行。这就是所谓的“计划调用(scheduling a call)”。
二、目前有两种方式可以实现:

  • setTimeout允许我们将函数推迟到一段时间间隔之后再执行。
  • setInterval允许我们重复运行一个函数,从一段时间间隔之后开始运行,之后以该时间间隔连续重复运行该函数。

三、这两个方法并不在 JavaScript 的规范中。但是大多数运行环境都有内建的调度程序,并且提供了这些方法。目前来讲,所有浏览器以及 Node.js 都支持这两个方法。

setTimeout

一、语法:

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

1、参数说明:

  • func|code:想要执行的函数或代码字符串。 一般传入的都是函数。
    • 由于某些历史原因,支持传入代码字符串,但是不建议这样做。
  • delay:执行前的延时,以毫秒为单位(1000 毫秒 = 1 秒),默认值是 0;
  • arg1,arg2…:要传入被执行函数(或代码字符串)的参数列表(IE9 以下不支持)

【示例1】sayHi()方法会在 1 秒后执行:

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

setTimeout(sayHi, 1000);

【示例2】带参数的情况:

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

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

二、如果第一个参数位传入的是字符串,JavaScript 会自动为其创建一个函数。
1、所以这么写也是可以的:

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

2、但是,不建议使用字符串,我们可以使用箭头函数代替它们,如下所示:

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

三、setTimeout期望得到一个对函数的引用。
【示例1】这里的sayHi()很明显是在执行函数,所以实际上传入setTimeout的是函数的执行结果。在这个例子中,sayHi()的执行结果是undefined(也就是说函数没有返回任何结果),所以实际上什么也没有调度。

setTimeout(sayHi(), 1000); // 一对括号()加在函数后面

用 clearTimeout 来取消调度

一、setTimeout在调用时会返回一个“定时器标识符(timer identifier)”,在我们的例子中是timerId,我们可以使用它来取消执行。
二、取消调度的语法:

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

【示例1】在下面的代码中,我们对一个函数进行了调度,紧接着取消了这次调度(中途反悔了)。所以最后什么也没发生:

let timerId = setTimeout(() => alert("never happens"), 1000);
alert(timerId); // 定时器标识符

clearTimeout(timerId);
alert(timerId); // 还是这个标识符(并没有因为调度被取消了而变成 null)

1、从alert的输出来看,在浏览器中,定时器标识符是一个数字。在其他环境中,可能是其他的东西。例如 Node.js 返回的是一个定时器对象,这个对象包含一系列方法。
三、我再重申一遍,这些方法没有统一的规范定义,所以这没什么问题。
四、针对浏览器环境,定时器在 HTML5 的标准中有详细描述,详见timers section

setInterval

一、setInterval方法和setTimeout的语法相同:

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

1、所有参数的意义也是相同的。不过与setTimeout只执行一次不同,setInterval是每间隔给定的时间周期性执行。
二、想要阻止后续调用,我们需要调用clearInterval(timerId)。

| 【示例】下面的例子将每间隔 2 秒就会输出一条消息。5 秒之后,输出停止:```javascript // 每 2 秒重复一次 let timerId = setInterval(() => alert(‘tick’), 2000);

// 5 秒之后停止 setTimeout(() => { clearInterval(timerId); alert(‘stop’); }, 5000);

1、alert 弹窗显示的时候计时器依然在进行计时<br />(1)在大多数浏览器中,包括 Chrome 和 Firefox,在显示alert/confirm/prompt弹窗时,内部的定时器仍旧会继续“嘀嗒”。<br />(2)所以,在运行上面的代码时,如果在一定时间内没有关掉alert弹窗,那么在你关闭弹窗后,下一个alert会立即显示。两次alert之间的时间间隔将小于 2 秒。 |
| --- |

| 【示例】写轮播图时使用定时器setInterval造成的性能问题、解决方法<br />1、用clearInteral(timeid)来清除,但是往往不能马上停止,用什么方法比较好解决?<br />2、优化方案:```jsx
var timeout = false; //启动及关闭按钮  
function time() {  
  if(timeout) return;  
  Method();  
  setTimeout(time,100); //time是指本身,延时递归调用自己,100为间隔调用时间,单位毫秒  
}

3、总结:
(1)一般不用setInterval,而用setTimeout的延时递归来代替interval。
(2)setInterval会产生回调堆积,特别是时间很短的时候。 | | —- |

延时

零延时的 setTimeout / setInterval

一、这儿有一种特殊的用法:setTimeout(func, 0),或者仅仅是setTimeout(func)。
二、这样调度可以让func尽快执行。但是只有在当前正在执行的脚本执行完成后,调度程序才会调用它。
1、也就是说,该函数被调度在当前脚本执行完成“之后”立即执行。
【示例1】下面这段代码会先输出 “Hello”,然后立即输出 “World”:

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

alert("Hello");

(1)第一行代码“将调用安排到日程(calendar)0 毫秒处”。但是调度程序只有在当前脚本执行完毕时才会去“检查日程”,所以先输出”Hello”,然后才输出”World”。
三、此外,还有与浏览器相关的 0 延时 timeout 的高级用例,我们将在事件循环:微任务和宏任务一章中详细讲解。

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

一、在浏览器环境下,嵌套定时器的运行频率是受限制的。根据HTML5 标准所讲:“经过 5 重嵌套定时器之后,时间间隔被强制设定为至少 4 毫秒”。
1、简单来说,5层以上的定时器嵌套会导致至少4ms的延迟。
2、这个限制来自“远古时代”,并且许多脚本都依赖于此,所以这个机制也就存在至今。
3、对于服务端的 JavaScript,就没有这个限制,并且还有其他调度即时异步任务的方式。例如 Node.js 的setImmediate
二、【示例1】让我们用下面的示例来看看这到底是什么意思。其中setTimeout调用会以零延时重新调度自身的调用。每次调用都会在times数组中记录上一次调用的实际时间。那么真正的延迟是什么样的?让我们来看看:

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

setTimeout(function run() {
  times.push(Date.now() - start); // 保存前一个调用的延时

  if (start + 100 < Date.now()) alert(times); // 100 毫秒之后,显示延时信息
  else setTimeout(run); // 否则重新调度
});

// 输出示例:
// 1,1,1,1,9,15,20,24,30,35,40,45,50,55,59,64,70,75,80,85,90,95,100
// 也有可能是:2,3,4,8,12,17,22,27,31,37,42,46,51,57,61,66,71,76,81,85,91,95,100,105

1、第一次,定时器是立即执行的(正如规范里所描述的那样),接下来我们可以看到9, 15, 20, 24…。两次调用之间必须经过 4 毫秒以上的强制延时。(译注:这里作者没说清楚,timer 数组里存放的是每次定时器运行的时刻与 start 的差值,所以数字只会越来越大,实际上前后调用的延时是数组值的差值。示例中前几次都是 1,所以延时为 0)
2、如果我们使用setInterval而不是setTimeout,也会发生类似的情况:setInterval(f)会以零延时运行几次f,然后以 4 毫秒以上的强制延时运行。
【示例2】

let a = performance.now();
setTimeout(() => {
  let b = performance.now();
  console.log(b - a);
  setTimeout(() => {
    let c = performance.now();
    console.log(c - b);
    setTimeout(() => {
      let d = performance.now();
      console.log(d - c);
      setTimeout(() => {
        let e = performance.now();
        console.log(e - d);
        setTimeout(() => {
          let f = performance.now();
          console.log(f - e);
          setTimeout(() => {
            let g = performance.now();
            console.log(g - f);
          }, 0);
        }, 0);
      }, 0);
    }, 0);
  }, 0);
}, 0);

// 浏览器中打印结果大概如下
1.8999999985098839
1.7000000029802322
1.7999999970197678
2.399999998509884
5.600000001490116
5.899999998509884

1、和规范一致,第5次执行的时候延迟来到了4ms以上。

浏览器中实现0ms延时的定时器:postMessage

一、可以用postMessage来实现真正0延迟的定时器

(function () {
  var timeouts = [];
  var messageName = 'zero-timeout-message';

  // 保持 setTimeout 的形态,只接受单个函数的参数,延迟始终为 0。
  function setZeroTimeout(fn) {
    timeouts.push(fn);
    window.postMessage(messageName, '*');
  }

  function handleMessage(event) {
    if (event.source == window && event.data == messageName) {
      event.stopPropagation();
      if (timeouts.length > 0) {
        var fn = timeouts.shift();
        fn();
      }
    }
  }

  window.addEventListener('message', handleMessage, true);

  // 把 API 添加到 window 对象上
  window.setZeroTimeout = setZeroTimeout;
})();

1、由于postMessage的糊掉函数的执行时机和setTimeout类似,都属于宏任务,所以可以简单利用postMessage和addEventListener(‘message’)的消息通知组合,来实现模拟定时器的功能。
2、这样,执行时机类似,但是延迟更小的定时器就完成了。
3、结果

// 控制台打印出来的结果
0.3999999985098839
0.19999999552965164
0.20000000298023224
0.29999999701976776
0.10000000149011612
0.10000000149011612

(1)全部在0.1~0.3毫秒级别,而且不会随着嵌套层数的增多而增加延迟

测试

一、理论上来说,postMessage的实现没有被浏览器引擎限制速度,一定是比setTimeout要快的。

设计实现方法测试

一、分别用postMessage版定时器和传统定时器做一个递归执行计数函数的操作,看看同样计数到100分别需要花多少时间
二、实验代码

function runtest() {
  var output = document.getElementById('output');
  var outputText = document.createTextNode('');
  output.appendChild(outputText);
  function printOutput(line) {
    outputText.data += line + '\n';
  }

  var i = 0;
  var startTime = Date.now();
  // 通过递归 setZeroTimeout 达到 100 计数
  // 达到 100 后切换成 setTimeout 来实验
  function test1() {
    if (++i == 100) {
      var endTime = Date.now();
      printOutput(
        '100 iterations of setZeroTimeout took ' +
          (endTime - startTime) +
          ' milliseconds.'
      );
      i = 0;
      startTime = Date.now();
      setTimeout(test2, 0);
    } else {
      setZeroTimeout(test1);
    }
  }

  setZeroTimeout(test1);

  // 通过递归 setTimeout 达到 100 计数
  function test2() {
    if (++i == 100) {
      var endTime = Date.now();
      printOutput(
        '100 iterations of setTimeout(0) took ' +
          (endTime - startTime) +
          ' milliseconds.'
      );
    } else {
      setTimeout(test2, 0);
    }
  }
}

1、先通过setZeroTimeout,也就是postMessage版本来递归计数到100,然后切换成setTimeout计数到100
三、实验结果
1、差距不固定
(1)有的mac用无痕模式排除插件等因素的干扰后,以计数到100为例,大概有80~100倍的事件差距。
(2)在硬件更好的台式机上,甚至能到200倍以上
2、aSuncat-20210629,我的mac air上打印的结果

100 iterations of setZeroTimeout took 8 milliseconds. 
100 iterations of setTimeout(0) took 515 milliseconds.

Performance面板

一、打开Performance面板,看看更直观的可视化界面中,postMessage版的定时器和setTimeout版的定时器是如何分布的。以下是文章作者的图:
image.png

1、左边的postMessage版本的定时器分布非常密集,大概在5ms以内就执行完了所有的计数任务。
2、右边的setTimeout版本相比较下分布的就很稀疏了。通过上方的时间轴可以看出,前4次的执行间隔大概在1ms左右,到了第5次就拉开到4ms以上
3、以下是aSuncat-20210629:自己电脑上的(看得出来,我的电脑性能比作者的要差,哈哈)
image.png

场景 / 作用

一、以下是需要无延迟的定时器的场景:React源码

React源码:做时间切片

一、借用React Scheduler为什么使用MessageChannel实现这篇文章中的一段伪代码

const channel = new MessageChannel();
const port = channel.port2;

// 每次 port.postMessage() 调用就会添加一个宏任务
// 该宏任务为调用 scheduler.scheduleTask 方法
channel.port1.onmessage = scheduler.scheduleTask;

const scheduler = {
  scheduleTask() {
    // 挑选一个任务并执行
    const task = pickTask();
    const continuousTask = task();

    // 如果当前任务未完成,则在下个宏任务继续执行
    if (continuousTask) {
      port.postMessage(null);
    }
  },
};

二、React把任务切分成很多片段,这样就可以通过把任务交给postMessage的回调函数,来让浏览器主线程拿回控制权,进行一些更有限的渲染任务(比如用户输入)。
三、为什么不用执行时机更靠前的微任务呢?
答:关键的原因在于微任务会在渲染之前执行,这样就算浏览器有紧急的渲染任务,也得等微任务执行完才能渲染。

所有的调度方法都不能保证确切的延时

一、例如,浏览器内的计时器可能由于许多原因而变慢:

  • CPU 过载。
  • 浏览器页签处于后台模式。
  • 笔记本电脑用的是电池供电(译注:使用电池供电会以降低性能为代价提升续航)。

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

嵌套的 setTimeout

一、周期性调度有两种方式
1、一种是使用setInterval
2、另外一种就是嵌套的setTimeout,就像这样:

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

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

(1)上面这个setTimeout在当前这一次函数执行完时(*)立即调度下一次调用。
二、嵌套的setTimeout要比setInterval灵活得多。采用这种方式可以根据当前执行结果来调度下一次调用,因此下一次调用可以与当前这一次不同。
【示例1】我们要实现一个服务(server),每间隔 5 秒向服务器发送一个数据请求,但如果服务器过载了,那么就要降低请求频率,比如将间隔增加到 10、20、40 秒等。

let delay = 5000;

let timerId = setTimeout(function request() {
  ...发送请求...

  if (request failed due to server overload) {
    // 下一次执行的间隔是当前的 2 倍
    delay *= 2;
  }

  timerId = setTimeout(request, delay);

}, delay);

1、并且,如果我们调度的函数占用大量的 CPU,那么我们可以测量执行所需要花费的时间,并安排下次调用是应该提前还是推迟。
三、嵌套的setTimeout能够精确地设置两次执行之间的延时,而setInterval却不能。
四、下面来比较这两个代码片段。
1、第一个使用的是setInterval:

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

(1) 对setInterval而言,内部的调度程序会每间隔 100 毫秒执行一次func(i++):
image.png
(2)使用setInterval时,func函数的实际调用间隔要比代码中设定的时间间隔要短!
(3)这也是正常的,因为func的执行所花费的时间“消耗”了一部分间隔时间。
(3)也可能出现这种情况,就是func的执行所花费的时间比我们预期的时间更长,并且超出了 100 毫秒。
(4)在这种情况下,JavaScript 引擎会等待func执行完成,然后检查调度程序,如果时间到了,则立即再次执行它。
(5)极端情况下,如果函数每次执行时间都超过delay设置的时间,那么每次调用之间将完全没有停顿。
2、第二个使用的是嵌套的setTimeout:

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

(1)这是嵌套的setTimeout的示意图:
image.png
(2)嵌套的setTimeout就能确保延时的固定(这里是 100 毫秒)。
① 这是因为下一次调用是在前一次调用完成时再调度的。

垃圾回收和 setInterval/setTimeout 回调(callback)

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

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

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