浏览器和EventLoop机制

734 阅读9分钟

写在前面

2022/3/19 更新浏览器相关知识

通过这几天的学习,看了多篇掘金大神们对于事件循环的讲解以及书籍还有相关文档的介绍,我也大概了解了事件循环是如何运行的,遂有了此篇文章,若有错误,欢迎指正

同步和异步

想要了解事件循环,我们首先需要了解两个十分重要的概念

因为 JS 引擎是单线程执行的,所以如果某些任务太过复杂,那么势必就会影响到后面任务的执行(阻塞),所以将任务划分为了同步任务和异步任务,二者的区别就是,当浏览器执行到同步任务时,会直接执行,当执行到异步任务时,异步任务会在某些条件达到时,将回调函数推入到任务队列中(任务队列不止一条,后面会详细讲解)等待执行

需要注意的地方:

  • console.log 是标准的同步任务,一开始我把这玩意当作是用户输出,理解成宏任务了。。
  • 关于异步任务达到某些条件,这里的条件可能有些同学会困惑,我举个例子,比如说 setTimeout,它将回调函数推入任务队列的条件就是等待时间到达,再举个例子,Promise 将回调函数比如 then 中的回调函数推入任务队列的条件就是 Promise 的状态发生改变

事件循环中的任务类型

在浏览器的事件循环中,主要存在以下几类任务:

  1. 宏任务(Task)

    • 例如:setTimeoutsetInterval、I/O 操作、UI 交互事件等。
    • 每次事件循环会从宏任务队列中取出一个任务执行。
  2. 微任务(Microtask)

    • 例如:Promise.thenMutationObserver 等。
    • 每个宏任务执行完毕后,会清空整个微任务队列
  3. 渲染任务(Render Steps)

    • 包括 requestAnimationFrame 回调、样式计算、布局、绘制等。
    • 在宏任务和微任务处理完毕后,如果当前帧需要渲染,浏览器会执行这些操作。

