你不知道的 JS 事件循环细节

本文主要讲的是 JS 事件循环一些不会注意的细节,以及对页面渲染的影响。

事件循环概述

  1. 从主执行栈出发,执行栈执行结束。
  2. 执行栈结束后切换到微任务队列,分两种情况:
    2-1. 有任务,取出队头的任务放入执行栈,再从 1 开始。
    2-2. 无任务,切换到宏任务队列。
  3. 切换到宏任务队列,宏任务队列和微任务队列操作一样。
  4. 宏任务清空之后继续回到 1。

常见的宏任务和微任务

宏任务:setTimeout, setInterval, script 代码块

微任务:Promise, queueMicrotask, async/await, Mutation Observer

代码示例

简单示例

js 复制代码
setTimeout(() => console.log(1), 0)
Promise.resolve().then(() => console.log(2))
Promise.resolve().then(() => console.log(3))
console.log(4)
console.log(5)

点击展开/折叠答案

打印顺序:4,5,2,3,1

答案解析:

  1. 初始状态下 微任务队列 = \[\], 宏任务队列 = \[\]
  2. 上面代码块全部放入主执行栈
  3. 执行到第一行,此时 宏任务队列 = () =\> cosole.log(1)
  4. 第二行,遇到微任务,加入微任务队列,此时 微任务队列 = () =\> console.log(2)
  5. 第三行,此时 微任务队列 = () =\> console.log(2), () =\> console.log(3)
  6. 第四行,同步任务,直接打印 4
  7. 第五行,同步任务,直接打印 5
  8. 主执行栈执行结束,切换到微任务队列,发现有任务,取出队头,此时 微任务队列 = () =\> console.log(3)
  9. 执行刚才取出的任务,打印 2
  10. 主执行栈执行结束,继续切换到微任务队列,取出队头,此时微任务队列 = \[\]
  11. 打印 3
  12. 主执行栈执行结束,继续切换到微任务队列,发现没有任务,切换到宏任务队列
  13. 取出宏任务,此时宏任务队列为空
  14. 执行 console.log(1),结束

script 代码块示例

html 复制代码
<script>
    Promise.resolve().then(() => console.log(1))
</script>

<script>
    console.log(2)
</script>

点击展开/折叠答案

打印顺序:1,2

答案解析:

按理来说我们打印顺序应该是 2,1,也就是先执行同步任务,再执行异步任务,但是此时却相反,因为我们的 script 整个代码块也会被认为是一个宏任务。

  1. 页面加载完毕,此时 宏任务队列 = script代码块1,script代码块2,微任务队列 = \[\]
  2. 执行栈为空,切换到微任务队列
  3. 微任务队列也为空,切换到宏任务队列
  4. 发现有任务,取出队头,此时 宏任务队列 = script代码块2
  5. 主执行栈执行任务,产生了微任务,此时 微任务队列 = () =\> console.log(1)
  6. 主执行栈执行结束,切换到微任务,发现有任务,取出任务执行
  7. 打印 1
  8. 微任务队列为空,宏任务队列有任务,取出任务执行
  9. 打印 2

复杂示例

js 复制代码
console.log(1)
setTimeout(() => {
    console.log(2)
    Promise.resolve().then(() => {
        console.log(3)
    })
}, 0)
Promise.resolve().then(() => {
    console.log(4)
    Promise.resolve().then(() => {
        console.log(5)
        setTimeout(() => console.log(7), 0)
    })
})
setTimeout(() => console.log(8), 0)
console.log(9)

点击展开/折叠答案

打印顺序:1,9,4,5,2,3,8,7

这个只是代码多了,和上面示例逻辑一样

事件循环与渲染的关系

浏览器的 js 线程和渲染线程是交叉进行的,我们如果能清楚了解两者之间的关系,就可以写出更高性能的代码。

渲染是同步还是异步?

请问页面颜色是立即改变,还是 5 秒后

js 复制代码
function wait (num = 5) {
    console.log('wait start')
    num *= 1000
    const start = performance.now()
    while (performance.now() - start < num) {
        
    }
    console.log('wait end')
}

