JS 中的事件循环 - 懂前端的都知道这里的门道有多深!

3 分钟
JavaScript基础知识

有人说:如果你不会事件循环,那么你就根本不会前端。听完我就来气了,我是不懂事件循环,但也完成了那么多需求,写了那么多代码,怎么我就不懂前端了呢?不行,我必须要看看这个事件循环到底是什么东西,到时候好和提出这个观点的人理论一番。下面跟我来看看事件循环到底是什么吧!

什么是事件循环 ?

事件循环是游览器渲染进程渲染主线程的工作方式。看到这个描述的我是一脸懵的,我就知道事情不简单,没办法只能拆解这句话不懂的地方逐个击破了,让我们先来看看什么是 渲染进程 吧 ~

渲染进程

小知识:游览器是多进程多线程的。很好理解,当你打开一个标签页面开始,其他标签页面不受影响,这就是说明一个标签页面就是一个进程,多线程就更好理解,一个标签进程下面就会有网络线程,渲染线程等等

说渲染进程之前可能有些人都不知道进程是什么!例如我,其实我们可以简单理解进程为:为「程序运行」分配的「内存空间」。打个比方,苹果手机的生产,生产出一部苹果手机的步骤就是程序,生产它需要的富士康工厂就是内存空间。那在游览器中程序工作也就需要为程序分配一个单独的空间,下面我们继续了解一下进程的特点,

  • 每个程序(应用)至少有一个进程
  • 进程可以有多个,进程之间相互独立,如果需要通信需要双方同意
  • 每个进程是隔离的,一个进程崩溃了,不会影响其他的进程

那渲染进程其实就是进程的一个实例,它继承进程的所有特点,渲染进程是也就是游览器三大进程之一,下面我们看一下游览器中的三个重要进程: 1)游览器进程:它负责 chrome 游览器的界面(是全局的界面)显示,用户交互,子进程管理(最开始游览器启动只有这一个进程,但是它会启动其他进程) 2)网络进程:加载网络资源 3)渲染进程:开启一个渲染主线程,主线程负责执行 HTML、CSS、JS

渲染主线程

渲染进程默认就是生成一个渲染主线程。肯定有小伙伴会不知道什么是线程?那个人还是我,线程简单理解为:运行程序代码的「东西」就是「线程」。继续说苹果手机的生产,在富士康(进程)中有很多生产线,例如生产摄像头的,生产屏幕的,那这每一条生产线就是一个线程。一个进程至少会有一个线程(主线程,也就是跟随进程启动的时候产生的线程),这里是因为内存空间是宝贵的,如果没有线程,内存空间(也就是进程)就会被释放。

进程和线程的关系:一个进程可以有多个线程,线程运行在进程中,同一进程内的线程共享进程的内存空间和资源,哦对了,同一进程中的线程可以并发执行(这在后面有用)

为什么渲染进程只有一个主线程 ? 渲染主线程要做哪些事情?

下面这些都是渲染主线程的工作

  • 解析 HTML
  • 解析 CSS
  • 计算样式
  • 布局
  • 处理图像
  • 每秒把页面画 60 次
  • 执行全局 JS 代码
  • 执行事件处理函数
  • 执行计时器的回调函数
  • 。。。。

那为什么渲染进程不适用多个线程来处理这么多的事情?而是全部交给渲染主线程?其主要原因是:

  1. JavaScript是单线程,如果开启多个线程,我们知道线程是可以并发执行的,那就需要处理复杂的线程同步问题
  2. 可能会出现多个线程同时操作 DOM 导致的竞态条件
  3. 一致性问题,如:dom 操作循序性和原子性、JavaScript 代码执行顺序

那现在知道了为什么使用一个渲染主线程来处理这么多任务。那为什么就一个单线程在处理同步,异步非常复杂的程序时,页面依然不卡呢?是的,主角要登场了,也就是我们强大的事件循环机制,通过它,让我们游览器主线程永不阻塞

事件循环机制

下面我们来看一下渲染主线程是如何工作的,也就是事件循环机制到底是什么!

最开始,渲染主线程会进入无限循环 每次循环都会去消息队列(message queue)查看是否有任务,有任务会执行任务,没有任务进入休眠 其他线程可以随时向消息队列添加任务(加入到队列末尾),如果新任务加入时渲染主线程在休眠,就渲染主线程会被唤醒 在执行任务的时候,其实任务可能会产生新的任务,这里可能不太好理解,我举个例子:

