最近在写【重拾前端】系列,下面有几个快速通道,大家自取
前言
事件循环这个事情,其实在我们的工作中或多或少都会碰到,可能我们只是没有去认认真真的理解他,了解他而已。今天我们一起把事件循环吃透。
单线程的JS
其实,事件循环就是对于单线程的JS应运而生的。
- 单线程?什么是线程,诶,我好像听过进程诶,他们两兄弟啥区别?
- 为什么js一定要单线程啊,我听说CPU不是有很多核吗?为什么不多线程?
线程和进程的爱恨纠葛
这里我推荐阮一峰老师的一篇文章
为什么JS是单线程?
这个要回到Js历史了,布兰登·艾奇(Brendan Eich)老哥用10天创造js。当时js用来干嘛,简单的浏览器交互,验证,操作一下dom是吧。那把它设计成那么复杂干什么,而且如果多线程的话,操作dom会出现麻烦的事情,假设一个线程读取DOM节点数据的同时,另一个线程把那个DOM节点删了,呵呵。所以js一个线程就够了,也就是一步一步顺序运行下来。
浏览器中的消息队列和事件循环
这里暂时只说浏览器中的循环事件循环,有关node的话可能有些细微的差别,不过底层的原理都是差不多的。
注意
这里主要是从一个设计者的角度来模拟,从零构建浏览器中的时间循环。为了能让你更加深刻地理解事件循环机制,我们就从最简单的场景来分析,然后带你一步步了解浏览器页面主线程是如何运作的。
开始
事先约定好的执行顺序
假如我有以下几个任务
1 | var num1 = 1+2; // 任务 1 |
如果让我来设计,我就会有一个主线程,然后把他们按顺序排进去,然后顺序执行。
1 | function mainThread(){ |
😯,我们已经设计了最简单的线程啦。
线程运行中,处理突发事件
很多时候,所有的任务不是之前就统一安排好的,比如⌨️的输入,🖱的点击等等。
如果想要在线程运行中可以很好的处理这些事件。就需要
====>事件循环。这里我们用while来实现简单的事件循环
1 | function awaitt() { |
这样改版之后有了以下几点改进:
- 引入了循环机制
- 引入事件,然后整个线程在运行的过程中,活了起来,不再是死的,运行完就滚蛋了的那种,有了交互了
(🤫最简单的实现。。。别吐槽代码,都是最简单的实现和最简单的场景,助于理解而已。。。)
处理其他线程的任务
上面的版本用了事件循环的方式来获取内部的事件,但是对于外部突发情况的事件是无法解决的。所以,我们需要升级~
那么怎么设计好一个线程模型呢?我们换个角度想想思路马上就出来了。
这些外部的任务,都是有先后顺序的,哪怕是都是突发情况。他们也是有先后顺序。
所以一个比较通用的模式就是
======> 消息队列
消息队列是一种数据结构,可以存放要执行的任务。它符合队列“先进先出”的特点,也就是说要添加任务的话,添加到队列的尾部;要取出任务的话,从队列头部去取。
我们要完成以下几个操作:
- 添加一个消息队列;
- IO 线程中产生的新任务添加进消息队列尾部;
- 渲染主线程会循环地从消息队列头部中读取任务,执行任务。
1 | const arrTask = [] |
我们用一个数组来模拟一个队列。
既基础了内部的任务,也处理了外部突发的任务
有任务进来, 就会执行任务。
退出主线程
当页面主线程执行完成之后,又该如何保证页面主线程能够安全退出呢?Chrome 是这样解决的,确定要退出当前页面时,页面主线程会设置一个退出标志的变量,在每次执行完一个任务时,判断是否有设置退出标志。
1 | const arrTask = [] |
单线程的缺点
通过上面的介绍,你应该清楚了,页面线程所有执行的任务都来自于消息队列。消息队列是“先进先出”的属性,也就是说放入队列中的任务,需要等待前面的任务被执行完,才会被执行。鉴于这个属性,就有如下两个问题需要解决。
如何处理高优先级的任务
场景:
假如,我现在有一大串的任务在主线程上执行。
我的dom节点变化的时候,我需要发送请求或者提示一个alert
之类的业务逻辑要执行。
现在可行的有两种方案:
- dom节点变化的话,我就停下现在正在运行的主线程上的任务,然后调用我需要执行的业务逻辑。这样确实具有实效性,但是!这样会使得当前任务的效率极度降低,比如我的dom节点变化200次,那岂不是当前的任务特别久了吗?
- 第二个方法就可以解决上述的问题:一旦dom节点有变化,哪怕是200000次变化,我都是将业务逻辑push到队列的尾部,这样就不会影响当前任务进行以及效率了。但是!这样的话我的实效性就莫得了。就莫得了。
总结一下:其实就是实效性和任务效率的权衡问题罢了。
江江江~
微任务就应运而生啦。
加入我们把任务队列里面的每个任务都称作宏任务的话,每个宏任务里面都会包含一个微任务队列。每个宏任务结束之后,都回去清理一遍微任务队列里面的队列,这样的话既保护了实效性,也保护了任务的效率。
如何处理调用时间过长的问题
我们都知道js是单线程的。如果现在在执行一个任务的情况下。其他任务就是要等待的。那么就会出现某个任务计算的周期特别长,导致别人都在等待。
👌,我们来验证一下
1 | while(true){} |
下面的alter
永远都不会执行了。因为while
在工作,alter
在等他把资源让出来。
假如说,我现在有一个动画要进行,但是运行完一帧之后,有一个极其复杂的计算,导致了我的动画不流畅,那么用户可能就会非常的烦。这不是我们希望看到的。
所以js有一个回调机制,到了特定的时间点了,回调一下,把动画的下一帧执行一下,然后继续当前的复杂计算。
总结
- 如果有一些确定好的任务,可以使用一个单线程来按照顺序处理这些任务,这是第一版线程模型。
- 要在线程执行过程中接收并处理新的任务,就需要引入循环语句和事件系统,这是第二版线程模型。
- 如果要接收其他线程发送过来的任务,就需要引入消息队列,这是第三版线程模型。
- 如果其他进程想要发送任务给页面主线程,那么先通过 IPC 把任务发送给渲染进程的 IO 线程,IO 线程再把任务发送给页面主线程。
- 消息队列机制并不是太灵活,为了适应效率和实时性,引入了微任务
基于消息队列的设计是目前使用最广的消息架构,无论是安卓还是 Chrome 都采用了类似的任务机制,所以理解了本篇文章的内容后,你再理解其他项目的任务机制也会比较轻松。
浏览器是怎么实现setTimeout
要想知道浏览器是怎么实现的,我们先回顾一下,我们之前设计的那个事件循环系统:
有xx任务了,ok,我push
到任务队列。以此循环。
所以我们写在setTimeout
里面的函数执行其实也是一个任务,也是需要push到任务队列去的。
但是,我们的这个是其实是一个异步的函数,不能直接将整个函数push到队列中,否则的话我们的实效性就很有可能出错了,比如我希望他在24小时之后alter
告诉我已经到了第二天了。他如果是直接push到任务队列,没有到第二天就会告诉我第二天到了,这不是让用户匪夷所思嘛。
所以肯定不是直接push
的。那又是怎么弄得嘞?
你也可以思考下,如果让你在消息循环系统的基础之上加上定时器的功能,你会如何设计?
在 Chrome 中除了正常使用的消息队列之外,还有另外一个消息队列,这个队列中维护了需要延迟执行的任务列表,包括了定时器和 Chromium 内部一些需要延迟执行的任务。所以当通过 JavaScript 创建一个定时器时,渲染进程会将该定时器的回调任务添加到延迟队列中
我们把之前的事件循环模拟改一下
1 | const mainTask = [ |
所以这里我们会看到一个问题,那就是其实你到点了可能要不会立刻执行,必须等到之前的任务执行完了才轮到延迟队列的查询和执行。
我们代码中有一个彩蛋—-> ID
这个ID有什么用?
就是用于clearTimeout
直接传入ID,然后循环找到他,就可以直接从队列里面删掉就好了。
这里就不需要贴代码了吧?
冷知识
- 如果 setTimeout 存在嵌套调用,那么系统会设置最短时间间隔为 4 毫秒
- 未激活的页面,setTimeout 执行最小间隔是 1000 毫秒
- 除了前面的 4 毫秒延迟,还有一个很容易被忽略的地方,那就是未被激活的页面中定时器最小值大于 1000 毫秒,也就是说,如果标签不是当前的激活标签,那么定时器最小的时间间隔是 1000 毫秒,目的是为了优化后台页面的加载损耗以及降低耗电量。这一点你在使用定时器的时候要注意。
- 延时执行时间有最大值
- 除了要了解定时器的回调函数时间比实际设定值要延后之外,还有一点需要注意下,那就是 Chrome、Safari、Firefox 都是以 32 个 bit 来存储延时值的,32bit 最大只能存放的数字是 2147483647 毫秒,这就意味着,如果 setTimeout 设置的延迟值大于 2147483647 毫秒(大约 24.8 天)时就会溢出,这导致定时器会被立即执行。
- 使用 setTimeout 设置的回调函数中的 this 不符合直觉
- 这里可以看我之前的文章,有对this进行攻破
总结
- 首先,为了支持定时器的实现,浏览器增加了延时队列。
- 其次,由于消息队列排队和一些系统级别的限制,通过 setTimeout 设置的回调任务并非总是可以实时地被执行,这样就不能满足一些实时性要求较高的需求了。
宏任务和微任务
其实通过之前的学习,我们对事件循环已经了解的差不多了,这里主要是更详细了解一下他们。
宏任务
宏任务有哪些?
除了微任务的都是宏任务。。。
那么微任务有哪些?
Process.nextTick
、Promise.then catch finally
(注意我不是说 Promise)、MutationObserver
。
在之前的学习我们知道在宏任务结束之后,会去执行他自己当前的微任务队列。在这个微任务队列执行完成之后再去执行下一个宏任务。
微任务
异步函数有两种方式:
第一种是把异步回调函数封装成一个宏任务,添加到消息队列尾部,当循环系统执行到该任务的时候执行回调函数。这种比较好理解,我们前面介绍的 setTimeout 和 XMLHttpRequest 的回调函数都是通过这种方式来实现的。
第二种方式的执行时机是在主函数执行结束之后、当前宏任务结束之前执行回调函数,这通常都是以微任务形式体现的。
- 微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列。
- 微任务的执行时长会影响到当前宏任务的时长。比如一个宏任务在执行过程中,产生了 100 个微任务,执行每个微任务的时间是 10 毫秒,那么执行这 100 个微任务的时间就是 1000 毫秒,也可以说这 100 个微任务让宏任务的执行时间延长了 1000 毫秒。所以-你在写代码的时候一定要注意控制微任务的执行时长。
- 在一个宏任务中,分别创建一个用于回调的宏任务和微任务,无论什么情况下,微任务都早于宏任务执行。
期末考试
1 | console.log('script start') |
大家可以自己在浏览器里面试试吧。