likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 11

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

事件循环概述

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

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

常见的宏任务和微任务

宏任务:setTimeout, setInterval, script 代码块 微任务:Promise, queueMicrotask, async/await, Mutation Observer

代码示例

简单示例

setTimeout(() => console.log(1), 0)
Promise.resolve().then(() => console.log(2))
Promise.resolve().then(() => console.log(3))
console.log(4)
console.log(5)
点击展开/折叠答案

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

打印顺序: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 代码块示例

<script>
    Promise.resolve().then(() => console.log(1))
</script>

<script>
    console.log(2)
</script>
点击展开/折叠答案

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

打印顺序: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

复杂示例

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 秒后

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()
点击展开/折叠答案

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

答案解析:

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

渲染具体在哪个时机?

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

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)
点击展开/折叠答案

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

答案:

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

答案解析:

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

requestAnimationFrame 探索

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

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)
点击展开/折叠答案

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

答案:

  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

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 也是在维护一个队列,和微任务,宏任务执行机制一样,也是先取出队头任务,放入执行栈,然后依次从微任务队列开始查找,再继续往后。

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

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

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

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

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

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

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))
点击展开/折叠答案

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

答案:

  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,拿点击事件举例,同一个元素可以注册多个事件,我们修改下上面的代码,当我们回车输入以下代码,在第一次空转的时候点击页面

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))
点击展开/折叠答案

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

答案:

  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。

最后

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

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

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