消息队列和事件循环机制

概述

考察浏览器的事件循环机制通常以题目的形式出现,比如下面这道面试题。

function task () { return new Promise((resolve, reject) => { resolve(); }); } function callback2 () { task().then(res => { console.log('5'); }) return task(); } function callback1 () { callback2() .then(() => { console.log('3'); }); setTimeout(() => { console.log('4'); }, 0); } console.log('1'); callback1(); console.log('2');

你可能知道做这种题目的顺序,先同步再异步,先微后宏,继而输出正确答案。

然而如果问你为什么浏览器会这样输出,可能就会泛泛而谈了,因为你并没有真正理解浏览器的事件循环机制。

下面我们就从浏览器的底层开始讲起,让你明白为什么会输出这样的结果。

浏览器的多进程架构

了解事件循环之前,我们首先要知道浏览器是多进程的。

这篇文章的分析是基于 Chrome 浏览器的。Chrome、微软的 Edge 以及国内的大部分主流浏览器,都是基于 Chromium 二次开发而来,而 Chrome 是 Google 的官方发行版,特性和 Chromium 基本一样,只存在一些产品层面差异,再加上 Chrome 是目前世界上使用率最高的浏览器,所以 Chrome 最具代表性。

关于 Chrome 浏览器的多进程架构,你可以参考下图。

从图中可以看出,Chrome 浏览器包括:1 个浏览器(Browser)主进程、1 个 GPU 进程、1 个网络(NetWork)进程、多个渲染进程和多个插件进程。

浏览器进程

浏览器进程。主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。

渲染进程

渲染进程。核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中,默认情况下,Chrome 会为每个 Tab 标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下。

GPU 进程

GPU 进程。其实,Chrome 刚开始发布的时候是没有 GPU 进程的。而 GPU 的使用初衷是为了实现 3D CSS 的效果,只是随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制,这使得 GPU 成为浏览器普遍的需求。最后,Chrome 在其多进程架构上也引入了 GPU 进程。

网络进程

网络进程。主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程。

插件进程

插件进程。主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响

浏览器的事件循环系统

简单的了解完浏览器的多进程架构之后,我们就可以正式开始学习浏览器的事件循环机制了。

今天我们要研究的内容主要发生在渲染进程中。每个渲染进程都有一个主线程,并且主线程非常繁忙,既要处理 DOM,又要计算样式,还要处理布局,同时还需要处理 JavaScript 任务以及各种输入事件。要让这么多不同类型的任务在主线程中有条不紊地执行,这就需要一个系统来统筹调度这些任务,这个统筹调度系统就是我们今天要讲的消息队列(任务队列)和事件循环系统(机制)。

关于事件循环系统,你可以参考下图。

由图中可以看出事件循环运行的整个流程。但是你是否思考过为什么事件循环机制是这样的?

为了更加深刻地理解事件循环机制,可以从最简单的场景分析,一步步了解浏览器页面主线程是如何运作的。

事件循环分析

单线程处理任务

我们先从最简单的场景讲起,比如有下面一系列的任务。

  • 任务 1:1 + 2
  • 任务 2:20 / 5
  • 任务 3:7 * 8
  • 任务 4:打印出任务 1、任务 2、任务 3 的运算结果

现在要在一个线程中去执行这些任务,通常我们会这样编写代码:

