0%

深入理解 JavaScript 之事件循环(Event Loop)

这次继续一步步回顾 JS 基础知识点,今天讲的是 JS 中的事件循环。

深入理解 JavaScript 系列已经很久没更新了,之前的系列文章如下:

更多前端内容可以看我 个人博客

JavaScript 是单线程的

JS 是一门单线程的非阻塞的脚本语言,这表示在同一时刻最多也只有一个代码段执行。

为什么 JavaScript 是单线程的

如果 JS 是多线程的,因为 JS 有 DOM API 可以操作 DOM,如果同时开了两个线程同时操作 DOM 的话,一个线程删除了当前的 DOM 节点,另一个线程要操作当前的 DOM,那么就会有矛盾到底以哪个线程为主。为了避免这种情况出现,JS 就被设计为单线程,而且单线程执行效率高。现在虽然也有 web worker 标准的出现,但它也有很多限制,受主线程控制,是主线程的子线程。

JS 如何处理异步任务

JS 是单线程,那么非阻塞怎么体现呢?如果 JS 是阻塞的,那么 JS 发起一个异步 IO 请求,在等待结果返回的这个时间段,后面的代码就无法执行了,而 JS 主线程和渲染进程是互斥的,因此可能造成浏览器假死的状态。事实 JS 是非阻塞的,那它要怎么实现异步任务呢,靠的就是事件循环。

事件循环

事件循环就是通过异步执行任务的方法来解决单线程的弊端的。

  1. 一开始整个脚本作为一个宏任务执行
  2. 执行过程中同步代码直接执行,宏任务进入宏任务队列,微任务进入微任务队列
  3. 当前宏任务执行完出队,读取微任务列表,有则依次执行,直到全部执行完
  4. 执行浏览器 UI 线程的渲染工作
  5. 检查是否有 Web Worker 任务,有则执行
  6. 执行完本轮的宏任务,回到第 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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')
  1. 整体 script 作为第一个宏任务进入主线程,输出script start
  2. 遇到 setTimeout,setTimeout 为宏任务,加入宏任务队列
  3. 遇到 Promise,其 then 回调函数加入到微任务队列;第二个 then 回调函数也加入到微任务队列
  4. 继续往下执行,输出script end
  5. 检测微任务队列,输出promise1promise2
  6. 进入下一轮循环,执行 setTimeout 中的代码,输出setTimeout

最后执行结果为:

1
2
3
4
5
script start
script end
promise1
promise2
setTimeout

经典题目 2

来看一道面试的经典题目

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
async function async1() {
console.log('async1 start')
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2')
}
console.log('script start')
setTimeout(function () {
console.log('setTimeout')
}, 0)
async1()
new Promise(function (resolve) {
console.log('promise1')
resolve()
}).then(function () {
console.log('promise2')
})
console.log('script end')
  1. 整体 script 作为第一个宏任务进入主线程,代码自上而下执行,执行同步代码,输出 script start
  2. 遇到 setTimeout,加入到宏任务队列
  3. 执行 async1(),输出async1 start;然后遇到await async2(),await 实际上是让出线程的标志,首先执行 async2(),输出async2;把 async2() 后面的代码console.log('async1 end')加入微任务队列中,跳出整个 async 函数。(async 和 await 本身就是 promise+generator 的语法糖。所以 await 后面的代码是微任务。)
  4. 继续执行,遇到 new Promise,输出promise1,把.then()之后的代码加入到微任务队列中
  5. 继续往下执行,输出script end。接着读取微任务队列,输出async1 endpromise2,执行完本轮的宏任务。继续执行下一轮宏任务的代码,输出setTimeout

最后执行结果为:

1
2
3
4
5
6
7
8
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout

经典题目 3

我们来看下面一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
setTimeout(function () {
console.log('timer1')
}, 0)

requestAnimationFrame(function () {
console.log('requestAnimationFrame')
})

setTimeout(function () {
console.log('timer2')
}, 0)

new Promise(function executor(resolve) {
console.log('promise 1')
resolve()
console.log('promise 2')
}).then(function () {
console.log('promise then')
})

console.log('end')
  • 整体 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
2
3
4
5
6
7
promise 1
promise 2
end
promise then
requestAnimationFrame
timer1
timer2

在我的火狐浏览器执行结果:

1
2
3
4
5
6
7
promise 1
promise 2
end
promise then
timer1
timer2
requestAnimationFrame

谷歌浏览器中的结果 requestAnimationFrame()是在一次事件循环后执行,火狐浏览器中的结果是在三次事件循环结束后执行。

可以知道,浏览器只保证 requestAnimationFrame 的回调在重绘之前执行,但没有确定的时间,何时重绘由浏览器决定。

参考文章


-------------本文结束感谢您的阅读-------------