页面的正常运转,是基于消息队列事件循环系统。

消息队列:

事件循环系统:通过事件,调用一个个的任务。

通过 setTimeout 和 XMLHttpRequest 这两个 WebAPI 来介绍事件循环的应用。

setTimeout:
就是一个定时器,用来指定某个函数在多少毫秒之后执行。它会返回一个整数,表示定时器的编号,同时你还可以通过该编号来取消这个定时器。

浏览器怎么实现 setTimeout

要了解定时器的工作原理,就得先来回顾下之前讲的事件循环系统。
我们知道渲染进程中,所有运行在主线程上的任务都需要先添加到消息队列,然后事件循环系统再按照顺序执行消息队列中的任务

下面我们来看看那些典型的事件:

  • 当接收到 HTML 文档数据,渲染引擎就会将“解析 DOM”事件添加到消息队列中,
  • 当用户改变了 Web 页面的窗口大小,渲染引擎就会将“重新布局”的事件添加到消息队列中。
  • 当触发了 JavaScript 引擎垃圾回收机制,渲染引擎会将“垃圾回收”任务添加到消息队列中。
  • 同样,如果要执行一段异步 JavaScript 代码,也是需要将执行任务添加到消息队列中。

以上列举的只是一小部分事件,这些事件被添加到消息队列之后,事件循环系统就会按照消息队列中的顺序来执行事件。

所以说要执行一段异步任务,需要先将任务添加到消息队列中

不过通过定时器设置回调函数有点特别,它们需要在指定的时间间隔内被调用,但消息队列中的任务是按照顺序执行的,所以为了保证回调函数能在指定时间内执行,你不能将定时器的回调函数直接添加到消息队列中。

那么该怎么设计才能让定时器设置的回调事件在规定时间内被执行呢?

你也可以思考下,如果让你在消息循环系统的基础之上加上定时器的功能,你会如何设计?
在 Chrome 中除了正常使用的消息队列之外,还有另外一个消息队列,这个队列中维护了需要延迟执行的任务列表,包括了定时器和 Chromium 内部一些需要延迟执行的任务。

所以当通过 JavaScript 创建一个定时器时,渲染进程会将该定时器的回调任务添加到延迟队列中。
如果感兴趣,你可以参考Chromium 中关于队列部分的源码。
源码中延迟执行队列的定义如下所示:

  1. DelayedIncomingQueue delayed_incoming_queue;

当通过 JavaScript 调用 setTimeout 设置回调函数的时候,渲染进程将会创建一个回调任务,包含了回调函数 showName、当前发起时间、延迟执行时间,其模拟代码如下所示:

  1. struct DelayTask{
  2. int64 id
  3. CallBackFunction cbf;
  4. int start_time;
  5. int delay_time;
  6. };
  7. DelayTask timerTask;
  8. timerTask.cbf = showName;
  9. timerTask.start_time = getCurrentTime(); //获取当前时间
  10. timerTask.delay_time = 200;//设置延迟执行时间

创建好回调任务之后,再将该任务添加到延迟执行队列中,代码如下所示:

  1. delayed_incoming_queue.push(timerTask);

现在通过定时器发起的任务就被保存到延迟队列中了,那接下来我们再来看看消息循环系统是怎么触发延迟队列的
我们可以来完善上一篇文章中消息循环的代码,在其中加入执行延迟队列的代码,如下所示:

  1. void ProcessTimerTask(){
  2. //从delayed_incoming_queue中取出已经到期的定时器任务
  3. //依次执行这些任务
  4. }
  5. TaskQueue task_queue
  6. void ProcessTask();
  7. bool keep_running = true;
  8. void MainTherad(){
  9. for(;;){
  10. //执行消息队列中的任务
  11. Task task = task_queue.takeTask();
  12. ProcessTask(task);
  13. //执行延迟队列中的任务
  14. ProcessDelayTask()
  15. if(!keep_running) //如果设置了退出标志,那么直接退出线程循环
  16. break;
  17. }
  18. }

从上面代码可以看出来,我们添加了一个 ProcessDelayTask 函数,该函数是专门用来处理延迟执行任务的。

这里我们要重点关注它的执行时机,在上段代码中,处理完消息队列中的一个任务之后,就开始执行 ProcessDelayTask 函数:
ProcessDelayTask 函数会根据发起时间和延迟时间计算出到期的任务,然后依次执行这些到期的任务

等到期的任务执行完成之后,再继续下一个循环过程。
通过这样的方式,一个完整的定时器就实现了。

设置一个定时器,JavaScript 引擎会返回一个定时器的 ID。那通常情况下,当一个定时器的任务还没有被执行的时候,也是可以取消的,具体方法是调用 clearTimeout 函数,并传入需要取消的定时器的 ID。如下面代码所示:
clearTimeout(timer_id)

其实浏览器内部实现取消定时器的操作也是非常简单的,就是直接从 delayed_incoming_queue 延迟队列中,通过 ID 查找到对应的任务,然后再将其从队列中删除掉就可以了。

