【事件循环秘籍】人形浏览器的修炼之道(上篇)

967 阅读36分钟

前言

最近在刷面经的时候, 发现【事件循环】出现的频率还是挺高的。这让我想起几年前参加校招的时候,传的很火的一道字节的判断输出顺序的考察事件循环的代码题。

(不过也无法考证那道题到底是不是真的是字节出的,感觉存在蹭那时候宇宙厂热度的嫌疑。)

scriptUI renderingI/OPromise(async,await)setTimeoutprocess.nextTickMutationObserver等等,这些老朋友可都还熟悉?

image.png

如果我现在零帧起手放这样一道代码题,让你给出文本内容的正确输出顺序,你能确保自己回答正确吗?

console.log('1');

setTimeout(() => {
  console.log('2');
  new Promise((resolve) => {
    console.log('3');
    resolve();
  }).then(() => {
    console.log('4');
  });
}, 0);

new Promise((resolve) => {
  console.log('5');
  resolve();
}).then(() => {
  console.log('6');
});

setTimeout(() => {
  console.log('7');
  new Promise((resolve) => {
    console.log('8');
    resolve();
  }).then(() => {
    console.log('9');
  });
}, 0);

console.log('10');

(当然能,这有啥难的,复制一下到浏览器控制台,按下Enter ,不仅给你答对咯,还是秒答呢)

image.png

·

·

·

😄开个小玩笑~

不过话又说回来,这题从难度上来说还仅仅是开胃小菜,倘若掘友们真在面试中遇到了,那可以说是手拿把掐。

当然这个假设的前提是,好几年前........

image.png

大家都知道现在的环境是越来越难了,咱们这一行的技术门槛也是一直在变高。想要拿到自己期望的薪资,往往要付出比过去多好几倍的努力。

当我们在准备面试的时候,遇到【事件循环】这一部分的内容,我们不能仅仅满足于能够正确判断代码题的输出顺序这种程度。因为从最新的面经来看,面试官对【事件循环】的考察范围和要求已经今非昔比:

  • 浏览器的事件循环
  • Node的事件循环
  • Web Workers
  • Shared Workers
  • Service Workers
  • WebAssembly
  • Event Source
  • WebSocket

上述这些概念和工具都是可以从【事件循环】发散出去的内容。也是面试官对我们能力广度和深度进行考察时的部分判断依据。

作为一名前端工程师,或者说一名前端高级工程师,我们不能再让自己局限于只是判断正确那些代码题的输出顺序。

我们要对浏览器的工作原理有充分的了解,这对我们后续的Web性能优化错误定位等都有很大的帮助。

在本篇文章,我会从最基础的浏览器事件循环出发,逐步深入探讨JavaScript事件循环的工作原理,并向外扩展,结合业务代码,聊一聊其他帮助我们处理JavaScript线程的工具和概念,丰富大家的知识储备。

期望大家阅读完本篇文章之后(上篇和下篇),能够化身为人形浏览器。

image.png

进程和线程

在深入学习浏览器事件循环之前,理解进程和线程的概念是非常重要的。在本小节,我会引用科班教材的内容,帮助大家建立正确的基础概念,然后结合几个实际的例子,通过“理论指导实践,实践作用于理论”的方法论,加深各位的理解。