document.body.style.background = 'pink'
wait()

点击展开/折叠答案

答案解析:

dom 的操作是同步的,但渲染是异步的,所以会在同步任务执行完毕之后

渲染具体在哪个时机?

上面我们知道了渲染是异步的,下面我们找下渲染的具体时机

js 复制代码
function wait (num = 5) {
    console.log('wait start')
    num *= 1000
    const start = performance.now()
    while (performance.now() - start < num) {
        
    }
    console.log('wait end')
}

document.body.style.background = 'pink'
Promise.resolve().then(() => {
    console.log(1)
    wait(2)
})
setTimeout(() => {
    wait(2)
    console.log(2)
}, 0)
console.log(3)

点击展开/折叠答案

答案:

  1. 打印 3
  2. 打印 1
  3. 空转 2s
  4. 浏览器渲染
  5. 空转 2s
  6. 打印 2

答案解析:

我们可以得出浏览器渲染是在微任务清空之后,宏任务执行之前

requestAnimationFrame 探索

我们之前做动画会用到 requestAnimationFrame 这个 api,官方的定义是他在渲染之前执行,那么我们探索一下他是否是微任务

js 复制代码
function wait (num = 5) {
    console.log('wait start')
    num *= 1000
    const start = performance.now()
    while (performance.now() - start < num) {
        
    }
    console.log('wait end')
}

document.body.style.background = 'pink'
setTimeout(() => {
    wait(2)
    console.log(1)
}, 0)
requestAnimationFrame(() => {
    wait(2)
    console.log(2)
})
Promise.resolve().then(() => {
    console.log(3)
    Promise.resolve().then(() => {
        wait(2)
        console.log(4)
    })
})
Promise.resolve().then(() => {
    console.log(5)
})
console.log(6)

点击展开/折叠答案

答案:

  1. 打印 6
  2. 打印 3
  3. 打印 5
  4. 空转 2s
  5. 打印 4
  6. 空转 2s
  7. 打印 2
  8. 浏览器渲染
  9. 空转 2s
  10. 打印 1

答案解析:

如上所见,requestAnimationFrame 回调函数的注册时间迟于 setTimeout,早于 Promise,但是他执行时机却是在微任务队列清空之后,渲染之前,也就在宏任务执行之前,所以 requestAnimationFrame 严格来说不从属于宏任务和微任务,是存在与他们之间,渲染之前。

深入 requestAnimationFrame

我们调整下上面的代码,深究一下 requestAnimationFrame

js 复制代码
setTimeout(() => {
    console.log(1)
}, 0)
requestAnimationFrame(() => {
    console.log(2)
    Promise.resolve().then(() => {
        console.log(3)
    })
})
requestAnimationFrame(() => {
    console.log(4)
})
Promise.resolve().then(() => {
    console.log(5)
    Promise.resolve().then(() => {
        console.log(6)
    })
})
Promise.resolve().then(() => {
    console.log(7)
})
console.log(8)

点击展开/折叠答案

答案:

  1. 打印 8
  2. 打印 5
  3. 打印 7
  4. 打印 6
  5. 打印 2
  6. 打印 3
  7. 打印 4
  8. 打印 1

答案解析:

我们发现打印 2 和 4,两个 requestAnimationFrame 回调之间打印了 3,可以得出 requestAnimationFrame 也是在维护一个队列,和微任务,宏任务执行机制一样,也是先取出队头任务,放入执行栈,然后依次从微任务队列开始查找,再继续往后。

我们根据上面示例丰富我们的流程图

事件循环与人机交互的关系

人机交互,也叫 UI event,指的就是我们平常对浏览器的操作,比如说我们的点击事件,滚轮事件。

UI event 是宏任务还是微任务?

当我们回车输入以下代码,在第一次空转的时候点击页面,看下最终的打印结果

js 复制代码
function wait (num = 5) {
    console.log('wait start')
    num *= 1000
    const start = performance.now()
    while (performance.now() - start < num) {
        
    }
    console.log('wait end')
}