补充

关于延迟消息队列的一点补充

Q:我没有太理解这个异步延迟队列,既然是队列,但好像完全不符合先进先出的特点。在每次执行完任务队列中的一个任务之后都会去执行那些已经到期的延迟任务,这些延迟的任务具体是如何取出的呢。

A: 我文章说是队列,为了和消息队列统一起来,不然表述起来有点拗口。
其实是一个hashmap结构等到执行这个结构的时候,会计算hashmap中的每个任务是否到期了,到期了就去执行,直到所有到期的任务都执行结束,才会进入下一轮循环

Q:老师,您好,延迟队列和消息队列是什么关系,怎么配合工作的?

A: 延迟消息队列主要是放一些定时执行的任务,如JavaScript设置定时器的回调,还有浏览器内部的一些定时回调任务! 这类任务需要等到指定时间间隔之后才会被执行!

而正常的消息队列中的任务只会按照顺序执行,执行完上个任务接着执行下个任务,不需要关系时间间隔!

Q:1.执行延迟队列的任务,是一次循环只取出一个,还是检查只要时间到了,就执行?
2.微任务是在宏任务里的,是执行完一个宏任务,就去执行宏任务里面的微任务?

A: 比如有五个定时的任务到期了,那么会分别把这个五个定时器的任务执行掉,再开始下次循环过程

chromium中,当执行一个宏任务时,才会创建微任务队列,等遇到checkpoint时就会执行微任务!

Q:为什么有些文章说渲染进程中有一个定时器线程用来计时的 到时间后会把回调函数塞到消息队列 而没有提到延迟队列这个说法 求老师解答?

老师没有回答。
我的理解:
一个东西得不同解释,感觉作者为了讲得通俗易懂些,和其它得解释确实有点出入,不过并不影响我们学习。
老师讲的延迟队列,就是别人说的定时器线程:
定时器线程:计时,到点就把任务(回调函数)取出来,塞到主线程中去执行;
延迟队列:当上一个任务执行完毕时,检查队列中是否有到点的任务,有的话就全部执行完后,再进入下一次循环。

其他回答:
评论好多说延迟队列得,其实就是一个定时器线程吧,定时器线程负责计时,到点了就把回掉push到消息队列中。

延迟消息队列中如果有多个任务都到期了,是按照加入的顺序执行,还是按照谁先到期了谁先执行

代码:

  1. setTimeout(() => {
  2. console.log(1);
  3. }, 20);
  4. setTimeout(() => {
  5. console.log(3);
  6. }, 10);
  7. // 大约执行79ms
  8. for (let i = 0; i < 90000000; i++) {
  9. // do soming
  10. }

那么会输出多少呢?

  1. 3
  2. 1

解析:
延迟消息队列并不是一般意义上的“先进先出”的队列,而是一个hashmap结构的队列
内部会对每个任务进行计算,计算出谁先到了,谁就先执行。
所以10ms的任务先到,先打印3。

关于多个消息队列

Q:、之前讲过,在循环系统的一个循环中,先从消息队列头部取出一个任务执行,该任务执行完后,再去延迟队列中找到所有的过期任务依次执行完。那前面这句话和本篇文章的这句话好像有矛盾:”先从多个消息队列中选出一个最老的任务,这个任务称为 oldestTask”。
A:第一段话是WHATWG标准定义的,在WHATWG规范,定义了在主线程的循环系统中,可以有多个消息队列,比如鼠标事件的队列,IO完成消息队列,渲染任务队列,并且可以给这些消息队列排优先级。

但是在浏览器实现的过程中,目前只有一个消息队列,和一个延迟执行队列。
一个是规范,一个是实现。

一句话,标准定义了很多队列,而浏览器只实现了一个普通队列和一个延时队列!

关于raF

Q:requestAnimationFrame 提供一个原生的API去执行动画的效果,它会在一帧(一般是16ms)间隔内根据选择浏览器情况去执行相关动作。

setTimeout是在特定的时间间隔去执行任务,不到时间间隔不会去执行,这样浏览器就没有办法去自动优化。

今日得到
浏览器的页面是通过消息队列和事件循环系统来驱动的。settimeout的函数会被加入到延迟消息队列中,
等到执行完Task任务之后就会执行延迟队列中的任务。然后分析几种场景下面的setimeout的执行方式。

作者回复: 回答的很棒,raf是按照系统刷新的节奏调用的!

问题

Q:老师:请教下,下面这段代码每次执行结果不同?能分析下什么原因吗?

setTimeout(() =>{
console.log(2)
setTimeout(() => {
console.log(3)
}, 99)
}, 100)
setTimeout(() => {
console.log(1)
}, 200);

我自己测试,大部分情况下是:2 1 3,也有2 3 1的情况。