进程(Process

进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文(context)中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。——《深入理解计算机系统》

当我们通过向shell输入一个可执行目标文件的名字,运行程序时,shell 就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。

应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。

举个我们更熟悉的例子,新开一个网页,就是创建了一个新的进程。我们可以通过【任务管理器】(Ctrl+Alt+.)直观地看到正在运行中的进程。

如👇下图👇:

image.png

线程(Thread

线程就是运行在进程上下文中的逻辑流。现代操作系统允许我们编写一个进程同时运行多个线程的程序。线程由内核自动调度。每个线程都有它自己的线程上下文(thread context),包括一个唯一的整数线程 ID(Thread ID,TID)、栈、栈指针、程序计数器、通用目的寄存器和条件码。所有的运行在一个进程里的线程共享该进程的整个虚拟地址空间。——《深入理解计算机系统》

这里我用之前读书时浅浅学过的Java写一个简单的可以完成银行账务存款、取款的程序,来给大家展示一下具备多个线程的进程。

public class BankAccount {
    private double balance;

    public BankAccount(double initialBalance) {
        this.balance = initialBalance;
    }

    // 同步方法确保线程安全
    public synchronized void deposit(double amount) {
        balance += amount;
        System.out.println(Thread.currentThread().getName() + " 存款 " + amount + ",账户余额为 " + balance);
    }

    // 同步方法确保线程安全
    public synchronized void withdraw(double amount) {
        if (balance >= amount) {
            balance -= amount;
            System.out.println(Thread.currentThread().getName() + " 取款 " + amount + ",账户余额为 " + balance);
        } else {
            System.out.println("余额不足,无法取款 " + amount);
        }
    }

    public double getBalance() {
        return balance;
    }

    public static void main(String[] args) {
        BankAccount account = new BankAccount(1000.0); // 初始余额为1000元

        // 创建存款线程
        Thread depositThread = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                account.deposit(100.0);
                try {
                    Thread.sleep(100); // 模拟操作耗时
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "存款线程");

        // 创建取款线程
        Thread withdrawThread = new Thread(() -> {
            for (int i = 0; i < 3; i++) {
                account.withdraw(200.0);
                try {
                    Thread.sleep(100); // 模拟操作耗时
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "取款线程");

        // 启动线程
        depositThread.start();
        withdrawThread.start();

        try {
            // 等待线程执行完毕
            depositThread.join();
            withdrawThread.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 打印最终余额
        System.out.println("最终账户余额为: " + account.getBalance());
    }
}

在这个例子中:

  • BankAccount 类有一个余额字段和三个方法:deposit(存款)、withdraw(取款)和getBalance(获取余额)。
  • deposit 和 withdraw 方法都是同步方法,这意味着同一时间只有一个线程可以执行这些方法,从而保证了操作的原子性和线程安全。
  • 在 main 方法中,我们创建了两个线程,一个用于存款,另一个用于取款。每个线程都执行一个操作循环,并在操作之间暂停一段时间来模拟耗时操作。
  • 我们使用 join 方法等待每个线程完成,以确保主线程在打印最终余额之前所有线程都已完成它们的任务。

运行程序之后,可以看到控制台会输出以下内容:

image.png

看完这个代码例子之后,同样的,我们再聊一聊我们更熟悉的例子。在上一小节,聊进程的时候,我们说到,打开一个网页就是创建了一个新的进程。

比如我们打开稀土掘金 (juejin.cn),进入首页。

image.png

我们会发现这个页面并不仅仅是展示作用,它还能够支持用户通过键盘输入信息完成文章的检索,通过鼠标点击完成标签页的切换,还能查看文章中的视频,支持一些CSS动画(比如头像旋转)等等。

这些五花八门的功能背后,离不开多个线程的协同工作

  1. JavaScript线程

    • 负责解析和执行网页中的JavaScript代码。
    • 由于JavaScript的单线程特性,所以它可能会阻塞GUI渲染线程。
  2. GUI渲染线程

    • 负责页面的绘制,将DOM树转换成可视内容。
    • 处理CSS样式,进行布局计算和绘制。
  3. 网络线程

    • 负责处理网络资源的加载,例如下载HTML页面、JavaScript文件、CSS样式表、图片等。
  4. 定时器线程(例如setTimeoutsetInterval):

    • 管理定时器,当定时器到期时,将回调函数放入事件队列中等待执行。
  5. 事件触发线程

    • 监听事件(如鼠标点击、键盘输入、网络事件等),并将它们放入事件队列。
  6. Web Worker线程

    • 允许运行在后台的JavaScript代码,执行计算密集型或高延迟的任务,而不影响主线程。
  7. 浏览器插件线程

    • 每个浏览器插件可能都有自己的线程,用于执行插件代码。
  8. 合成线程

    • 用于将页面分成多个层,并在GPU线程的帮助下进行合成,以优化渲染性能。
  9. I/O线程

    • 处理磁盘读写操作,例如本地存储(IndexedDB、localStorage)。

这些线程之间通过事件循环(Event Loop)、消息队列(Message Queue)和任务调度机制来协作。

例如,当用户发起一个HTTP请求时,网络线程负责发送请求并接收响应,然后将响应数据传递给主线程进行后续处理,如更新DOM。定时器线程会在定时器到期时将回调函数添加到任务队列中,等待JavaScript线程空闲时执行。

二者关系

image.png

(一个进程可以有多个线程,也可以只有一个线程)

浏览器事件循环

在上一章节的内容里,我们提到了进程线程,并用浏览器相关的内容做了举例。那么,在正式聊浏览器的事件循环之前,我期望大家带着3个问题进行阅读:

  • What:什么是事件循环
  • Why:为什么需要事件循环
  • How:事件循环是怎么生效的

浏览器是如何渲染页面的

在了解浏览器为什么需要事件循环机制之前,我们先简单回顾一下浏览器是如何渲染页面的。

(如果你期望得到一个非常完整的全过程,如从输入url到页面实际渲染的整个流程,可以访问这个开源项目:超详细文章

image.png

渲染过程

当我们输入url之后,浏览器会进行如下几个步骤:

  1. 响应:首先,浏览器会向服务器发送HTTP请求,以获取对应的资源文件,通常是HTML文件。
  2. 解析:拿到资源之后,通过HTML解释器和CSS解释器将其转换成对应的DOM树和CSSOM树。
  3. 样式:接着,浏览器会将DOM树和CSSOM树合并,生成渲染树(Render Tree)。
  4. 布局:然后,从根节点开始递归调用,为渲染树中的每一个节点给出其在屏幕上出现的精确坐标,递归结束后,我们就得到了一颗带有布局信息的渲染树。
  5. 绘制:最后,浏览器使用UI后端层绘制每个节点,将内容显示在屏幕上。

(若对渲染的每一步期望加深理解,可以访问渲染页面:浏览器的工作原理 - Web 性能 | MDN (mozilla.org)

渲染模式

聊到浏览器渲染,那就顺便提到一下出场率较高的2种渲染模式:CSR和SSR。本文不做额外的篇幅,仅提一下如何区分一张网页是CSR还是SSR

服务端渲染(SSR)

我们访问力扣的首页,或者是掘金、知乎的首页,通过上一节内容中提到的浏览器渲染的过程,我们知道这些网页都会先走第一个环节:响应——即请求对应的资源文件。

我们按下F12,再将调试工具切换至【网络】, 可以看到返回给客户端的是一串HTML字符串,客户端拿到手之后可以直接渲染然后呈现其内容。

image.png

image.png

我们可以直接在【网络/预览】那里直接看到👇没有完全加载CSS样式的页面👇:

image.png

如果我们选择“查看网页源代码”的话,可以在里面找到页面上的内容

image.png

客户端渲染(CSR)

还是同样的步骤,当我们进入具体某一道算法题的详情页面时,我们会发现无法在【网络/预览】那里看到页面的大致样子,而是只能看到白屏。

image.png

在左侧我们能够实际看到的页面中的内容,比如题目的信息、做题的代码区域等等,都藏在了<script src="xxxxxx.js">中。

此时,如果我们也选择“查看网页源代码”的话,里面就找不到页面上的内容了

image.png (一大片的<script src="xxxxxx.js">)

JavaScript登场

在前面的内容里,我们提到了浏览器渲染的过程以及渲染的模式。

当网页以客户端模式渲染时,我们无法在【网络/预览】处查看到页面的大致内容。因为客户端加载完毕资源文件,要在浏览器里跑一遍JavaScript(也就是之前提到的<script src="xxxxxx.js">),根据JavaScript的运行结果,生成相应的DOM,最终将内容呈现在页面上。

在过了一遍浏览器渲染过程之后,我们知道网页的内容呈现离不开渲染树的构造,即离不开DOM树和CSSOM树。

既然浏览器在加载完JavaScript文件后,使得网页上呈现的内容发生变化了,我们便不难推出,JavaScript的运行结果导致原来的DOM树和CSSOM树发生了变化,从而最终导致绘制在屏幕上的内容变化。

于是我们可以得出这样的一个结论:

JavaScript的作用在于修改,它帮助我们修改网页的方方面面:内容、样式以及它如何响应用户交互。这“方方面面”的修改,本质上都是对 DOM 和 CSSDOM 进行修改。因此 JS 的执行会阻止 CSSOM,在我们不作显式声明的情况下,它也会阻塞 DOM。——《前端性能优化原理与实践》

为什么需要事件循环

在了解了JavaScript在浏览器渲染中扮演的角色之后,我们进一步探讨为什么浏览器需要事件循环机制。

渲染会被阻塞

默认情况下,JavaScript 的解析和执行会阻塞渲染。这意味着浏览器在遇到 JavaScript 之后,会阻塞解析任何出现在其后的 HTML 代码,直到脚本处理完成。因此,样式和绘制也会被阻塞。——MDN

让我们通过一个代码例子来模拟一下渲染被阻塞时的场景。

(该例子以waiter/Script-Load: HTML的script标签加载与执行时机示例 (github.com)这个开源项目为基础,做了二次修改)

默认加载模式的代码
<html>

<head>
  <title>测试js阻塞渲染</title>
  <script>
    document.addEventListener('DOMContentLoaded', (event) => {
      console.log('DOMContentLoaded');
    });
    window.addEventListener('load', (event) => {
      console.log('loaded');
    });
  </script>
</head>

<body>
  <div>开始解析DOM</div>
  <script src="/1.normal1.js?wait=3000"></script>
  <div>等待解析的节点1</div>
  <div>等待解析的节点2</div>

</body>

</html>

我们在3个<div>元素中插入了一个<script>,用于加载JS文件并执行,这个加载的过程将会耗时3秒。让我们运行这个代码,一起来看看结果如何吧。

默认加载模式的动图

动画.gif

由上图可以看到,后续的2个<div>元素的渲染确实被阻塞了,直到<script>加载并执行完JS之后(也就是3秒后),HTML才继续进行解析工作,将剩余的部分在页面上渲染出来。

这显然对网页的访问者造成了不太良好的使用体验,第一时间他所面对的网页是残缺的。

我们期望JS的加载和执行不会阻塞HTML的解析,让我们的页面能够以完整的姿态呈现给用户。

该怎么做呢?给<script>标签设置defer或者async即可。

让我们一起来修改一下代码,然后看看结果吧。

defer加载模式的代码
...
  <script src="/1.normal1.js?wait=3000" defer></script>
...
defer加载模式的动图

动画defer.gif

可以看到,js的加载和执行不再阻塞HTML的解析工作,页面在一开始时就是完整的姿态。

async加载模式的代码
...
  <script src="/1.normal1.js?wait=3000" defer></script>
...
async加载模式的动图

动画async.gif

可以看到,js的加载同样不再阻塞HTML的解析工作。

不同点

虽然看起来defer和async实现的效果是一样的,但是我们留心右侧控制台的内容,会发现,defer是在 DOMContentLoaded之前完成执行的,而async是在DOMContentLoaded之后执行的。

DOMContentLoaded:当 HTML 文档完全解析,且所有延迟脚本(<script defer src="…"> 和 <script type="module">)下载和执行完毕后,会触发 DOMContentLoaded 事件。

👇这里我附上了3种加载模式对于HTML解析的影响示意图。👇 image.png

于是我们可以得出这样的结论:

  • async:当我们的脚本与 DOM 元素和其它脚本之间的依赖关系不强时,选用此模式
  • defer:当我们的脚本依赖于 DOM 元素和其它脚本的执行结果时,选用此模式

我们可以通过合理地设置<script>标签的加载模式,来避免一些不必要的阻塞,从而提升性能。

扩展

更多有关<script>标签的详细信息,大家可以访问HTML 标准 (whatwg.org) 文档查阅。

单线程的JavaScript

在上一节的内容中,我们聊到了通过script标签加载JS时,默认的加载模式会阻塞HTML的解析工作,从而导致页面渲染的中止。

阻塞渲染的JS本身,在自己的执行过程中,也会发生阻塞

JavaScript在设计上是单线程的,这意味着在浏览器中,JavaScript代码在同一时间只能执行一个任务。

单线程的设计简化了编程模型,但也带来了一些限制:如果一个任务需要花费大量时间来处理,那么它后面的任务就必须等待,这可能导致用户界面冻结,用户体验下降,如:

  • I/O阻塞:JS执行过程中,如果遇到需要等待I/O操作(如网络请求、文件读写等),整个页面都会被阻塞。

    渲染线程:“不是哥们,我好不容易解决了加载JS会阻塞HTML解析的问题,结果你JS自己执行任务的时候还是照样会出现阻塞的情况啊,合着我这费半天劲全白忙活了呗,你这不太合适了吧。”

    JS:

    image.png

题外话:为什么JavaScript不能是多线程的?

在之前章节的内容里,我们明白了JS的定位,它扮演着“修改”的角色(响应用户的交互、修改DOM)。

假如JS是多线程的,即同一时间可以做多件事情,那么当线程A期望在某个DOM节点中修改文本的内容,而线程B却期望直接删除该DOM节点时,浏览器该以哪一方为准?

浏览器:“已老实,求放过。”

这就是所谓的如果JS是多线程的,可能会导致不可预测的行为和混乱

image.png

然而,随着多核CPU的普及,单线程模型在处理一些计算密集型或高延迟的任务时显得不够高效。

因此 W3C在HTML5中提出了新标准:Web Worker。

(其实也不算新了,2014年就提了,官方文档最近更新的记录是2022年。)

我们一起来看看官方文档HTML Standard (whatwg.org)

image.png

对于中文文档的翻译,我个人觉得第一句翻得不太好,于是修改了下,大家可以看下对照用的中文版定义。

image.png

从定义中可以看出,它允许JavaScript脚本创建多个线程,这些子线程无法操作DOM(这就避免了我们在一开始讨论多线程时,提出的同时操作DOM的问题),并且完全受主线程控制。

在一个 worker 中最主要的你不能做的事情就是直接影响父页面。包括操作父页面的节点以及使用页面中的对象。你只能间接地实现,通过 DedicatedWorkerGlobalScope.postMessage 回传消息给主脚本,然后从主脚本那里执行操作或变化。——MDN

这样看来,虽然Web Worker允许JavaScript脚本创建多个线程,但并没有改变JavaScript单线程的本质。

image.png

至于worker的创建方式(const myWorker = new Worker("worker.js"); )、以及它的各种类型,本篇文章不再扩展,咱们下一篇再叙🍹。

同步与异步

为了不被大家讨厌为了解决单线程的局限性,JavaScript引入了同步和异步的概念。

  • 同步任务:在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务。
  • 异步任务:不进入主线程,而是进入"任务队列"(Task Queue)的任务。只有当任务队列通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

在介绍完这俩的概念后,我们一起来看看“JavaScript运行时(Runtime)”(你也可以称之为运行环境)的示意图(以V8引擎为例)。

image.png

运行时有2大部分,堆负责分配内存,栈负责调用函数。

而在这张运行图里,我们可以发现,似乎上面提到的异步任务,比如setTimeout,HTTP请求以及和浏览器相关的DOM操作也好,都并不存在

换句话说,如果我们去git clone整个v8引擎的代码仓库,我们也同样找不到它们。

因为它们都属于Web APIs,需要依赖于浏览器。

那么,浏览器到底是如何去统筹协调同步任务和异步任务,以及如何管理“任务队列”中的任务从而实现它们的有序执行呢?

让我们有请本文的主角,事件循环!

image.png

事件循环

同样的,在介绍它之前,先让我们一起来看看W3C上的标准定义

image.png

为了协调事件、用户交互、脚本、渲染、网络等,用户代理必须使用本节描述的 事件循环。 每个 代理 有一个关联的 事件循环,它对每个代理是唯一的。

我们可以画一张基于JavaScript Runtime的👇事件循环示意图👇:

image.png

(参考自Philip Roberts非常有名的演讲:《What the heck is the event loop anyway?)》)

在上图中我们可以看到,JS在运行时,栈中的函数也许会调用Web API,当这些函数执行完毕被出栈后,它们对于外部API的调用会使其的回调函数被添加至回调队列。而等到栈中全部的函数执行完毕时(即栈为空时),回调队列中的那些遗留下来的回调函数会被按照FIFO(first in,first out)的顺序推入栈中,依次执行。

(推进栈一个,执行完一个,栈弹出一个,栈为空之后,再推进去下一个)。

这个过程将不断重复。

事件循环的作用:

  1. 协调任务执行:事件循环负责管理任务队列中的任务,确保它们按照正确的顺序执行。它不断检查主线程是否空闲,如果空闲,则从任务队列中取出一个任务执行。
  2. 处理用户交互:在用户与网页交互时(如点击按钮、输入文字等),事件循环确保这些事件能够被及时响应,而不会因为当前正在执行的任务而被阻塞。
  3. 非阻塞I/O操作:当进行网络请求或读取文件等I/O操作时,事件循环允许JavaScript继续执行其他任务,而不是等待I/O操作完成。
  4. 维持浏览器性能:通过事件循环,浏览器可以保持界面的流畅性,即使在进行复杂计算或等待异步操作时也不会卡顿。

当然,对于示意图的文字描述只是简单地概括了一下Event Loop的大致行为,并没有具体到细节,比如任务队列是需要区分宏任务(macro task)与微任务(micro task)的,以及任务执行完毕后和触发渲染之间的关系等等。

铺垫完毕全部的前置信息后,接下来就一起走一遍事件循环的完整过程吧。

image.png

事件循环的工作流程

首先,我们要复习一遍常见的两种类型的任务都有哪些,这将帮助我们在阅读代码的时候,更容易地站在浏览器的立场上进行思考。

  • 常见的宏任务(macro task):script(整体代码)、setTimeout、setInterval、 setImmediate、 I/O 操作、UI 渲染等。
  • 常见的微任务(micro task):Promise、MutationObserver、queueMicrotask、process.nextTick等。

接下来,我们终于开始解析事件循环的全过程:

  1. 首先,调用栈为空,微任务(micro)队列也为空,宏任务(macro)队列中有且只有1个任务,那就是script脚本(即整体的js代码)。

    • 不知道大家是否听说这样一个观点:“微任务的执行一定快于宏任务。”
    • 但是现在,当我们分析完第一步之后,是不是马上即可驳倒这个观点了?因为script属于宏任务,而浏览器一定会最先执行一个script,也就是最先执行一个宏任务。因此呢,微任务的执行一定快于宏任务并不是绝对
  2. 由于微任务队列为空,调用栈也为空,因此script从宏任务队列中弹出,被推入调用栈,同步代码开始执行。当浏览器开始执行JS代码时,会遇到一些可能调用了Web API的函数(setTimeout等等),以及没有调用的函数,这将会产生新的宏任务和微任务,新产生的这些任务会根据任务类型被推入对应的任务队列。当script被执行完毕后,它将从调用栈中弹出。

  3. 此时调用栈又处于空的状态。接下来浏览器会怎么做呢?

    • 首先判断微任务队列是否为空,如果不为空,则将微任务队列中的任务弹出,推入调用栈执行。这部操作后,如果微任务列队还是不为空,继续将微任务从微任务队列中弹出,推入调用栈执行,直到微任务队列为空后停止。
    • 当微任务全部执行完毕后(即微任务队列处于空的状态),再从宏任务队列弹出宏任务,将其推入调用栈,执行它。
      • 这里需要注意的是,如果被推入调用栈并且执行的宏任务又产生了微任务的话,浏览器在执行完当前的宏任务后,并不会继续再从宏任务队列中弹出宏任务并将其推入调用栈执行了,而是优先把刚刚新产生的微任务先从微任务队列弹出,并推入调用栈执行。也就是说,执行宏任务的前提是微任务队列必须为空
  4. 当微任务队列为空后(即当前产生的微任务全都执行完毕后),浏览器执行渲染操作,更新界面。

    • 不过呢,浏览器可能会合并多个渲染操作以优化性能,不一定每次事件循环都会触发渲染。
    • 比如,批处理:浏览器可能会将多个DOM变更、样式变更或者布局变更等渲染相关的操作进行批处理。这意味着浏览器不会对每一个小的变更都立即执行渲染操作,而是会在一段时间内收集这些变更,然后一次性处理。
  5. 检查是否存在Web worker任务,有则做相应处理。

    • Web Workers有自己的全局环境,它们的事件循环与主线程的事件循环是分开的,不过可以被主线程使用terminate方法终止,自己也可以通过postMessageonmessage与主线程进行消息传递。
  6. 事件循环继续检查调用栈、宏任务队列和微任务队列,如此循环。

文字和图片可能还不是那么直观能够让大家体会到这个完整的过程,因此我这里附上了一个可视化事件循环的在线网站(github上超过1000颗⭐的项目),大家可以试试哦~

网站截图

image.png

动图

动画掘金dongtu网站.gif

练习题

理论知识铺垫了很多,我觉得还是需要一些代码题练练手的,看看自己的掌握水平,因此感兴趣的掘友可以做做看。

image.png

题目一
console.log('1');

setTimeout(() => {
  console.log('2');
  new Promise((resolve) => {
    console.log('3');
    resolve();
  }).then(() => {
    console.log('4');
  });
}, 0);

new Promise((resolve) => {
  console.log('5');
  resolve();
}).then(() => {
  console.log('6');
});

setTimeout(() => {
  console.log('7');
  new Promise((resolve) => {
    console.log('8');
    resolve();
  }).then(() => {
    console.log('9');
  });
}, 0);

console.log('10');

答案

1 5 10 6 2 3 4 7 8 9

解析
  • console.log('1'); 输出 1。
  • console.log('5');  输出 5,因为new Promise  是同步代码。
  • console.log('10'); 输出 10,因为这也是同步代码。
  • new Promise 的  .then() 微任务执行,输出 6。
  • 第一个  setTimeout 宏任务执行,输出 2,接着 new Promise 同步代码执行输出 3, .then() 微任务被添加到微任务队列。
  • 第一个  setTimeout 宏任务执行完毕后,执行微任务队列中的  .then(),输出 4。
  • 第二个  setTimeout 宏任务执行,输出 7, new Promise 同步代码执行输出 8, .then() 微任务被添加到微任务队列。
  • 第二个  setTimeout 宏任务执行完毕后,执行微任务队列中的  .then(),输出 9。
  • 动图

    动画掘金题目1.gif

    题目二
    console.log('A');
    
    setTimeout(() => {
      console.log('B');
      new Promise((resolve) => {
        console.log('C');
        resolve();
      }).then(() => {
        console.log('D');
      });
    }, 0);
    
    new Promise((resolve) => {
      console.log('E');
      resolve();
    }).then(() => {
      console.log('F');
      return new Promise((resolve) => {
        console.log('G');
        resolve();
      }).then(() => {
        console.log('H');
      });
    });
    
    console.log('I');
    
    
    
    答案

    A E I F G H B C D

    动图

    动画掘金题目2.gif

    Node事件循环

    image.png

    在之前的章节中,我们详细探讨了浏览器的事件循环机制。众所周知,JavaScript的核心运行环境是V8引擎,它不仅广泛应用于浏览器环境中,还被应用于Node.js。

    Node.js作为基于Chrome V8引擎的JavaScript运行时环境,其事件循环机制与浏览器中的事件循环有着异曲同工之妙,但又存在一些差异。

    接下来,我们将深入探讨Node.js的事件循环。

    Node.js事件循环概述

    image.png

    什么是事件循环?

    事件循环通过尽可能地将操作交给系统内核来处理,使得Node.js能够执行非阻塞的I/O操作(虽说默认情况下只使用单个JavaScript线程)

    由于绝大多数的现代操作系统内核是多线程的,它们能够在后台执行多个操作。当这些操作之一完成时,内核会通知Node.js,以便将相应的回调添加到轮询队列中以供最终执行。

    当Node.js启动时,它会初始化事件循环,处理提供的输入脚本,该脚本可能会发出异步请求、安排定时器的生效时间、调用process.nextTick(),然后开始事件循环。

    👇下面的这张图是事件循环执行顺序的简化概述👇:

       ┌───────────────────────────┐
    ┌─>│           timers          │
    │  └─────────────┬─────────────┘
    │  ┌─────────────┴─────────────┐
    │  │     pending callbacks     │
    │  └─────────────┬─────────────┘
    │  ┌─────────────┴─────────────┐
    │  │       idle, prepare       │
    │  └─────────────┬─────────────┘      ┌───────────────┐
    │  ┌─────────────┴─────────────┐      │   incoming:   │
    │  │           poll            │<─────┤  connections, │
    │  └─────────────┬─────────────┘      │   data, etc.  │
    │  ┌─────────────┴─────────────┐      └───────────────┘
    │  │           check           │
    │  └─────────────┬─────────────┘
    │  ┌─────────────┴─────────────┐
    └──┤      close callbacks      │
       └───────────────────────────┘
    

    (图源Node.js官方文档

    Node.js的事件循环是基于libuv库实现的,它是一个封装了操作系统底层API的跨平台库。Node.js的事件循环主要分为以下几个阶段:

    1. 定时器阶段(Timers) :处理setTimeout和setInterval回调函数,并且受到poll阶段控制。(在Node.js中,定时器设置的阈值也并不是准确时间,只能做到尽快执行,下文会举一个例子,和大家一起过一遍。)

    2. 待处理回调阶段(Pending Callbacks) :执行I/O操作的回调,例如TCP错误类型(如果一个TCP套接字在尝试连接时收到ECONNREFUSED错误,而 *nix系统希望等待这些错误被报告),这会被送入回调队列中,在此阶段完成执行。

    3. 空闲、准备阶段(Idle, Prepare) :仅内部使用。

    4. 轮询阶段(Poll) :轮询阶段主要有两个功能:

      1. 计算它应该阻塞和轮询I/O的时间,然后 
      2. 处理轮询队列中的事件。 

      当事件循环进入轮询阶段且没有安排定时器任务时,以下两件事之一会发生:

      • 如果轮询队列非空,事件循环将遍历其回调队列,同步执行它们,直到队列耗尽或达到系统依赖的硬限制。
      • 如果轮询队列为空,以下两件事之一会发生:
        • 如果setImmediate()队列非空,事件循环将结束轮询阶段并进入检查阶段以执行setImmediate()的回调。
        • 如果setImmediate()队列为空,事件循环将等待其他回调任务被添加到轮询队列中,然后立即执行它们。

      一旦轮询队列为空,事件循环将检查是否有达到时间阈值的定时器。如果有一个或多个定时器已准备好,事件循环将回到timers阶段以执行这些定时器的回调。

    5. 检查阶段(Check) :执行setImmediate()的回调。

    6. 关闭回调阶段(Close Callbacks) :处理关闭事件,如socket.on(‘close’, …)。

    每个阶段都有一个FIFO的回调队列要执行。当队列耗尽或者超出了回调限制时,事件循环才会进入下一个阶段。

    同之前我们在聊的浏览器事件循环一样,当前被执行的任务很有可能也会生成更多的新的操作或者事件,新生成的这些任务会被内核安排进对应阶段的任务队列。

    不过需要注意的是,在轮询阶段(poll phase)处理轮询事件时,由内核排队的新事件的回调任务可能会被加入到轮询任务队列中。因此,长时间运行的回调可能会使轮询阶段运行的时间远超过定时器的阈值。

    这意味着什么呢?

    这意味着定时器将不能按照设定的时间正确的执行。

    让我们一起来看一下官方文档中给出的代码例子

    
    const fs = require('node:fs');
    function someAsyncOperation(callback) {
      // 假设这需要95毫秒来完成
      fs.readFile('/path/to/file', callback);
    }
    const timeoutScheduled = Date.now();
    setTimeout(() => {
      const delay = Date.now() - timeoutScheduled;
      console.log(`${delay}ms have passed since I was scheduled`);
    }, 100);
    // 执行需要95毫秒完成的someAsyncOperation
    someAsyncOperation(() => {
      const startCallback = Date.now();
      // 执行需要10毫秒的操作...
      while (Date.now() - startCallback < 10) {
        // 什么都不做
      }
    });
    

    我们一起来过一遍这个例子,顺便也是过一遍Node.js的事件循环。

    首先Node.js启动,它会初始化事件循环,发起I/O 事件(fs.readFile)、安排setTimeout的生效时间。全部ok后,正式开始事件循环。

    • timers阶段没有发现任何到期的定时器(毕竟咱们设置的时间是100ms),即任务队列为空,于是从timers阶段直接进入pending阶段。
    • 在pending阶段,也没有发现任何从上一轮被延迟下来的待执行的I/O 回调,即任务队列为空,因此进入poll阶段。
    • 在poll阶段,此时轮询任务队列是空的(因为还在等待内核返回文件读取的结果。假设读取的时间是95ms,fs.readFile()此时还没有完成),那么由于轮询队列是空的,于是事件循环将检查是否有快到期的定时器,如果有,将停滞在poll阶段等待那个最快的定时器到达阈值,然后执行它的回调任务。
      • 然而,就在等待的时候(要等100ms),文件读取完成了(因为只要95ms),于是对应的I/O 回调被添加进了轮询队列,此时轮询队列不为空了,于是事件循环要开始执行轮询队列中的任务。
      • 轮询队列中的任务执行完毕总共需要10毫秒的时间,在执行到一半的时候,定时器设置的100毫秒阈值到了,本该到期后就立刻执行的定时器回调,由于当前还在执行轮询队列中的I/O 回调任务,必须等待此任务执行完毕(10毫秒走完),轮询任务队列为空,才能执行定时器的回调。
      • 于是,本该100毫秒后就执行的定时器回调任务,硬是等到105毫秒才能被执行,产生了5毫秒的延迟。

    To prevent the poll phase from starving the event loop, libuv (the C library that implements the Node.js event loop and all of the asynchronous behaviors of the platform) also has a hard maximum (system dependent) before it stops polling for more events——Node.js

    为了防止轮询阶段耗尽事件循环,libuv(实现 Node.js 事件循环和平台上所有异步行为的 C 库)还设置了一个硬性最大值(与系统相关),在达到该值之前会停止继续轮询更多事件。

    聊到这里,也许你发现了,我们刚刚提到的I/O 操作也好,定时器任务setTimeout也好,都属于“宏任务”的范畴,那么“微任务”在Node.js的事件循环中又是按照什么规则执行的呢?

    微任务队列会在以上每个阶段完成前清空。

    process.nextTick()

    我们会发现,process.nextTick()没有在之前的事件循环执行顺序的简化概述图中展示,尽管它是异步API的一部分。

    这是因为process.nextTick()严格来说不是事件循环的一部分(它拥有自己的一个专属队列)。nextTick队列将在当前操作完成后处理,而不管事件循环的当前阶段如何。在这里,操作被定义为从底层C/C++处理程序到处理需要执行的JavaScript的转换。

    我们在任何阶段调用process.nextTick()时,所有传递给process.nextTick()的回调都将在事件循环继续之前被解决。

    当上述的六个阶段完成后,如果当前nextTick队列不为空,则会清空此队列中的所有回调函数,并且优先于之前我们提到的微任务队列。

    让我们看一个例子

    setTimeout(() => {
        console.log('timer1')
       
        Promise.resolve().then(function() {
          console.log('promise1')
        })
        Promise.resolve().then(function() {
          console.log('promise2')
        })
        Promise.resolve().then(function() {
          console.log('promise3')
        })
       }, 0)
    
       
       
       process.nextTick(() => {
        console.log('nextTick')
        process.nextTick(() => {
          console.log('nextTick')
          process.nextTick(() => {
            console.log('nextTick')
            process.nextTick(() => {
              console.log('nextTick')
            })
          })
        })
       })
       
    

    这段代码的输出是这样的

    image.png

    为什么使用process.nextTick()? 

    有两个主要原因:

    • 允许开发者先处理错误、清理任何不再需要的资源,或者尝试重新请求,然后再继续事件循环
    • 有时需要让回调在事件循环开始下个阶段之前运行

    setImmediate()

    setImmediate和setTimeout相似,但是根据调用时间的不同,它们的行为也不同。

    • setImmediate被设计为在当前轮询poll阶段完成后执行脚本。
    • setTimeout计划在以毫秒为单位的最小阈值过去之后运行脚本。

    setImmediate()相较于setTimeout()的主要优势在于,如果在I/O周期内,setImmediate()总是会比任何timers都快。

    让我们看一个官方文档里的例子:

    // timeout_vs_immediate.js
    const fs = require('node:fs');
    
    fs.readFile(__filename, () => {
      setTimeout(() => {
        console.log('timeout');
      }, 0);
      setImmediate(() => {
        console.log('immediate');
      });
    });
    
    

    我们在I/O的回调函数中创建了setTimeout()和setImmediate()。当内核完成读取文件的操作之后,会执行I/O的回调函数,这也就意味着会进入第2个阶段pending callbacks,当在此阶段处理完回调函数时(即创建两个定时器任务),对于第3个阶段poll来说,此时轮询队列为空,并且setImmediate()的队列不为空,于是就会直接进入第4个阶段check,去执行setImmediate()的回调函数,输出'immediate'。再然后,在下一轮事件循环中的timers阶段,执行setTimeout()的回调函数,输出'timeout'

    前者是本轮循环的下一个阶段就执行,而后者是下一轮循环的timers阶段才执行,执行快与慢的差异由此可见一斑。

    Node.js事件循环与浏览器事件循环的差异

    1. 任务来源

      • 浏览器:浏览器的事件循环主要处理用户交互、DOM 操作、定时器等任务。
      • Node.js:Node.js 的事件循环处理的是服务器端的 I/O 操作,例如文件读写、网络请求等。
    2. 执行顺序

      • 浏览器:浏览器的事件循环并不是区分具体的某某阶段如何,而是根据当前调用栈、宏任务队列、微任务队列的状态正确地执行任务。
      • Node.js:Node.js 的事件循环是按照上述六个阶段依次执行的。
    3. 执行环境

      • 浏览器:浏览器的事件循环运行在浏览器进程中,与 DOM 渲染线程紧密相关。
      • Node.js:Node.js 的事件循环是基于 Libuv 库实现的,利用底层操作系统的多线程特性,处理更高并发的请求。
    4. 定时器精度

      • 浏览器:浏览器中的定时器精度通常为4ms(来自HTML5的标准)。
      • Node.js:Node.js中的定时器精度依赖于操作系统的实现。

    结语

    在本篇文章中,我们以一道事件循环的经典面试题为起点,深入探讨了JavaScript事件循环机制的核心原理。通过对比浏览器和Node.js环境下事件循环的差异,我们不仅揭示了事件循环在不同运行环境中的具体表现和工作方式,还剖析了其背后的设计哲学和实现细节。

    文章中,我们一步步解析了事件循环的各个阶段,包括宏任务和微任务的执行顺序,以及它们在异步编程中的重要性。此外,我们还通过实例演示了事件循环在实际应用中的影响,帮助掘友们更好地理解这一关键概念。

    在下一篇文章中,我们将一起探讨在【前言】章节提到的扩展内容,此内容涵盖一系列前沿技术,我们将逐一介绍如下主题:

    • Web Workers:我们将探讨如何利用Web Workers在浏览器中实现真正的多线程,从而在不影响主线程的情况下执行耗时任务,提升应用的响应性能。
    • Shared Workers:我们将一同学习Shared Workers的概念,探讨它们如何被多个浏览上下文共享,以及如何在复杂的Web应用中实现跨标签页或窗口的通信。
    • Service Workers:我们将深入研究Service Workers的强大功能,包括它们如何拦截网络请求、管理缓存以及如何在离线状态下提供丰富的用户体验。
    • WebAssembly:我们将探究WebAssembly(Wasm)这一新兴技术,它如何允许开发者将C/C++、Rust等语言编译成在Web上运行的代码,从而大幅提升Web应用的性能。
    • Event Source:我们将探讨Event Source API如何实现服务器到客户端的单向通信,以及它是如何用于构建实时Web应用的。
    • WebSocket:最后,我们从WebSocket协议出发,探讨它如何实现全双工通信,以及在实时数据传输和交互式应用中的关键作用。

    通过这些扩展内容的探讨,旨在帮助掘友们掌握构建高性能、响应式和现代Web应用的关键技术,为Web开发之旅增添更多强大的工具和策略。

    我们下一篇文章见~

    image.png