这次继续一步步回顾 JS 基础知识点,今天讲的是 JS 中的事件循环。
深入理解 JavaScript 系列已经很久没更新了,之前的系列文章如下:
- 深入理解 JavaScript 之变量提升
 - 深入理解 JavaScript 之由一道题来思考闭包
 - 深入理解 JavaScript 之执行上下文和执行栈
 - 深入理解 JavaScript 之执行上下文和变量对象
 - 深入理解 JavaScript 之作用域链与闭包
 - 深入理解 JavaScript 之原型与原型链
 - 深入理解 JavaScript 之 new 原理及模拟实现
 - 深入理解 JavaScript 之获取数组中的最大值方法(this,apply)
 - 深入理解 JavaScript 之实现继承的 7 种方式
 
更多前端内容可以看我 个人博客
JavaScript 是单线程的
JS 是一门单线程的非阻塞的脚本语言,这表示在同一时刻最多也只有一个代码段执行。
为什么 JavaScript 是单线程的
如果 JS 是多线程的,因为 JS 有 DOM API 可以操作 DOM,如果同时开了两个线程同时操作 DOM 的话,一个线程删除了当前的 DOM 节点,另一个线程要操作当前的 DOM,那么就会有矛盾到底以哪个线程为主。为了避免这种情况出现,JS 就被设计为单线程,而且单线程执行效率高。现在虽然也有 web worker 标准的出现,但它也有很多限制,受主线程控制,是主线程的子线程。
JS 如何处理异步任务
JS 是单线程,那么非阻塞怎么体现呢?如果 JS 是阻塞的,那么 JS 发起一个异步 IO 请求,在等待结果返回的这个时间段,后面的代码就无法执行了,而 JS 主线程和渲染进程是互斥的,因此可能造成浏览器假死的状态。事实 JS 是非阻塞的,那它要怎么实现异步任务呢,靠的就是事件循环。
事件循环
事件循环就是通过异步执行任务的方法来解决单线程的弊端的。
- 一开始整个脚本作为一个宏任务执行
 - 执行过程中同步代码直接执行,宏任务进入宏任务队列,微任务进入微任务队列
 - 当前宏任务执行完出队,读取微任务列表,有则依次执行,直到全部执行完
 - 执行浏览器 UI 线程的渲染工作
 - 检查是否有 Web Worker 任务,有则执行
 - 执行完本轮的宏任务,回到第 2 步,继续依此循环,直到宏任务和微任务队列都为空
 
宏任务与微任务
JS 引擎把所有任务分成两类,一类叫宏任务(macroTask),一类叫微任务(microTask)
宏任务
- script(整体代码)
 - setTimeout/setInterval
 - I/O
 - UI 渲染
 - postMessage
 - MessageChannel
 - requestAnimationFrame
 - setImmediate(Node.js 环境)
 
微任务
- new Promise().then()
 - MutaionObserver
 - process.nextTick(Node.js 环境)
 
经典题目 1
关于更细节的描述我就不写了,因为有更好的文章可以参考学习:
简单介绍后,接下来就来看几道经典题目:
1  | console.log('script start')  | 
- 整体 script 作为第一个宏任务进入主线程,输出
script start - 遇到 setTimeout,setTimeout 为宏任务,加入宏任务队列
 - 遇到 Promise,其 then 回调函数加入到微任务队列;第二个 then 回调函数也加入到微任务队列
 - 继续往下执行,输出
script end - 检测微任务队列,输出
promise1、promise2 - 进入下一轮循环,执行 setTimeout 中的代码,输出
setTimeout 
最后执行结果为:
1  | script start  | 
经典题目 2
来看一道面试的经典题目
1  | async function async1() {  | 
- 整体 script 作为第一个宏任务进入主线程,代码自上而下执行,执行同步代码,输出 
script start - 遇到 setTimeout,加入到宏任务队列
 - 执行 async1(),输出
async1 start;然后遇到await async2(),await 实际上是让出线程的标志,首先执行 async2(),输出async2;把 async2() 后面的代码console.log('async1 end')加入微任务队列中,跳出整个 async 函数。(async 和 await 本身就是 promise+generator 的语法糖。所以 await 后面的代码是微任务。) - 继续执行,遇到 new Promise,输出
promise1,把.then()之后的代码加入到微任务队列中 - 继续往下执行,输出
script end。接着读取微任务队列,输出async1 end,promise2,执行完本轮的宏任务。继续执行下一轮宏任务的代码,输出setTimeout 
最后执行结果为:
1  | script start  | 
经典题目 3
我们来看下面一段代码:
1  | setTimeout(function () {  | 
- 整体 script 代码执行,开局新增三个宏任务,两个 setTimeout 和一个 requestAnimationFrame
 - 遇到 Promise,先输出
promise1,promise2,加把 then 回调加入微任务队列。 - 继续往下执行,输出
end - 执行 promise 的 then 回调,输出
promise then - 接下来剩三个宏任务,我们可以知道的是
timer1会比timer2先执行,那么requestAnimationFrame呢? 
当每一轮事件循环的微任务队列被清空后,有可能发生 UI 渲染,也就是说执行任务的耗时会影响视图渲染的时机。
通常浏览器以每秒 60 帧(60fps)的速率刷新页面,这个帧率最适合人眼交互,大概 1000ms/60 约等于 16.7ms 渲染一帧,如果要让用户看得顺畅,单个宏任务及它相应的微任务最好能在 16.7ms 内完成。
requestAnimationFrame 是什么?
window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。
requestAnimationFrame 的基本思想是 让页面重绘的频率和刷新频率保持同步,相比 setTimeout,requestAnimationFrame 最大的优势是由系统来决定回调函数的执行时机。
但这个也不是每轮事件循环都会执行 UI 渲染,不同浏览器有自己的优化策略,比如把几次的视图更新累积到一起重绘,重绘之前会通知 requestAnimationFrame 执行回调函数,也就是说 requestAnimationFrame 回调的执行时机是在一次或多次事件循环的 UI render 阶段。
在我的谷歌浏览器执行结果:
1  | promise 1  | 
在我的火狐浏览器执行结果:
1  | promise 1  | 
谷歌浏览器中的结果 requestAnimationFrame()是在一次事件循环后执行,火狐浏览器中的结果是在三次事件循环结束后执行。
可以知道,浏览器只保证 requestAnimationFrame 的回调在重绘之前执行,但没有确定的时间,何时重绘由浏览器决定。
参考文章
- ps: 个人技术博文 Github 仓库,觉得不错的话欢迎 star,给我一点鼓励继续写作吧~