setTimeout(function () { 
  const loading = document.getElementById("loading")
  loading.addEventListener("click", function () {
    console.log("click loading");
  });
}, 1000);
console.log(1)

运行这段代码,发现是要创建定时器,是一个异步的任务,这时候它就会通知计时线程,把异步的任务交给其他线程来做,自己继续执行下面的代码打印一个 1 ,当 1 秒过后,计时线程会把计时器回调函数加入到消息队列中,然后游览器主线程会依次执行消息队列中的任务

肯定有聪明的小伙伴会问,任务有优先级吗?任务是按照什么顺序执行的呢?其实任务是没有优先级的,但是每一个任务都有一个任务类型,同一种任务类型的任务都在一个队列,不同任务类型可以分属于不同的队列(潜台词:不同的任务类型,可以在同一个队列)这里的队列指的就是消息队列,每错就是渲染主线程获取任务的那个消息队列。不同的消息队列是有优先级的,在 W3C 的规范中规定,游览器必须有一个微队列(microtask queue)微队列任务优先于所有其他任务队列

我们所说的微任务就是在微队列中,宏任务现在就是在其他队列中

渲染主线程通过异步的方式,实现了它自己永远都在执行同步任务,异步任务都交给其他线程来处理,其他线程处理好了再加入到消息队列中,渲染主进程通过不断执行消息队列的任务方式保证自己执行超多任务也不卡,给大家画一个渲染主线程工作图: 我们看一下这段代码结合上面的图,来更加深入了解事件循环机制

function delay(ms) {
  var start = Date.now();
  while (Date.now() - start < ms) {}
}
function f1() {
  console.log(1);
  Promise.resolve().then(function () {
    console.log(2);
  });
}

setTimeout(function () {
  console.log(3);
  Promise.resolve().then(f1);
}, 0);

Promise.resolve().then(function () {
  console.log(4);
});

document.addEventListener("click", function () {
  console.log(6);
});

delay(3000);

console.log(5);

这里运行是三秒后打印:

5
4
3
1
2

这里通过上面的知识,我们可以知道会把定时器加入延时队列 => 把 Promise.resolve().then() 加入微队列 => 把监听事件加入交互队列 => 死循环三秒 => 打印输出 5 => 从微队列拿到任务执行输出 4 => 在延时队列得到任务运行函数输出 3 => 微队列运行函数 f1 输出 1 => 微队列运行 输出 2 => 当触发点击事件输出 6

为什么要学习事件循环?

那到这里,学习事件循环的原因也很好理解了,事件循环是渲染主线程的工作原理,前端的所有工作也又都在渲染主线程进行,那不了解事件循环,确实是可以说不懂前端

面试题

面试题1:阐述一下 JS 的事件循环

参考答案:

事件循环又叫做消息循环,是浏览器渲染主线程的工作方式。

在 Chrome 的源码中,它开启一个不会结束的 for 循环,每次循环从消息队列中取出第一个任务执行,而其他线程只需要在合适的时候将任务加入到队列末尾即可。

过去把消息队列简单分为宏队列和微队列,这种说法目前已无法满足复杂的浏览器环境,取而代之的是一种更加灵活多变的处理方式。

根据 W3C官方的解释,每个任务有不同的类型,同类型的任务必须在同一个队列,不同的任务可以属于不同的队列。不同任务队列有不同的优先级,在一次事件循环中,由浏览器自行决定取哪一个队列的任务。但浏览器必须有一个微队列,微队列的任务一定具有最高的优先级,必须优先调度执行。

面试题2:JS 中的计时器能做到精确计时吗?为什么?

参考答案:

不行,因为:

  1. 计算机硬件没有原子钟,无法做到精确计时
  2. 操作系统的计时函数本身就有少量偏差,由于 JS 的计时器最终调用的是操作系统的函数,也就携带了这些偏差
  3. 按照 W3C 的标准,浏览器实现计时器时,如果嵌套层级超过 5 层,则会带有 4毫秒的最少时间,这样在计时时间少于 4毫秒时又带来了偏差
  4. 受事件循环影响,计时器的回调函数只能在主线程空闲时运行,又会带来偏差

此文自动发布于:github issues