宏任务(macro-task)

  • script
  • setTimeout
  • serInterval
  • setImmediate(不推荐使用,因为不是标准化的方法)
  • I/O(输入输出,再次提醒 console 是同步任务
  • HTTP 请求(比如 ajax)

微任务(micro-task)

  • process.nextTick(node 中的函数,会在下一次事件循环之前执行,并且会优先于其他微任务)
  • Promise(then catch finally)
  • Async 中的 Await
  • MutationObserver(浏览器专属)
  • queueMicrotask(浏览器和 node 都可以使用)

关于宏任务和微任务需要注意的地方,也是我之前的误解:

  • 一个宏任务内部可能也会嵌套有宏任务和微任务(这很正常,比如说 script,setTimeout),遇到宏任务,需要在这个宏任务下把整个流程执行完毕(流程后面会讲),例如遇到 setTimeout,必须把整个 setTimeout 宏任务(包含里边的微任务)全部执行完毕以后再执行新的宏任务

  • await 的执行类似于 promise.then,是 generator 的语法糖不是 promise

    async function async1() {
      await async2()
      console.log('asyn1 end')
    }
    // 上述代码在执行上等同于如下代码
    async function async1() {
      Promise.resolve(async2()).then(() => {
        console.log('asyn1 end');
      });
    }
    
  • 关于异步任务,其实异步执行的都是回调函数,异步任务本身仍是同步执行的,把这句话理解了很多面试题就迎刃而解了

    • 举两个例子

       new Promise(() => { 
         函数内的代码同步执行,可以理解成普通的构造函数执行 
       }).then(() => { 
         then函数中的内容异步执行 
       })
       // 一道很经典的面试题
       for (var i = 0; i < 5; i++) {
         setTimeout(() => { //函数本身同步执行,这也是为什么回调中输出的i和传入时间中的i不同的原因
           // 回调函数异步执行
           console.log(i)
         }, i * 1000)
       }
      
    • 只有当Promise状态发生改变时,then中回调函数才会被推入任务队列等待执行

事件循环过程

了解了前面那么多的准备知识,终于到了正题,事件循环的过程,直接上图

image.png

用文字解释一下上面的过程,每一次事件循环的开始,都会先去检查宏任务队列,为空则执行微任务,不为空则执行一项宏任务,执行完一项宏任务后,再去检查微任务队列,不为空则会去执行微任务,直到微任务队列为空,为空后事件循环会进行判断此时页面是否需要重新渲染,如果需要进行渲染,那么就会执行渲染相关的任务,比如 requestAnimationFrame,回流,重绘等等,在这之后会开始下一轮事件循环

发现了吗,每次事件循环过程中,宏任务只会执行一个,微任务则会一直执行到队列为空

重新渲染/页面更新的时机

通常情况下,会在每次事件循环后执行一次界面更新过程,但事实情况是每一次事件循环之后不一定会发生更新,界面更新的时机受到多种因素影响

  1. 界面刷新率:界面刷新率是浏览器每秒钟刷新屏幕的次数,通常为60Hz(即每秒60次)。如果界面刷新率较低,界面更新可能会被限制在刷新率的频率内。

  2. 宏任务和微任务的执行时间:如果事件循环中的宏任务和微任务占用较多的时间,可能会延迟界面更新的发生,比如一次 event loop 时间超过了一帧的间隔时间(60hz 的情况下一帧的间隔是 16.67 ms),就可能导致界面更新在下一帧时才更新。

  3. 浏览器的优化行为:浏览器可能会对多次界面更新进行优化,将多次DOM操作合并为一次渲染,从而减少重绘和回流的次数,提高性能。比如说一帧之内经历了好几次 event loop,那么就可能把这几次的 dom 操作合并为一次进行渲染

做题实战环节

读到这里,相信你对事件循环也有了一定的了解,用一道大杂烩题目来考察你是否真的理解了事件循环

console.log('js start~~~')

async function async1() {
  await async2()
  console.log('asyn1 end')
}

async function async2() {
  console.log('async2 end')
}

async1()

process.nextTick(function () {
  console.log('nodeeeeeeee~~');
})

setTimeout(function () {
  console.log('setTimeout22222')
}, 10)

setTimeout(function () {
  console.log('setTimeout111')
  new Promise((resolve) => {
    console.log('setTimeout中的promise同步执行')
    resolve()
  }).then(() => {
    console.log('我是哈哈哈哈哈')
  })
}, 0)

new Promise(resolve => {
  console.log('Promise正常执行')
  resolve()
}).then(() => {
  console.log('promise1')
}).then(() => {
  console.log('promise2')
})

console.log('js end~~~')

结果如下

image.png

在事件循环开始,首先先检查宏任务队列,此时队列中只有一个script(在node中自然就是整段js代码),执行这个宏任务,同步任务会直接执行,所以输出js start~~~,在这之后,会执行异步函数async1,需要注意的是被async标识的函数会被理解为异步函数,但是执行仍是以同步函数的方式进行执行,async1函数中有一个await async2(),所以会再输出async2 end,并且把异步函数内await下方的代码推入微任务队列,然后执行到nextTick,nextTick会把回调推入微任务队列的队首,再然后是两个setTimeout,到时间后推入宏任务队列,然后是Promise,输出Promise正常运行,然后是js end~~~

接着执行微任务队列,先是输出nodeeeee~~~接着继续按顺序依次执行

然后到下一次事件循环,执行一个宏任务,这里我主要想强调的是一个宏任务执行,会连同它内部产生的宏任务和微任务一并执行完成后,再执行其他宏任务

注意: 因为用到了 nextTick,所以需要在 node 环境中运行,若想在浏览器运行,要删除 nextTIck

再深入一点

想要更好的了解事件循环过程,我们还需要了解跟浏览器相关的知识。跟事件循环相关的知识我会加深颜色。

浏览器

juejin.cn/post/724069…

浏览器是多进程的,它包括以下这些进程:

  1. 浏览器主进程(Browser 进程,负责主控和协调)
    • 负责浏览器界面显示,用户交互
    • 创建和销毁进程,即开关页面
    • 网络资源管理、下载
  2. 浏览器渲染进程(Renderer进程,又被称为浏览器内核,内部是多线程的,核心任务是将 HTML、CSS、JavaScript 转换为用户可以交互的页面),内部的线程有
    • GUI 渲染线程
    • JS 引擎线程(与渲染线程互斥,二者只能同时运行一个)
    • 事件触发线程(跟 EventLoop 相关,当一些事件触发时,比如鼠标点击或者 setTimeout,该线程就会把对应的回调放入任务队列中)
    • 定时触发器线程(用来给计时器计时的)
    • 异步 http 请求线程(对于 xhr,浏览器实际上是新开一个线程进行请求)
  3. 第三方插件进程
  4. GPU 进程,一开始只是为了实现 3D CSS 效果,后来所有的 UI 界面都交由 GPU 来绘制

进一步理解事件循环

结合浏览器再来看事件循环实际上是这样的:

(1)同步任务在JS引擎线程(主线程)上执行,形成执行栈(Execution Context Stack)。

(2)主线程之外,事件触发线程管理着一个任务队列(这个任务队列实际上会分成之前所说的宏任务和微任务队列)。只要事件触发,就在任务队列之中放置一个回调函数。

(3)执行栈中的同步任务执行完毕,系统就会读取任务队列,如果有回调函数需要执行,将其加到主线程的执行栈并执行。

(4)当一个宏任务执行完成后,会去清空微任务队列,当微任务清理完毕后,浏览器会去判断是否需要进行渲染,如果需要渲染,那么 JS 引擎线程将会挂起,GUI 线程重新执行渲染

(5)当 GUI 渲染线程渲染完毕后,又会交由 JS 引擎线程执行新一轮的事件循环,执行下一个宏任务...

image.png

参考

一次弄懂Event Loop

Promise.then MDN

理解 JavaScript 的 async/await

「吊打面试官」彻底理解事件循环、宏任务、微任务

从浏览器多进程到JS单线程,JS运行机制最全面的一次梳理

Vue源码解析

《JavaScript忍者秘籍》