document.body.addEventListener('click', () => console.log('click'))
document.body.style.background = 'pink'
console.log(1)
setTimeout(() => console.log(2), 0)
requestAnimationFrame(() => {
    console.log(3)
    wait(2)
})
Promise.resolve().then(() => console.log(4))
console.log(5)
wait()
Promise.resolve().then(() => console.log(6))

点击展开/折叠答案

答案:

  1. 打印 1
  2. 打印 5
  3. 空转 5s
  4. 打印 4
  5. 打印 6
  6. 打印 click
  7. 打印 3
  8. 空转 2s
  9. 页面渲染
  10. 打印 2

答案解析:

如上可知,我们在空转时点击了页面,是在执行 Promise.resolve().then(() => console.log(6)) 之前,但是最终执行是在打印 6 之后,所以我们可以得出,UI event 也是在微任务清空之后,requestAnimationFrame 执行之前。

UI event 是否也在维护一个队列?

对于 UI event,拿点击事件举例,同一个元素可以注册多个事件,我们修改下上面的代码,当我们回车输入以下代码,在第一次空转的时候点击页面

js 复制代码
function wait (num = 5) {
    console.log('wait start')
    num *= 1000
    const start = performance.now()
    while (performance.now() - start < num) {
        
    }
    console.log('wait end')
}

document.body.addEventListener('click', () => {
    console.log('click 1')
    Promise.resolve().then(() => console.log(0))
})
document.body.addEventListener('click', () => {
    console.log('click 2')
})
document.body.style.background = 'pink'
console.log(1)
setTimeout(() => console.log(2), 0)
requestAnimationFrame(() => {
    console.log(3)
    wait(2)
})
Promise.resolve().then(() => console.log(4))
console.log(5)
wait()
Promise.resolve().then(() => console.log(6))

点击展开/折叠答案

答案:

  1. 打印 1
  2. 打印 5
  3. 空转 5s
  4. 打印 4
  5. 打印 6
  6. 打印 click 1
  7. 打印 0
  8. 打印 click 2
  9. 打印 3
  10. 空转 2s
  11. 页面渲染
  12. 打印 2

答案解析:

首先根据上一个示例,我们得出 UI event 是在微任务队列清空之后,requestAnimationFrame 之前,现在我们对同一个点击注册了两个回调,第一个回调执行过程中会产生微任务,而这个微任务的执行是在第二个回调执行之前。

所以我们得出 UI event 也是维护了一个队列,当我们 click 1 回调放入执行栈执行,过程中产生了新的微任务,此时执行栈执行结束,继续从微任务队列找任务,微任务队列清空之后才到了 click 2。

最后

最后再丰富下我们的流程图:

欢迎大家讨论,发表意见,文中有错误的地方也欢迎指出来,祝大家都能掌握事件循环,拿到好的 offer,写出高性能的代码。

相关推荐
Mh2 小时前
别再只会 `find` 了:Map 在前端业务里的真实用法
前端·javascript
陈随易2 小时前
FFmpeg 9.0 发布,代号 Lei,音视频处理再升级
前端·后端·程序员
爱丶狸3 小时前
Grafana_Zabbix_ImageRenderer_部署与前端操作手册
linux·前端·zabbix·grafana·kylin
用户059540174466 小时前
把AI对话记忆存储测试从手工改成Playwright+pytest,覆盖率从20%提到96%,回归时间缩短90%
前端·css
kyriewen6 小时前
别再这样写TypeScript了——Code Review中最常见的8个反模式
前端·javascript·typescript
名字还没想好☜6 小时前
Next.js ‘use client‘ 到底加在哪:Server/Client Components 边界与常见报错
开发语言·前端·javascript·react·next.js
午安~婉6 小时前
GitHub Token/ GitHub Stats统计显示图异常
前端·github·vercel·github token
码云之上7 小时前
AI Agent 工程化总览篇:从 Prompt 到 Harness
前端·人工智能
2501_926978337 小时前
以说明书 DNA 为模板——完整 AGI 的结构图景
前端·人工智能·经验分享·笔记·ai写作
IT_陈寒7 小时前
Vite静态资源引用这个坑我踩得有点疼
前端·人工智能·后端