学习JS的时候,一开始就是介绍JS是什么?他是一个解释性、弱类型、单线程
的脚本语言。它为什么是单线程?不能像Java那样使用多线程编程吗?什么是同步和异步?如何去管理同步和异步代码呢?这便是本文需要讲述的JS中一个非常重要的知识点-事件循环
。
JS为什么是单线程
JS为什么不能像Java那样多线程编程,非要使用单线程呢?与语言自身的服务对象有关。JS诞生之初是与DOM进行交互。如果是多线程的话,多个线程同时操作了同一个元素将会带来复杂的线程锁的问题以及不可预知的结果。
可以想象一个多线程场景:
- 线程A想删除元素D这个节点
- 线程B想修改元素D这个节点的内容
- 如果两个线程同时执行,浏览器将无法判断以谁的指令为准
现代浏览器已经提供了多线程的编程Web Workers
,它允许在后台线程中运行代码。但是不能操作DOM
,也是为避免上述的问题。它的诞生主要是为了解决一些计算任务较重的、耗时的工作。从而不影响渲染主线程的UI渲染和事件响应,以提高页面性能。
同步和异步
同步代码
同步就是代码按照书写顺序一行行执行。
js
console.log('script start');
console.log('loop start');
for(var i = 0; i < 10; i++) {
console.log(i); // 依次 打印0 ~ 9
}
console.log('loop end');
console.log('script end');
上述代码将会按照书写的顺序一一执行。执行结果如下:
text
script start
loop start
0 1 2 3 4 5 6 7 8 9
loop end
script end
同步代码特点:
按顺序执行,易于理解和调试。会阻塞后续代码的执行。当有一段执行耗时的同步代码时,后续代码必须等待。
异步代码
异步代码在执行后,不需要等待它执行完成,立刻继续执行后续代码。等到这个任务在某个时刻完成了,通过回调函数等方式通知JS主线程处理结果。
js
console.log('script start');
setTimeout(() => {
console.log('setTimeout executing');
}, 1000)
console.log('script end');
上述代码在执行到setTimeout(() => ...)
时,不会打印setTimeout
回调函数的代码,而是先执行后续代码,等待大约1秒钟之后,才会回过头执行setTimeout
的回调函数。 异步代码特点:
执行顺序和书写顺序不一致,不会阻塞主线程执行代码。开发中随处可见异步代码。例如ajax网络请求数据
、setTiemout
、Promise
。
事件循环
JS在单线程的环境下,是如何管理同步和异步代码的呢?这便引出了本文的主角---事件循环机制
。在时间循环机制之前。需要介绍构成事件循环机制的几个角色。
事件循环的参与者
渲染主线程:
它做干的事情相当相当多。例如解析HTML、解析CSS、计算样式、布局和绘制等。还有一个工作:执行JS代码
。调用栈:
它是一个栈结构(遵循后进先出原则),用来跟踪当前正在执行的代码。微任务队列:
它是一个存储微任务的队列数据结构(遵循先进先出的原则)。它的优先级要高于任务队列。任务队列:
它是一个存储(除微任务外)异步任务回调函数的队列数据结构(遵循先进先出的原则)。在浏览器中有多个任务队列。- 定时器任务队列:存放setTimeout和setInterval的回调函数
- 网络任务队列:存放网络请求(如fetch、XMLHttpRequest)完成后的回调函数
- 用户交互队列:存放用户交互的事件的回调函数。例如点击事件(click)、键盘敲击的事件(keydown)等事件的回调函数
- 历史记录队列:存放
histtory.back()
、history.go()
等操作的回调函数 - DOM操作任务队列: 存放DOM操作相关的任务。例如
MutationObserver
的回调
💡提示
MutationObserver本身是一个微任务,但是它触发了DOM变化,这个DOM变化的处理可能涉及普通任务(换个说法就是宏任务)。常见的是一些DOM更新的任务。
📢特殊情况渲染主线程其中还有一个任务是渲染页面。这个工作会发生事件循环机制的一个特定阶段。在浏览器看来渲染视为最优先的事情。在事件循环中,当微任务队列清空后,浏览器会自行判断是否需要渲染(通常每秒60次)。如果需要,则执行渲染工作。确保用户看到流畅页面。
上述讲到了多个任务队列
和微任务队列
存储任务的队列。渲染主线程根据什么判断优先拿哪个队列的任务压入调用栈去执行呢?由于浏览器的引擎实现有细微的差别。但是基本遵循如下原则:微任务
> 用户交互任务
> 网络和定时任务
具体步骤
我们通过一个简单例子说明一下。示例代码如下:
js
console.log('script start');
setTimeout(function () {
console.log('setTimeout');
}, 0);
Promise.resolve().then(function () {
console.log('promise1');
}).then(function () {
console.log('promise2');
});
console.log('script end');
一开始渲染主线程会开启一个无限循环,开始监听任务队列和微任务队列。JS逐行代码解析,边解析边执行。此时调用栈、任务队列和微任务队列都是空的。

- 遇到console。log('script start')代码,将其执行上下文压入调用栈执行代码,打印'script start'
2. 打印输出完毕后,将其执行执行上下文推出栈,遇到setTimeout函数,将其执行上下文压入栈,执行代码。将回调函数放入任务队列(计时器任务队列)。setTimeout的执行上下文出栈。虽然定时器定时为0,在setTimeout内部最少有几微秒的延迟。
3. 遇到Promise.resolve代码执行,将其执行上下文放入调用栈中,在执行中后面的两个then的回调函数 依次放入微任务队列中。然后其上下文出栈。
4. 遇到console。log('script end')代码,将其执行上下文压入调用栈执行代码,打印'script end'
5. 打印输出完毕后,调用栈为空时,渲染主线程将会优先检查微任务队列中是否为空,此时正好不为空。按照先进先出的原则,将先放入队列的任务压入调用栈中执行。
6.执行完毕后,打印'promise1'。然后出栈,再次检查微任务队列是否为空,检查情况还有任务,则继续将其任务压入调用栈执行。
7. 执行完毕后,打印'promise2'。然后出栈,在此检查微任务队列中是否为空,此时微任务队列为空,再检查其他任务队列是否,发现计时器任务队列不为空并且定时时间已到。则将setTimeout的回调函数压入调用栈中执行。
8. 执行完毕后,打印'setTimeout'。示例代码执行完毕。
上述代码执行过程是一个简单的事件循环。主线程就是这样周而复始,永不停歇地监听。
小结
本文主要讲述如下内容:
- JS是单线程,为什么设计成单线程
- 什么是同步和异步,各自的特点
- 事件循环中参与的主要角色。各自的用处。
- 讲解多个任务队列和微任务队列的优先级
- 通过一个例子讲解了事件循环的执行过程