function MainThread () { const num1 = 1 + 2; // 任务1 const num2 = 20 / 5; // 任务2 const num3 = 7 * 8; // 任务3 console.log('sum:', num1, num2, num3); // 任务4 }

在上面的执行代码中,我们把所有任务代码按照顺序写进主线程里,等线程执行时,这些任务会按照顺序在线程中依次被执行;等所有任务执行完成之后,线程会自动退出。可以参考下图来直观地理解下其执行过程:

loop01.png

线程运行过程中处理新任务

但并不是所有的任务都是在执行之前统一安排好的,大部分情况下,新的任务是在线程运行过程中产生的。比如在线程执行过程中,又接收到了一个新的任务要求计算“10+2”,那上面那种方式就无法处理这种情况了。

要想在线程运行过程中,能接收并执行新的任务,就需要采用事件循环机制。

我们可以通过一个 for 循环语句来监听是否有新的任务,如下面的示例代码:

function getInputVal () { const num = window.prompt('请输入一个数字:'); return Number(num); } function MainThread () { for ( ; ; ) { const n1 = getInputVal(); const n2 = getInputVal(); const sum = n1 + n2; console.log('sum:', sum); } }

相较于第一版的线程,这一版的线程做了两点改进。

  • 引入了循环机制,具体实现方式是在线程语句最后添加了一个 for 循环语句,线程会一直循环执行。
  • 引入了事件,可以在线程运行过程中,等待用户输入的数字,等待过程中线程处于暂停状态,一旦接收到用户输入的信息,那么线程会被激活,然后执行相加运算,最后输出结果。

事件循环的本质就是 for 循环,但这里的循环并不会导致主线程卡顿。
因为实际过程中采用系统级中断机制,也就是有事件时,线程才会被激活,没事件时,线程就会被挂起。

通过引入事件循环机制,就可以让该线程“活”起来了,我们每次输入两个数字,都会打印出两数字相加的结果,你可以结合下图来参考下这个改进版的线程:

loop02.png

处理其他线程发送过来的任务

在第二版的线程模型中,所有的任务都是来自于线程内部的,如果另外一个线程想让主线程执行一个任务,利用第二版的线程模型是无法做到的。

那下面我们就来看看其他线程是如何发送消息给渲染主线程的,具体形式你可以参考下图:

loop03.png

可以看出,渲染主线程会频繁接收到来自于 IO 线程的一些任务,接收到这些任务之后,渲染进程就需要着手处理,比如接收到资源加载完成的消息后,渲染进程就要着手进行 DOM 解析了;接收到鼠标点击的消息后,渲染主线程就要开始执行相应的 JavaScript 脚本来处理该点击事件。

那么如何设计好一个线程模型,能让其能够接收其他线程发送的消息呢?一个通用模式是使用消息队列。

在解释如何实现之前,我们先说说什么是消息队列,可以参考下图:

loop04.png

消息队列是一种数据结构,可以存放要执行的任务。它符合队列“先进先出”的特点,也就是说要添加任务的话,添加到队列的尾部;要取出任务的话,从队列头部去取。

我们可以使用数组来模拟队列,使用 push 方法和 shift 方法模拟入队和出队的过程。

有了队列之后,我们就可以继续改造线程模型了,改造方案如下图所示:

loop05.png

从上图可以看出,我们的改造可以分为下面三个步骤:

  1. 添加一个消息队列;
  2. IO 线程中产生的新任务添加进消息队列尾部;
  3. 渲染主线程会循环地从消息队列头部中读取任务,执行任务;

有了这些步骤之后,那么接下来我们就可以按步骤使用代码来实现第三版的线程模型。

首先,构造一个队列。

class TaskQueue { constructor () { this.tasks = []; } pushTask (task) { this.tasks.push(task); } takeTask () { return this.tasks.shift(); } }

接下来,改造主线程,让主线程从队列中读取任务:

const taskQueue = new TaskQueue(); function ProcessTask (task) { task && task(); } function MainThread () { for ( ; ; ) { const task = taskQueue.takeTask(); console.log('执行 task'); ProcessTask(task); } }

在上面的代码中,我们添加了一个消息队列的对象,然后在主线程的 for 循环代码块中,从消息队列中读取一个任务,然后执行该任务,主线程就这样一直循环往下执行,因此只要消息队列中有任务,主线程就会去执行。

主线程的代码就这样改造完成了。这样改造后,主线程执行的任务都全部从消息队列中获取。所以如果有其他线程想要发送任务让主线程去执行,只需要将任务添加到该消息队列中就可以了。

我们可以事先为任务队列添加如下任务,查看实际运行效果。

function getInputVal () { const num = window.prompt('请输入一个数字:'); return Number(num); } function sum () { const n1 = getInputVal(); const n2 = getInputVal(); const sum = n1 + n2; console.log('sum:', sum); } taskQueue.pushTask(sum); taskQueue.pushTask(sum);

这里你可能会疑惑为什么模拟的主线程任务运行中,再向队列中添加任务不也会执行,其实原因也很简单。

因为代码都是运行在渲染进程的主线程上的,在主线程执行 MainThread 时,会阻塞其他代码运行。

由于是多个线程操作同一个消息队列,所以在添加任务和取出任务时还会加上一个同步锁(JS 没有锁机制),这块内容你也要注意下。

处理其他进程发送过来的任务

通过使用消息队列,我们实现了线程之间的消息通信。在 Chrome 中,跨进程之间的任务也是频繁发生的,那么如何处理其他进程发送过来的任务?你可以参考下图:

loop06.png

从图中可以看出,渲染进程专门有一个 IO 线程用来接收其他进程传进来的消息,接收到消息之后,会将这些消息组装成任务发送给渲染主线程,后续的步骤就和前面讲解的“处理其他线程发送的任务”一样了,这里就不再重复了。

宏任务与微任务

通过上面的介绍,你应该清楚了,页面线程所有执行的任务都来自于消息队列。消息队列是“先进先出”的属性,也就是说放入队列中的任务,需要等待前面的任务被执行完,才会被执行。鉴于这个属性,我们还有以下问题需要解决。

那就是如何处理高优先级的任务。

页面中的大部分任务都是在主线程上执行的,这些任务包括:

  • 渲染事件(如解析 DOM、计算布局、绘制);
  • 用户交互事件(如鼠标点击、滚动页面、放大缩小等);
  • JavaScript 脚本执行事件;
  • 网络请求完成、文件读写完成事件;

为了协调这些任务有条不紊地在主线程上执行,页面进程才引入了消息队列和事件循环机制,渲染进程内部会维护多个消息队列,比如延迟执行队列和普通的消息队列。然后主线程采用一个 for 循环,不断地从这些任务队列中取出任务并执行任务。我们把这些消息队列中的任务称为宏任务。

消息队列中的任务是通过事件循环系统来执行的,这里我们可以看看在 WHATWG 规范 中是怎么定义事件循环机制的。

这里大致总结了下 WHATWG 规范定义的大致流程:

  • 先从多个消息队列中选出一个最老的任务,这个任务称为 oldestTask;
  • 然后循环系统记录任务开始执行的时间,并把这个 oldestTask 设置为当前正在执行的任务;
  • 当任务执行完成之后,删除当前正在执行的任务,并从对应的消息队列中删除掉这个 oldestTask;
  • 后统计执行完成的时长等信息;

以上就是消息队列中宏任务的执行过程,通过前面的学习,相信你也很熟悉这套执行流程了。

在WHATWG规范,定义了在主线程的循环系统中,可以有多个消息队列,比如鼠标事件的队列,IO完成消息队列,渲染任务队列,并且可以给这些消息队列排优先级。但是在浏览器实现的过程中,目前只有一个消息队列,和一个延迟执行队列。一个是规范,一个是实现。

宏任务可以满足我们大部分的日常需求,不过如果有对时间精度要求较高的需求,宏任务就难以胜任了,下面我们就来分析下为什么宏任务难以满足对时间精度要求较高的任务。

前面我们说过,页面的渲染事件、各种 IO 的完成事件、执行 JavaScript 脚本的事件、用户交互的事件等都随时有可能被添加到消息队列中,而且添加事件是由系统操作的,JavaScript 代码不能准确掌控任务要添加到队列中的位置,控制不了任务在消息队列中的位置,所以很难控制开始执行任务的时间。为了直观理解,你可以看下面这段代码:

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> </head> <body> <div id="app"> <ol> <li>test</li> </ol> </div> <script> function timerCallback2 () { console.log('2'); } function timerCallback1 () { console.log('1'); setTimeout(timerCallback2, 0); } setTimeout(timerCallback1, 0); </script> </body> </html>

这段代码的目的是想通过 setTimeout 来设置两个回调任务,并让它们按照前后顺序来执行,中间也不要再插入其他的任务,因为如果这两个任务的中间插入了其他的任务,就很有可能会影响到第二个定时器的执行时间了。

但实际情况是我们不能控制的,比如在你调用 setTimeout 来设置回调任务的间隙,消息队列中就有可能被插入很多系统级的任务。你可以打开 Performance 工具,来记录下这段任务的执行过程,也可参考下面这张图片:

performance.png

setTimeout 函数触发的回调函数都是宏任务,如图中,左右两个黄色块就是 setTimeout 触发的两个定时器任务。

现在你可以重点观察上图中间浅红色区域,这里有很多一段一段的任务,这些是被渲染引擎插在两个定时器任务中间的任务。试想一下,如果中间被插入的任务执行时间过久的话,那么就会影响到后面任务的执行了

所以说宏任务的时间粒度比较大,执行的时间间隔是不能精确控制的,对一些高实时性的需求就不太符合了,比如后面要介绍的监听 DOM 变化的需求。所以又出现了一种新的技术——微任务。微任务可以在实时性和效率之间做一个有效的权衡。

微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。

不过要搞清楚微任务系统是怎么运转起来的,就得站在 V8 引擎的层面来分析下。

我们知道当 JavaScript 执行一段脚本的时候,V8 会为其创建一个全局执行上下文,在创建全局执行上下文的同时,V8 引擎也会在内部创建一个微任务队列。顾名思义,这个微任务队列就是用来存放微任务的,因为在当前宏任务执行的过程中,有时候会产生多个微任务,这时候就需要使用这个微任务队列来保存这些微任务了。不过这个微任务队列是给 V8 引擎内部使用的,所以你是无法通过 JavaScript 直接访问的。

也就是说每个宏任务都关联了一个微任务队列。那么接下来,我们就需要分析两个重要的时间点——微任务产生的时机和执行微任务队列的时机。

第一种方式是使用 MutationObserver 监控某个 DOM 节点,然后再通过 JavaScript 来修改这个节点,或者为这个节点添加、删除部分子节点,当 DOM 节点发生变化时,就会产生 DOM 变化记录的微任务。

第二种方式是使用 Promise,当调用 Promise.resolve() 或者 Promise.reject() 的时候,也会产生微任务。

通过 DOM 节点变化产生的微任务或者使用 Promise 产生的微任务都会被 JavaScript 引擎按照顺序保存到微任务队列中。

好了,现在微任务队列中有了微任务了,那接下来就要看看微任务队列是何时被执行的。

通常情况下,在当前宏任务中的 JavaScript 快执行完成时,也就在 JavaScript 引擎准备退出全局执行上下文并清空调用栈的时候,JavaScript 引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。WHATWG 把执行微任务的时间点称为检查点。当然除了在退出全局执行上下文式这个检查点之外,还有其他的检查点,不过不是太重要,这里就不做介绍了。

如果在执行微任务的过程中,产生了新的微任务,同样会将该微任务添加到微任务队列中,V8 引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束。也就是说在执行微任务过程中产生的新的微任务并不会推迟到下个宏任务中执行,而是在当前的宏任务中继续执行。

为了直观地理解什么是微任务,你可以参考下面的示意图:

task01.png

task02.png

该示意图是在执行一个 ParseHTML 的宏任务,在执行过程中,遇到了 JavaScript 脚本,那么就暂停解析流程,进入到 JavaScript 的执行环境。从图中可以看到,全局上下文中包含了微任务列表。

在 JavaScript 脚本的后续执行过程中,分别通过 Promise 和 removeChild 创建了两个微任务,并被添加到微任务列表中。接着 JavaScript 执行结束,准备退出全局执行上下文,这时候就到了检查点了,JavaScript 引擎会检查微任务列表,发现微任务列表中有微任务,那么接下来,依次执行这两个微任务。等微任务队列清空之后,就退出全局执行上下文。

以上就是微任务的工作流程,从上面分析我们可以得出如下几个结论:

  • 微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列。
  • 微任务的执行时长会影响到当前宏任务的时长。比如一个宏任务在执行过程中,产生了 100 个微任务,执行每个微任务的时间是 10 毫秒,那么执行这 100 个微任务的时间就是 1000 毫秒,也可以说这 100 个微任务让宏任务的执行时间延长了 1000 毫秒。所以你在写代码的时候一定要注意控制微任务的执行时长。
  • 在一个宏任务中,分别创建一个用于回调的宏任务和微任务,无论什么情况下,微任务都早于宏任务执行。

下面你再看文章开头的问题,是不是感觉很简单。

function task () { return new Promise((resolve, reject) => { resolve(); }); } function callback2 () { task().then(res => { console.log('5'); }) return task(); } function callback1 () { callback2() .then(() => { console.log('3'); }); setTimeout(() => { console.log('4'); }, 0); } console.log('1'); callback1(); console.log('2');

WebAPI 与 事件循环

我们已经知道浏览器页面是由消息队列和事件循环来驱动的。
接下来,我会通过 setTimeout 和 XMLHttpRequest 这两个 WebAPI 来介绍事件循环的应用。
你可能觉得它们太简单、太基础,但有时候恰恰是基础简单的东西才最重要。

setTimeout

说起 setTimeout 方法,想必大家都不会陌生,它就是一个定时器,用来指定某个函数在多少毫秒之后执行。
它会返回一个整数,表示定时器的编号,你还可以使用 cleanTimeout 来取消这个定时器。

function introduce () { console.log('定时器'); } let timerId = setTimout(introduce, 200); clearTimeout(timerId);

简单了解了 setTimeout 的使用方法后,那接下来我们就来看看浏览器是如何实现定时器的,然后再介绍下定时器在使用过程中的一些注意事项。

浏览器如何实现 setTimeout

我们知道渲染进程中所有运行在主线程上的任务都需要先添加到消息队列,然后事件循环系统再按照顺序执行消息队列中的任务。

所以说要执行一段异步任务,需要先将任务添加到消息队列中。不过通过定时器设置回调函数有点特别,它们需要在指定的时间间隔内被调用,但消息队列中的任务是按照顺序执行的,所以为了保证回调函数能在指定时间内执行,你不能将定时器的回调函数直接添加到消息队列中。

那么该怎么设计才能让定时器设置的回调事件在规定时间内被执行呢?
你也可以思考下,如果让你在消息循环系统的基础之上加上定时器的功能,你会如何设计?

在 Chrome 中除了正常使用的消息队列之外,还有另外一个消息队列,这个队列中维护了需要延迟执行的任务列表,包括了定时器和 Chromium 内部一些需要延迟执行的任务。所以当通过 JavaScript 创建一个定时器时,渲染进程会将该定时器的回调任务添加到延迟队列中。

如果感兴趣,你可以参考 Chromium 中关于队列部分的源码

有些文章说渲染进程中存在定时器线程来触发回调函数加入到事件队列中的,其实那只是一种实现标准。

实际在 Chromeium 的源码中是以延迟队列的形式来实现 setTimeout 这个 WebAPI 的。

延迟队列也是一种宏任务。延迟消息队列一般放一些定时执行的任务,如 JavaScript 设置的定时器回调,还有浏览器内部的一些定时回调任务。这类任务需要等到指定时间间隔才会被执行。

当通过 JavaScript 调用 setTimeout 设置回调函数的时候,渲染进程将会创建一个回调任务,包含了回调函数 showName、当前发起时间、延迟执行时间。创建好回调任务之后,再将该任务添加到延迟执行队列中。

这里说的延迟队列其实是一个 hashmap 结构,等到执行这个结构的时候,会计算 hashmap 中的每个任务是否到期了,到期了就去执行,直到所有到期的任务都执行结束,才会进入下一轮循环。

现在通过定时器发起的任务就被保存到延迟队列中了,那接下来我们再来看看消息循环系统是怎么触发延迟队列的。

我们可以来完善之前消息循环的代码,在其中加入执行延迟队列的代码,如下所示:

function ProcessDelayTask () {} let keep_running = true; function MainThread () { for ( ; ; ) { const task = taskQueue.takeTask(); // 执行消息队列中的任务 ProcessTask(task); // 执行延迟队列中的任务 ProcessDelayTask(); if (!MainThread) break; // 如果设置退出标记,直接退出线程循环 } }

从上面代码可以看出来,我们添加了一个 ProcessDelayTask 函数,该函数是专门用来处理延迟执行任务的。
这里我们要重点关注它的执行时机,在上段代码中,处理完消息队列中的一个任务之后,就开始执行 ProcessDelayTask 函数。ProcessDelayTask 函数会根据发起时间和延迟时间计算出到期的任务,然后依次执行这些到期的任务。等到期的任务执行完成之后,再继续下一个循环过程。通过这样的方式,一个完整的定时器就实现了。

设置一个定时器,JavaScript 引擎会返回一个定时器的 ID。那通常情况下,当一个定时器的任务还没有被执行的时候,也是可以取消的,具体方法是调用 clearTimeout 函数,并传入需要取消的定时器的 ID。如下面代码所示:

clearTimeout(timerId);

实浏览器内部实现取消定时器的操作也是非常简单的,就是直接从 delayed_incoming_queue 延迟队列中,通过 ID 查找到对应的任务,然后再将其从队列中删除掉就可以了。

使用 setTimout 的注意事项

  • 如果当前任务执行时间过久,会影响定时器任务执行。
  • 如果 setTimeout 存在嵌套调用,系统会设置最短时间间隔为 4 毫秒(嵌套超过 5 层)。
  • 未激活的页面,setTimeout 的执行最小间隔是 1000 毫秒。
  • 延时执行时间存在最大值(2147483647),超过这个时间会溢出,导致定时器立即执行。
  • 使用 setTimeout 设置的回调函数中的 this 执行 window。

因为使用 setTimeout 设置的回调任务实时性并不是太好,所以很多场景并不适合使用 setTimeout。
如果你要使用 JavaScript 来实现动画效果,函数 requestAnimationFrame 就是个很好的选择。

XMLHttpRequest

在 XMLHttpRequest 出现之前,如果服务器数据有更新,依然需要重新刷新整个页面。而 XMLHttpRequest 提供了从 Web 服务器获取数据的能力,如果你想要更新某条数据,只需要通过 XMLHttpRequest 请求服务器提供的接口,就可以获取到服务器的数据,然后再操作 DOM 来更新页面内容,整个过程只需要更新网页的一部分就可以了,而不用像之前那样还得刷新整个页面,这样既有效率又不会打扰到用户。

在深入讲解 XMLHttpRequest 之前,我们先来了解一下同步回调和异步回调这两个概念。

回调函数和系统调用栈

将一个函数作为参数传递给另外一个函数,那作为参数的这个函数就是回调函数。

const callback = function () { console.log('i am a callback function'); } function doWork (cb) { cb(); } doWork(callback);

在上面示例代码中,我们将一个匿名函数赋值给变量 callback,同时将 callback 作为参数传递给了 doWork() 函数,这时在函数 doWork() 中 callback 就是回调函数。

上面的回调方法有个特点,就是回调函数 callback 是在主函数 doWork 返回之前执行的,我们把这个回调过程称为同步回调。

既然有同步回调,那肯定也有异步回调。下面我们再来看看异步回调的例子:

const callback = function () { console.log('i am a callback function'); } function doWork (cb) { setTimeout(cb, 1000); } doWork(callback);

在这个例子中,我们使用了 setTimeout 函数让 callback 在 doWork 函数执行结束后,又延时了 1 秒再执行,这次 callback 并没有在主函数 doWork 内部被调用,我们把这种回调函数在主函数外部执行的过程称为异步回调。

在你应该知道什么是同步回调和异步回调了,那下面我们再深入点,站在消息循环的视角来看看同步回调和异步回调的区别。

我们已经知道浏览器页面是通过事件循环机制来驱动的,消息队列和主线程循环机制保证了页面有条不紊地运行。这里还需要补充一点,那就是当循环系统在执行一个任务的时候,都要为这个任务维护一个系统调用栈。

这里的系统调用栈类似于 JavaScript 的调用栈,是由 Chromium 的开发语言 C++ 来维护的。

每个任务在执行过程中都有自己的调用栈,那么同步回调就是在当前主函数的上下文中执行回调函数,这个没有太多可讲的。下面我们主要来看看异步回调过程,异步回调是指回调函数在主函数之外执行,一般有两种方式:

  • 第一种是把异步函数做成一个任务,添加到消息队列尾部;
  • 第二种是把异步函数添加到微任务队列中,这样就可以在当前任务的末尾处执行微任务了。

XMLHttpRequest 运行机制

xmlHttpRequest.png

这是 XMLHttpRequest 的总执行流程图,下面我们就来分析从发起请求到接收数据的完整流程。

我们先从 XMLHttpRequest 的用法开始,首先看下面这样一段请求代码:

function getData () { let xhr = new XMLHttpRequest(); xhr.onreadstatuschange = function () { switch (xhr.readyState) { case 0: // 请求未初始化 console.log('请求未初始化'); break; case 1: // OPENED console.log('OPENED'); break; case 2: // HEADERS_RECEIVED console.log('HEADERS_RECEIVED'); break; case 3: // LOADING console.log('LOADING'); break; case 4: // DONE if (this.status === 200 || this.status === 304) { console.log(this.responseText); } console.log('DONE'); break; } } } xhr.ontimeout = function () { console.log('ontimeout'); } xhr.onerror = function () { console.log('onerror'); } xhr.open('GET', URL, true); // 异步请求 xhr.timeout = 3000; xhr.responeType = 'text'; xhr.setRequestHeader('X_TEST', 'time.geekbang'); xhr.send();

上面是一段利用了 XMLHttpRequest 来请求数据的代码,再结合上面的流程图,我们可以分析下这段代码是怎么执行的。

1. 创建 XMLHttpRequest 对象

执行到 let xhr = new XMLHttpRequest() 后,JavaScript 会创建一个 XMLHttpRequest 对象 xhr,用来执行实际的网络请求操作。

2. 为 xhr 对象注册回调函数

因为网络请求比较耗时,所以要注册回调函数,这样后台任务执行完成之后就会通过调用回调函数来告诉其执行结果。

XMLHttpRequest 的回调函数主要有下面几种:

  • ontimeout,用来监控超时请求,如果后台请求超时了,该函数会被调用;
  • onerror,用来监控出错信息,如果后台请求出错了,该函数会被调用;
  • onreadystatechange,用来监控后台请求过程中的状态,比如可以监控到 HTTP 头加载完成的消息、HTTP 响应体消息以及数据加载完成的消息等。
3. 配置基础请求信息

注册好回调事件之后,接下来就需要配置基础的请求信息了,首先要通过 open 接口配置一些基础的请求信息,包括请求的地址、请求方法(是 get 还是 post)和请求方式(同步还是异步请求)。

然后通过 xhr 内部属性类配置一些其他可选的请求信息,你可以参考文中示例代码,我们通过xhr.timeout = 3000来配置超时时间,也就是说如果请求超过 3000 毫秒还没有响应,那么这次请求就被判断为失败了。

我们还可以通过xhr.responseType = "text"来配置服务器返回的格式,将服务器返回的数据自动转换为自己想要的格式,如果将 responseType 的值设置为 json,那么系统会自动将服务器返回的数据转换为 JavaScript 对象格式。

假如你还需要添加自己专用的请求头属性,可以通过 xhr.setRequestHeader 来添加。

4. 发起请求

一切准备就绪之后,就可以调用xhr.send来发起网络请求了。你可以对照上面那张请求流程图,可以看到:渲染进程会将请求发送给网络进程,然后网络进程负责资源的下载,等网络进程接收到数据之后,就会利用 IPC 来通知渲染进程;渲染进程接收到消息之后,会将 xhr 的回调函数封装成任务并添加到消息队列中,等主线程循环系统执行到该任务的时候,就会根据相关的状态来调用对应的回调函数。

  • 如果网络请求出错了,就会执行 xhr.onerror;
  • 如果超时了,就会执行 xhr.ontimeout;
  • 如果是正常的数据接收,就会执行 onreadystatechange 来反馈相应的状态;

这就是一个完整的 XMLHttpRequest 请求流程,如果你感兴趣,可以参考下 Chromium 对 XMLHttpRequest 的实现,点击这里查看代码

使用 XMLHttpRequest 注事事项

  • 跨域问题。XMLHttpRequest 不允许访问不同源的资源。
  • HTTPS 混合内容问题。HTTPS 是指混合内容是 HTTPS 页面中包含了不符合 HTTPS 安全要求的内容,比如包含了 HTTP 资源,通过 HTTP 加载的图像、视频、样式表、脚本等,都属于混合内容。简单来说就是 HTTPS 的页面中请求 HTTP 的资源是不允许的。

相关源码实现

大概自己实现了一下 Promise 和 Ajax,大家可以参考下。

Promise 实现

Promise 是一种解决异步问题的方案,也是一种规范。这里实现的 Promise 是满足 Promises/A+ 规范的。
关于如何说明自己实现的 Promise 是否满足 A+ 规范,大家可以使用 promises-aplus-tests 库来测试。

const PENDING = 'PENDING', FULFILLED = 'FULFILLED', REJECTED = 'REJECTED'; function resolvePromise (promise2, x, resolve, reject) { if (promise2 === x) { return reject(new TypeError('Chaining cycle detected for promise #[$Promise]')); } let called = false; if ((typeof x === 'object' && x != null) || typeof x === 'function') { try { let then = x.then; if (typeof then === 'function') { then.call(x, y => { if (called) return; called = true; resolvePromise(promise2, y, resolve, reject); }, r => { if (called) return; called = true; reject(r); }); } else { resolve(x); } } catch (error) { if (called) return; called = true; reject(error); } } else { resolve(x); } } class $Promise { constructor (executor) { this.state = PENDING; this.value = undefined; this.reason = undefined; this.onFulfilledCallbacks = []; this.onRejectedCallbacks = []; const resolve = (value) => { if (value instanceof $Promise) { return value.then(resolve, reject); } if (this.state === PENDING) { this.state = FULFILLED; this.value = value; this.onFulfilledCallbacks.forEach(fn => fn()); } } const reject = (reason) => { if (this.state === PENDING) { this.state = REJECTED; this.reason = reason; this.onRejectedCallbacks.forEach(fn => fn()); } } try { executor(resolve, reject); } catch (error) { reject(error) } } then (onFulfilled, onRejected) { onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : value => value; onRejected = typeof onRejected === 'function' ? onRejected : reason => { throw reason }; let promise2 = new $Promise((resolve, reject) => { switch (this.state) { case FULFILLED: setTimeout(() => { try { let x = onFulfilled(this.value); resolvePromise(promise2, x, resolve, reject); } catch (error) { reject(error); } }, 0); break; case REJECTED: setTimeout(() => { try { let x = onRejected(this.reason); resolvePromise(promise2, x, resolve, reject); } catch (error) { reject(error); } }, 0); break; case PENDING: this.onFulfilledCallbacks.push(() => { setTimeout(() => { try { let x = onFulfilled(this.value); resolvePromise(promise2, x, resolve, reject); } catch (error) { reject(error); } }, 0); }); this.onRejectedCallbacks.push(() => { setTimeout(() => { try { let x = onRejected(this.reason); resolvePromise(promise2, x, resolve, reject); } catch (error) { reject(error); } }, 0); }); break; } }); return promise2; } catch (errorCallback) { return this.then(null, errorCallback); } finally (finallyCallback) { return this.then((value) => { return $Promise.resolve(finallyCallback()).then(() => value); }, (reason) => { return $Promise.resolve(finallyCallback()).then(() => { throw reason; }); }); } static resolve (value) { return new $Promise((resolve, reject) => { resolve(value); }); } static reject (reason) { return new $Promise((resolve, reject) => { reject(reason); }); } static all (promiseArr) { let resArr = [], idx = 0; return new $Promise((resolve, reject) => { promiseArr.forEach((promise, index) => { if (isPromise(promise)) { promise.then(value => formatResArr(value, index, resolve), reject); } else { formatResArr(promise, index, resolve); } }); }); function formatResArr (value, index, resolve) { resArr[index] = value; if (++idx === promiseArr.length) { resolve(resArr); } } } static allSettled (promiseArr) { let resArr = [], idx = 0; if (!isIterable(promiseArr)) { throw new TypeError(`${promiseArr} is not iterable (cannot read property Symbol(Symbol.iterator))`); }; return new Promise((resolve, reject) => { if (promiseArr.length === 0) { return resolve([]); } promiseArr.forEach((promise, index) => { if (isPromise(promise)) { promise.then(value => { formatResArr('fulfilled', value, index, resolve); }, (reason) => { formatResArr('rejected', reason, index, resolve); }); } else { formatResArr('fulfilled', promise, index, resolve); } }); }); function formatResArr (status, value, index, resolve) { switch (status) { case 'fulfilled': resArr[index] = { status, value } break; case 'rejected': resArr[index] = { status, reason: value } break; default: break; } if (++idx == promiseArr.length) { resolve(resArr); } } } static race (promiseArr) { return new $Promise((resolve, reject) => { promiseArr.forEach(promise => { if (isPromise(promise)) { promise.then(resolve, reject); } else { resolve(promise); } }); }); } } function isPromise (x) { if ((typeof x === 'object' && x !== null) || typeof x === 'function') { let then = x.then; return typeof then === 'function'; } return false; } function isIterable (value) { return value !== null && value !== undefined && typeof value[Symbol.iterator] === 'function'; } // 脚本检测 $Promise.defer = $Promise.deferred = function () { let deferred = {}; deferred.promise = new $Promise((resolve, reject) => { deferred.resolve = resolve; deferred.reject = reject; }); return deferred; } module.exports = $Promise;

Ajax 实现

Ajax 就不多说了,大家肯定知道。

const $ = (function () { const randomNum = () => { let num = 0; for (let i = 0; i < 20; i++) { num += Math.floor(Math.random() * 10); } return num; } const formatData = (obj) => { let str = ''; for (let key in obj) { str += `${key}=${obj[key]}&`; } return str.replace(/&$/, ''); } const ajax = (options = {}) => { let xhr = window.XMLHttpRequest ? new XMLHttpRequest() : new ActiveXObject('Microsoft.XMLHTTP'); if (!xhr) { throw new Error('您的浏览器不支持异步发起 HTTP 请求'); } let type = (options.type || 'GET').toUpperCase(), dataType = options.dataType && options.dataType.toUpperCase() || 'JSON', url = options.url, data = options.data || null, fail = options.fail || function () {}, success = options.success || function () {}, complete = options.complete || function () {}, timeout = options.timeout || 3 * 10 * 1000, jsonp = options.jsonp || 'cb', jsonpCallback = options.jsonpCallback || `Jquery${randomNum()}_${Date.now()}`, async = options.async === false ? false : true; if (!url) { throw new Error('您没有填写 URL'); } if (dataType === 'JSONP') { if (type !== 'GET') { throw new Error('JSONP 格式必须是 GET 请求'); } const oScript = document.createElement('script'); oScript.src = !!~url.indexOf('?') ? `${url}&${jsonp}=${jsonpCallback}` : `${url}?${jsonp}=${jsonpCallback}`; document.body.appendChild(oScript); document.body.removeChild(oScript); window[jsonpCallback] = function (data) { success(data); } return; } t = setTimeout(() => { xhr.abort(); fail(); complete(); clearTimeout(t); t = null; xhr = null; }, timeout); xhr.onreadystatechange = function () { if (xhr.readyState === 4) { if (xhr.status >= 200 && xhr.status < 300 || xhr.status === 304) { switch (dataType) { case 'JSON': success(JSON.parse(xhr.responseText)); break; case 'TEXT': success(xhr.responseText); break; case 'XML': success(xhr.responseXML); break; default: success(JSON.parse(xhr.responseText)); break; } } else { fail(); } complete(); clearTimeout(t); t = null; xhr = null; } } xhr.open(type, url, async); type === 'POST' && xhr.setRequestHeader('Content-type', 'appliction/x-www-form-urlencoded'); xhr.send(type === 'GET' ? null : formatData(data)); } const post = ({ url, data, success, fail, complete }) => { ajax({ type: 'POST', url, data, success, fail, complete }); } const get = ({ url, success, fail, complete }) => { ajax({ type: 'GET', url, success, fail, complete }); } return { ajax, get, post } })();

总结

本篇文章从浏览器底层的角度分析了事件循环机制,相信以后遇到类似的题目你也可以轻松解决。

参考资料