这次继续一步步回顾 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,给我一点鼓励继续写作吧~