因为定时器本来就是不准的,看起来,99比200ms短,应该先到点;但是如果在定时器的延迟消息队列执行之前,系统又插入了一些系统级的任务,占用了超过了200ms的时间,那么

如果把三个定时器的时间间隔大一些,应该就是相对固定的答案了
比如
setTimeout(() =>{
console.log(2)
setTimeout(() => {
console.log(3)
}, 99)
}, 1000)
setTimeout(() => {
console.log(1)
}, 2000;

使用 setTimeout 的一些注意事项

使用定时器过程中存在的那些陷阱。
1. 如果当前任务执行时间过久,会影响定时器任务的执行
在使用 setTimeout 的时候,有很多因素会导致回调函数执行比设定的预期值要久,其中一个就是当前任务执行时间过久从而导致定时器设置的任务被延后执行。

我们先看下面这段代码:

  1. function bar() {
  2. console.log('bar')
  3. }
  4. setTimeout(bar, 0);
  5. for (let i = 0; i < 5000; i++) {
  6. let i = 5+8+8+8
  7. console.log(i)
  8. }

执行 foo 函数的时候使用 setTimeout 设置了一个 0 延时的回调任务,设置好回调任务后,foo 函数会继续执行 5000 次 for 循环。

通过 setTimeout 设置的回调任务被放入了消息队列中并且等待下一次执行,这里并不是立即执行的;

要执行消息队列中的下个任务,需要等待当前的任务执行完成,由于当前这段代码要执行 5000 次的 for 循环,所以当前这个任务的执行时间会比较久一点。这势必会影响到下个任务的执行时间。

  1. 如果 setTimeout 存在嵌套调用,那么系统会设置最短时间间隔为 4 毫秒
    也就是说在定时器函数里面嵌套调用定时器,也会延长定时器的执行时间,可以先看下面的这段代码: ```javascript function cb() { setTimeout(cb, 0); }

setTimeout(cb, 0);

  1. Chrome 中,定时器被嵌套调用 5 次以上,系统会判断该函数方法被阻塞了,如果定时器的调用时间间隔小于 4 毫秒,那么浏览器会将每次调用的时间间隔设置为 4 毫秒。
  2. 3. 未激活的页面,setTimeout 执行最小间隔是 1000 毫秒
  3. 除了前面的 4 毫秒延迟,还有一个很容易被忽略的地方,那就是未被激活的页面中定时器最小值大于 1000 毫秒,也就是说,如果标签不是当前的激活标签,那么定时器最小的时间间隔是 1000 毫秒,目的是为了优化后台页面的加载损耗以及降低耗电量。<br />这一点你在使用定时器的时候要注意。
  4. 4. 延时执行时间有最大值<br />除了要了解定时器的**回调函数时间比实际设定值要延后**之外,还有一点需要注意下,那就是** ChromeSafariFirefox 都是以 32 bit 来存储延时值**的,32bit 最大只能存放的数字是 **2147483647 毫秒**。<br />这就意味着,如果 setTimeout 设置的**延迟值大于 2147483647 毫秒(大约 24.8 天)时就会溢出,那么相当于延时值被设置为 0 了,这导致定时器会被立即执行**。<br />你可以运行下面这段代码:
  5. ```javascript
  6. function showName(){
  7. console.log("极客时间")
  8. }
  9. var timerID = setTimeout(showName,2147483648);//会被理解调用执行

运行后可以看到,这段代码是立即被执行的。但如果将延时值修改为小于 2147483647 毫秒的某个值,那么执行时就没有问题了。

  1. 使用 setTimeout 设置的回调函数中的 this 不符合直觉
    如果被 setTimeout 推迟执行的回调函数是某个对象的方法,那么该方法中的 this 关键字将指向全局环境,而不是定义时所在的那个对象。这点在前面介绍 this 的时候也提过.

总结

首先,为了支持定时器的实现,浏览器增加了延时队列

其次,由于消息队列排队和一些系统级别的限制,通过 setTimeout 设置的回调任务并非总是可以实时地被执行,这样就不能满足一些实时性要求较高的需求了。

最后,在定时器中使用过程中,还存在一些陷阱,需要你多加注意。

通过分析和讲解,你会发现函数 setTimeout 在时效性上面有很多先天的不足,所以对于一些时间精度要求比较高的需求,应该有针对性地采取一些其他的方案。

思考时间

由于使用 setTimeout 设置的回调任务实时性并不是太好,所以很多场景并不适合使用 setTimeout。比如你要用 JavaScript 来实现动画效果,函数 requestAnimationFrame 就是个很好的选择。

那么今天留给你的作业是:你需要网上搜索了解下 requestAnimationFrame 的工作机制,并对比 setTimeout,然后分析出 requestAnimationFrame 实现的动画效果比 setTimeout 好的原因

回答:
requestAnimationFrame自动根据屏幕刷新频率去执行回调任务,能保证和人眼一样的频率。
而setTimeout由于受其他异步任务的影响,设定的时间不一定就是执行的时间。