你不知道的 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,写出高性能的代码。

相关推荐
22:30Plane-Moon1 小时前
跨域解决方案
javascript
上单带刀不带妹2 小时前
Node.js 中的 fs 模块详解:文件系统操作全掌握
开发语言·javascript·node.js·fs模块
运维帮手大橙子2 小时前
完整的登陆学生管理系统(配置数据库)
java·前端·数据库·eclipse·intellij-idea
_Kayo_3 小时前
CSS BFC
前端·css
二哈喇子!4 小时前
Vue3 组合式API
前端·javascript·vue.js
二哈喇子!6 小时前
Vue 组件化开发
前端·javascript·vue.js
C4程序员6 小时前
北京JAVA基础面试30天打卡03
java·开发语言·面试
chxii6 小时前
2.9 插槽
前端·javascript·vue.js
姑苏洛言7 小时前
扫码点餐小程序产品需求分析与功能梳理
前端·javascript·后端
Freedom风间7 小时前
前端必学-完美组件封装原则
前端·javascript·设计模式