前端面试---我理解的浏览器事件循环

525 阅读10分钟

前言

似乎从2018年开始,事件循环开始在前端面试流行起来,到现在事件循环已经算得上前端的基础面试题了。理解和学习事件循环已经不仅仅是程序员基础技能,也是必备面试题了。虽然事件循环不是“新事物”,网上相关文章也特别多,但是“一千个人眼中有一千个哈姆雷特”,每个人对其都有各自的理解。因此,我也把我理解的浏览器事件循环以及理解的过程记录下来。

进程和线程

在进入正文之前,我们需要先来复习一下基础知识:进程和线程。进程是资源分配的最基础单位,运行调度的基本单位,拥有自身独立的地址空间和资源;而线程则是进程中执行运算的最小单位,每个进程最起码包含一个线程。

看到这里可能有些疑惑了,为什么需要了解进程和线程呢?这是因为每打开一个浏览器页面就是开启了一个进程(系统为浏览器分配了资源:内存等),而浏览器事件循环则运行在页面的渲染线程中。我们可以通过浏览器的浏览器来进行更为直观的认知,包括辅助进程:GPU进程、NetWork等。

浏览器常驻线程

  • GUI 渲染线程: 主要作用是绘制页面(页面重绘和回流),解析 HTML、CSS,构建 DOM 树,布局和绘制等。该线程与 JavaScript 引擎互斥,也就是所谓的 JS 执行阻塞页面更新。

  • JS 引擎线程(主线程:负责 JavaScript 脚本的执行,与 GUI 渲染线程互斥,执行时间过长将阻塞页面的渲染。

  • 事件触发线程:负责将准备好的事件交给 JS 引擎线程执行,由于JavaScript 是单线程的,所以在遇见多个事件加入任务队列的时候需要排队等待。

  • 定时器触发线程:负责执行异步定时器类事件,如 setTimeout、 setInterval 等。定时器到时间后把注册的回调加到任务队列的队尾(定时器并不是由 JS 引擎计时的,因为 JS 引擎是单线程的,如果JS引擎处于堵塞状态,那会影响到计时的准确性。因此,当计时完成被触发,事件会被添加到事件队列,等待JS引擎空闲了执行)。

  • HTTP 请求线程:负责执行异步请求,JS 引擎线程执行异步请求的时候会把函数交给该线程处理,当监听到状态变更事件,如果有回调函数,该线程会把回调函数加入到任务队列的队尾等待执行。

JS 引擎为什么是单线程?因为 JavaScript 能够对 DOM 树进行操作,如果存在多线程,在渲染 DOM 的同时,JavaScript 代码进行修改、删除操作,这样就会导致渲染异常。因此为了防止这种现象GUI渲染线程与JS线程被设计为互斥关系,当JS引擎执行的时候,GUI线程需要被冻结,但是GUI的渲染会被保存在一个队列当中,等待JS引擎空闲的时候执行渲染。

浏览器为什么需要事件循环?

浏览器不只是执行内部的任务,还存在用户的输入等触发事件(事件触发线程)。那么如何让用户触发的任务和内部任务合理有序的执行呢?这里可以通过前面的线程来串联起来:

渲染主线程通过一个 IO 线程用来接收其他进程传进来的消息,比如 HTTP 请求线程获取的资源、 事件触发线程触发的事件任务。这个 IO 线程本质上是一个消息队列,符合队列“先进先出”的特点,而为了让渲染主线程能够根据各个任务去渲染更新页面,这里需要一个循环事件去消息队列读取任务并执行,而这个循环事件就是浏览器事件循环。

宏任务与微任务

那浏览器事件循环的内部到底是怎么样运行的吗?我们可以从代码执行的角度来看一下:

事件循环主要靠任务驱动,在任务中又分为两种:同步任务异步任务,而异步任务又分为宏任务微任务(实际上,并没有宏任务这么一说,宏任务是为了区分微任务而出现的概念)。

在 JSC 引擎的术语中,把宿主(Node、浏览器) 发起的任务称为宏观任务,把 JavaScript 引擎发起的任务称为微观任务

微任务是比宏观任务更小的任务,能使我们在重新渲染UI之前执行指定的行为,避免不必要的UI重绘,使得应用状态更连续。微任务有两个执行时机:

1、Event-Loop 中执行微任务检查点时(微任务检查点的存在是为了防止有宏任务为空而微任务不为空的场景)

2、任何脚本任务执行结束的时侯,微任务需要尽快的通过异步方式执行。

为什么会存在微任务?

微任务产生的原因:如果在 DOM 发生变化时,采用同步通知的方式,会影响当前任务的执行效率;如果采用异步通知则又会影响到监控的实时性,因为在添加到消息队列的过程中,可能前面就有很多任务在排队,于是微任务就应用而生了。

宏任务、微任务关系

二者又是什么关系呢?宏任务是主流,当 JavaScript 开始被执行时,就开启了一个宏任务,在宏任务中执行一条指令;宏任务可以同时有多个,但是由于任务在队列中存放,符合队列“先进先出”的特点,因此它们会按顺序一个一个执行。每一个宏任务后面都可以有一个微任务队列,如果微任务队列存在指令或者方法,则执行完当前微任务才会往下执行下一个宏任务;如果不存在,则执行下一个微任务。因此微任务的执行时长会影响到当前宏任务的时长。

宏任务、微任务的常见场景

常见的宏任务有以下几种:

  • 渲染事件(如解析 DOM、 计算布局、绘制等)

  • 用户交互事件(如鼠标点击、滚动页面、放大缩小等)

  • JavaScript 脚本执行事件

  • 网络请求完成、文件读写完成事件等

常见的微任务有: Promise 的回调函数、MutationObserver 等。

看了这么多,我们可以通过一段代码来检验一下这些理论知识,首先我们先看一下 setTimeout、 Promise、普通代码的输出顺序:

function sleep(duration) {
  return new Promise(function(resolve, reject) {
    console.log("b");  // 注意 new Promise() 是同步方法,resolve才是异步方法,执行顺序3  
    setTimeout(resolve,duration);
  })
}
console.log("a");  // 宏任务1,执行顺序1
setTimeout(() => { // 宏任务2
  sleep(1000).then(()=>console.log("c", new Date())); // 微任务1,执行顺序4
}, 0);
console.log("d"); // 宏任务3,执行顺序2

输出的结果如下:

和我们预想中可能不一样的是 setTimeout 也是宏任务,但是还是最后一个执行的宏任务。这是因为 setTimeout 属于定时器任务,会在其他宏任务执行完成之后再执行。

Async/Await

基本概念

ES7 中,JavaScript 引入了 async/awaitasync 是一个通过异步执行并隐式返回 Promise 作为结果的函数。使用 await 可以使用多个 Promise,并且支持多个 Promise 的多重嵌套。

可以说 async/await 是提供了在不阻塞主线程的情况下使用同步代码实现异步访问资源的能力,并且使得代码逻辑更加清晰。可以参考以下代码:

async function foo(){
   return 'hello';
}
console.log(foo()); // Promise {<fulfilled>: 'hello'}

await 表达式

在 MDN 官方文档中,await 操作符用于等待一个 Promise 对象。它只能在异步函数 async function 中使用。其返回值是 Promise 对象的处理结果;如果等待的不是 Promise 对象,则返回该值本身。

如果结合我们之前的宏任务、微任务知识、async/awiat 来思考以下代码执行顺序:

async function foo(){
    console.log(1);
    let a = await 100;
    console.log(a);
    console.log(2); 
}
console.log(0);
foo();
console.log(3);
// 执行结果: 0, 1, 3,100,2

输出结果可能和我们预料的不一样,那是哪个流程导致了这个不一样呢?我们可以跟着代码先走一遍:

首先,执行console.log(0), 打印0;

其次,我们执行到 testFunc 函数,执行console.log(1),打印1;按照我们之前的理解, await 100 返回的是100,这是应该执行console.log(a),打印100,可是实际上却先执行console.log(3)。这是什么原因呢?

在这里我们需要再多了解两个知识:生成器函数、协程。

生成器函数( Generator)是一个带星号的函数,可以暂停执行和恢复执行(使用过 redux-saga 的朋友们可能已经很熟悉了)。

可以通过代码来熟悉一下:

function * foo(){
  console.log(1);
  yield 'generator 1';
  
  console.log(2);
  yield 'generator 2';
  
  console.log(3);
  return 'generator 2';
}
console.log(0);
let gen = foo();
console.log('outside 1');
console.log(gen.next().value);
console.log('outside 2');
console.log(gen.next().value);
console.log('outside 3');
console.log(gen.next().value);

执行结果如下:

通过输出结果,我们可以发现 foo 函数不是一次执行完成的,全局代码和 foo 函数是一直处于交替执行的状态。也就是说如果在生成器函数内部执行一段代码,如果遇到 yield 关键字,JS引擎将会把 yield 关键字后面的内容返回给外部,并且暂停该函数的执行,外部函数则可以通过next方法执行。那内部引擎是怎么做到函数暂停和恢复的呢?这个时候就需要引入协程的概念。协程可以说是线程上正在执行的任务,协程不被操作系统所控制,而是完全由程序所控制,一个线程可以有多个协程,但线程只能执行一个协程。比如当前执行的是A协程,如果需要执行B协程,A协程需要把主线程的控制权交给B协程(如果A协程启动B协程,则称A为B的父协程)。

这时我们再看之前的示例,可以看见我们的代码在进入 foo 函数时,因为 foo 函数是被 async 标记过的,会启动一个 foo 协程,而我们的 await 100 相当于:

let promise_ = new Promise(resolve, reject) => {
  resolve(100);
});

而整体流程会如下流水线,在 await 100 返回了一个 promise_ 给父协程,相当于为父协程微任务队列增加了一个微任务。

总结

以上就是我个人理解的浏览器的事件循环,其实可以将我们的浏览器整个运行当作一个工厂流水线,而各个线程、任务只是线上的一道工具,理解了基本流程,就能对代码的运行结果更加有把握。