本文正在参加「金石计划」
前言
之前也写过一篇关于JS事件循环机制的文章,但总感觉有些地方表述不大清晰,最近看了下袁老师大师课,结合自己的理解又重新梳理了下,希望对这方面有疑惑的同学能有所帮助
浏览器的进程模型
为什么讲事件循环要提到进程模型?
因为只有清楚了解了浏览器的进程模型,你才能够清楚的知道事件循环机制是发生在浏览器的哪个位置
何为进程
程序运行需要有它自己专属的内存空间,可以把这块内存空间简单的理解为进程
何为线程
有了进程之后,就可以运行代码了
运行代码的"人",称之为"线程"
一个进程至少有一个线程,在进程开启后会自动创建一个线程来运行代码,该线程称之为主线程
如果一个程序需要同时执行多块代码,主线程就会启动更多的线程来执行代码,所以一个进程中可以包含多个线程(只有一个主线程)
浏览器有哪些进程和线程
浏览器是一个多进程多线程的应用程序
浏览器的内部工作非常复杂,为了避免相互影响,启动浏览器后,会启动多个进程同时工作
这里画一个简化的浏览器进程模型,包含
- 浏览器进程
主要负责界面显示,用户交互,子进程管理等
- 网络进程
负责加载网络资源
- 渲染进程(重点)
渲染进程启动后,会开启一个渲染主线程,主线程负责执行HTML,CSS,JS代码
默认情况下,浏览器会为每个标签开启一个新的渲染进程,来保证不同标签页间互不影响(也是现在浏览器吃内存的原因之一)
渲染主线程是如何工作的
前面说过,每个进程至少都有一个主线程在负责执行任务
这里我们着重关注渲染进程的渲染主线程是如何工作的
它要处理的任务包括但不限于
- 解析 html
- 解析 CSS
- 计算样式
- 布局
- 绘制界面
- 执行全局 js 代码
- 执行事件处理函数
- 执行定时器回调函数
- 等等。。。。。
那么这么多的任务,浏览器是如何有序的工作呢?
答案就是排队
我们先把渲染主线程的工作在做进一步的简化理解
所有的 JS 代码都由渲染主线程来负责执行,其它子线程负责把要执行的JS代码(一般是我们的回调函数)打包成任务,送去排队
为什么这里要着重强调其它线程,以往大部分资料都简单粗暴的把JS代码执行过程中生成的宏任务直接丢到事件队列中去,这是不对的,少了对应子线程处理任务的环节,会导致一些现象理解起来有问题
常见的就是定时器
setTimeout(() => {
// fn1
console.log(1)
}, 2000)
setTimeout(() => {
// fn2
console.log(2)
}, 1000)
// 2, 1
如果执行阶段就把宏任务的回调函数(fn1, fn2)丢到执行队列排队,那么先执行先排队,预期结果就会是先输出1,在输出2
但实际上这个回调任务是交给了其它线程(定时器线程)处理
- 该线程会在1秒后把fn2丢到任务队列里排队,等待主线程执行,
- 在2秒后把fn1丢到任务队列里排队,等待主线程执行
- 要这么理解整个过程,才会得出先2后1的结果
通过渲染线程的工作理解事件循环
- 在最开始的时候,渲染主线程会进入一个无限循环的过程
- 每一次循环都会检查消息(任务)队列中是否有任务存在,如果有,取出第一个任务执行,执行完了进入下一次循环
- 其它所有线程,都可以随时向消息队列添加任务,新的任务会追加在任务队列的末尾
上面整个过程,称为事件循环
看到这里,应该跟大家平时接触到的事件循环的理解就比较接近了,但是还没完,真正的重点开始了,就是为什么我们在聊事件循环的时候,会扯上异步呢?
何为异步?
代码执行过程中,会遇到一些无法立即处理的任务,比如:
- 计时器完成后需要执行的任务 ——
setTimeout - 网络请求后需要执行的任务 ——
XHR,fetch - 用户操作后需要执行的任务 ——
addEventListener - ...
如果让渲染主线程等待这些任务的时机到达,就会导致主线程长期处于阻塞状态,从而导致浏览器卡死,如下:
渲染主线程承担着极其重要的工作,无论如何都不能阻塞!
因此,浏览器选择使用异步的方式,来解决这个问题
这样,主线程就只要按顺序一个一个执行任务就好,实际上它在执行任务的时候甚至都不用知道哪些是异步任务,它也不需要关心这些
这里可能有同学会疑问,说如果这样子的话那计时器是不是就很有可能不准了?
答案是是的,计时器在这种模式下就会产生误差
这里提供一段代码辅助大家理解
// 一段立即执行函数,但是里面有一个时长 5 秒的死循环
function waiting(time) {
let curTime = new Date()
while (curTime - time < 5000) {
curTime = new Date()
}
}
// 打印当前时间
console.log(new Date());
// 添加一个 1 秒后打印时间的计时器
setTimeout(() => {
console.log('setTimeout:' + new Date())
}, 1000)
// 执行上面的延迟函数
waiting(new Date())
大家觉得这种情况下计时器能否按时在 1 秒后打印出结果呢?
答案是在 5 秒后,因为渲染主线程执行的代码里有一个 5 秒的循环
即使 1 秒后定时器线程会把打印当前时间的任务推到事件队列中去
但是此时渲染主线程还在忙,只有当渲染主线程的代码执行完毕后,它才会去事件队列中拿取第一个任务进行执行
好了,了解浏览器异步的实现,我们来看一道常见的题目
如何理解 JS 的异步?
逻辑线是这样的:
因为JS是单线程,所以用同步策略会导致阻塞
为了解决阻塞问题,浏览器采用了异步的方式来避免
那异步是怎么做的呢,主线程将异步的任务交给其他线程处理
JS 是一门单线程的语言,这是因为它运行在浏览器的渲染主线程中,而渲染主线程只有一个
渲染主线程负责诸多工作,渲染页面,执行 JS 都在其中
如果使用同步的方式,就极有可能导致主线程产生阻塞,从而导致任务队列中的其它任务无法得到执行
这样一来,繁忙的主线程白白的消耗时间,另一方面导致页面无法及时更新,给用户造成卡死现象
所以浏览器用异步的方式来避免
具体做法是当某些任务发生时,比如计时器,网络,事件监听等,主线程会将任务交给其他线程去处理,自身立即结束任务的执行,转而执行后续的代码。当其他线程完成时,将事先传递的回调函数包装成任务,加入到任务队列末尾排队,等待主线程的调度执行
在这种异步模式下,浏览器最大限度的保证了单线程的流畅运行
JS为何会阻塞渲染?
这个问题也很常见,一般会提到避免在 js 代码执行过程中操作了 Dom 或者样式,为了避免在页面布局样式还未稳定时就进行渲染,导致后续改变后需要重复渲染的问题
这个说法也对,但今天我们尝试从事件循环的角度来理解这个问题
同样先来看一段代码
<body>
<h1>修改前</h1>
<button>修改</button>
<script>
function waiting(duration) {
let start = Date.now()
while (Date.now() - start < duration) { }
}
let h1 = document.querySelector('h1')
let button = document.querySelector('button')
button.addEventListener('click', () => {
console.log(Date.now())
h1.textContent = '修改后'
waiting(5000)
console.log(Date.now())
})
</script>
</body>
简单解释下,就是界面有一段文本,我们用一个按钮添加一个点击修改文本的事件
在事件中我们加了一段5秒的死循环
按代码顺序来看,会是先执行修改文本,更新界面,然后进入死循环,输出时间
但实际是
会进入一段 5 秒等待,然后看到文本被修改
这里主要是想说明,渲染主线程它执行代码和渲染界面的工作是互斥的
js 在点击的时候有没有触发更新文本的动作,有
但是界面有没有渲染更新,还没有
但是会产生一个新的任务(绘制),当排到绘制任务执行完时,界面就更新了
任务有优先级么?
看到这里,有的同学可能开始着急了,因为文章都快完了都还没有提到我们平时最常提到的微任务
我知道你很急,但你先别急,微任务的产生,实际上是为了完善任务的优先级
为什么这么说?
因为任务本身是没有优先级的概念一说,它遵循最简单的准则,先进先出
但是,消息队列是有优先级的!
浏览器通过把任务放到不同优先级的队列中,来实现不同类型任务的先后执行
而其中,我们常说的微任务队列,就是那个优先级最高的消息队列
在目前的浏览器实现中,常见的队列有下面这些:
- 延迟队列:用于存放计时器到达后的回调任务(优先级 —— 中)
- 交互队列:用于存放用户操作后产生的事件回调任务(优先级 —— 高)
- 微队列:用于存放需要最快执行的任务(优先级 —— 最高)
关于微任务队列,最常见的就是我们可以通过 Promise 往里面添加任务,这里就不展开了
这个时候,我们就可以把完整的任务执行模型画出来了
看到这里,基本上浏览器的事件循环机制就都清晰了,以后遇到一些关于异步任务输出先后顺序的题,按照这个思路去分析,绝对是稳的
常见问题
阐述一下 JS 的事件循环机制
事件循环又叫消息循环,是浏览器渲染主线程的工作方式
主线程会开启一个循环,每次循环从消息队列中取出第一个任务执行,而其他线程只需要在合适的时候将任务加入到队列的末尾即可
过去把消息队列简单的分为宏任务队列和微任务队列,这种说法目前已经不适用于现代浏览器的工作模式
根据 W3C 的标准,每个任务有不同的类型,同类型的任务会在同一个队列中,并且不同的队列会有不同的优先级,其中,微任务队列的优先级必须是最高的
JS 的计时器能做到精确计时吗
不能
- 计算机硬件层面本身就不支持精确计时
- 调用操作系统的时间方法本身就会存在延迟
- 按照 W3C 标准,多层计时器嵌套(5层),会带来 4 毫秒的误差
- 最重要的,受事件循环的影响,有时候这个误差会更加不可控
总结
最后有一段话可以好好琢磨一下
- 单线程是异步产生的原因(因为单线程有问题需要用异步来解决)
- 事件循环是异步的实现方式(浏览器通过事件循环这个模式,实现了任务的异步执行)
最后
发个求职信息,今年刚从杭州转到深圳,目前咸鱼中,有内推坑位的欢迎私信滴滴^_^