浏览器的事件循环反复学了好几次,但是时间一长还是会忘,所以打算写个学习记录,加深记忆,也方便以后复习的时候能快速捡起来。
浏览器的进程模型
进程和线程
这里不深入操作系统,只是简单理解一些基础概念
- 程序运行需要内存空间,简单理解这块内存空间就是进程
- 每个行用至少有一个进程,进程之间可以互相通信,进程之间相互隔离
- 进程通过线程来运行代码,进程开启后就会自动创建一个线程来运行代码,称为主线程
- 一个进程可以包含多个线程,因此可以运行多个代码
浏览器的进程和线程
浏览器是一个多进程多线程的应用程序
- 浏览器进程
- 负责界面展示、用户交互、管理子进程等
- 网络进程
- 加载网络资源
- 渲染进程
- 渲染进程开启后会主动开启一个渲染主线程,主线程负责执行html,css,js.默认情况下每个标签页会单独开启一个渲染主线程保证页面之间不会互相影响
通过浏览器的【更多工具】->【任务管理器】可以看到一些浏览器的进程
渲染主线程的工作流程
渲染主线程需要做的事包括但不限于:解析html、解析css、计算样式布局、处理图层,每秒绘制60帧画面、执行全局js,执行时间回调函数... 这么多的任务,如何处理任务调度呢?
- 渲染主线程一开始就会进入一个无限循环
- 每次循环会检查消息队列(或称:事件队列)是否有任务存在,如果有就取出第一个任务执行,然后进入下一次循环,如果没有就进入休眠
- 其他所有线程都可以向消息队列里添加新任务,新任务会放在消息队列的末尾,如果主线程师休眠,就会被唤醒进入循环执行消息队列的任务
上面这个过程,就被称为事件循环。
什么是异步
浏览器执行JS代码过程中会遇到一些无法立即执行的任务,例如:
- 计时器任务 setTimeout、setInterval
- 网络通信后执行的任务 XHR、Fetch
- 用户交互后的任务 addEventListener、onclick
// async
setTimeout(() => {
alert("world");
}, 1000);
console.log("hello");
js是一门单线程语言
如果渲染主线程一直等待这些任务执行时机到达,那主线程就会长期被阻塞,浏览器也就卡死了。因此渲染主线程采用了异步的方式解决阻塞,以setTimeout为例,实际的执行流程如下图,主线程遇到计时器代码,会把任务交给计时器线程,然后自身就转而继续执行其他任务了,计时器线程会处理setTimeout,把回调函数包装成任务加到消息队列的末尾,等待主线程调度执行。
一个简单的例子
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Event Loop</title>
</head>
<body>
<div>
<h2>Hello</h2>
<button>change</button>
</div>
<script>
const title = document.querySelector("h2");
const button = document.querySelector("button");
function delay(ms) {
const start = Date.now();
while (Date.now() - start < ms) {}
}
button.addEventListener("click", () => {
// 即使把delay函数放在后面,也会导致页面卡住 3s
title.innerText = "World";
delay(3000);
});
</script>
</body>
</html>
上面的例子执行逻辑如下:
- 渲染主线程执行全局js,也就是<script>
- 主线程遇到一个click的点击监听,然后会把任务交给
交互线程,让交互线程在合适的时机执行回调函数,这时候主线程就结束去执行后面的任务了,这里可以认为没有其他任务了,主线程进入休眠 - 交互线程在等到用户点击后,会把回到函数
fn包装成任务放进消息队列,这时候,主线程被唤醒,开始事件循环,把消息队列里的第一个任务拿出来执行,也就是fn,注意这时候fn已经进入主线程执行了 title.innerText = "World"执行会产生一个绘制任务paint放进消息队列,但是主线程的任务还没结束,delay(3000)也必须被执行完,才能去执行paint任务,所以这就是卡住3s的原因
消息队列的优先级
消息队列中的任务是没有优先级的,遵循先进先出原则。根据W3C解释:
- 每个任务都有一个任务类型,同类型的任务一定在同一个消息队列里。在一次事件循环中,浏览器可以根据实际情况从不同消息队列中取出任务执行
- 浏览器必须有一个微队列,微队列中的任务优先级最高
不再说宏任务,微任务了
现代浏览器一般至少包含以下三种队列:
- 延时队列 (优先级-中)
- 交互队列 (优先级 - 高)
- 微队列 (优先级 - 最高)
哪些操作会产生微任务(记忆)
new Promise的执行器函数是同步的,不是异步
- Promise回调(then,catch,finnaly)
- async/await (本质就是 Promise)
- MutationObserver 回调
- queueMicrotask (浏览器和 Node 都支持的原生微任务 API)
- process.nextTick() - Node
面试题再也不怕
看一个比较复杂的面试题例子
console.log(1);
const p = new Promise((resolve) => {
const timer1 = setTimeout(() => {
console.log(2);
}, 0);
resolve();
console.log(3);
});
const timer2 = setTimeout(() => {
console.log(4);
}, 0);
p.then(() => {
console.log(5);
const timer3 = setTimeout(
// 下面这个函数用fn指代
() => {
Promise.resolve().then(() => {
console.log(6);
});
},
0,
);
});
console.log(7);
一步步看执行逻辑
- 第一轮:首先执行全局js
- 打印 1
- 遇到Promise,执行器函数同步执行,遇到延时任务,把里面的延时函数
log(2)加入延时队列,继续执行打印3,执行器函数结束。 - 继续执行遇到timer2,把
log(4)放进延时队列。 - 然后遇到Promise回调,把回调函数放进微队列,也就是
log(5)和timer3,这时候timer3还没有执行,所以下面暂时用timer3代替。 - 继续执行,打印7。第一轮结束。已经打印1 3 7
- 第二轮:从微队列中拿出第一个任务执行,打印5.(因为微队列优先级更高),此时已经打印 1 3 7 5
- 第三轮:微队列中还有任务timer3,继续拿出来执行
发现是一个延时任务,把
fn放进延时队列 - 第四轮:此时微队列已经清空,消息队列里还剩延时队列,因此把延时队列的任务放进主线程执行。打印2 4,此时打印 1 3 7 5 2 4
然后执行fn,fn会执行一个Promise回调,因此又产生一个微任务,最后再把微任务放进主线程执行,打印6,最终打印
1 3 7 5 2 4 6
只要按照事件循环机制梳理,逻辑是非常清晰的。