3种必须知道的JavaScript异步编程模型

今天,我想和你聊聊 JavaScript 中涉及异步编程方面的一些技巧。

异步(Asynchronous)是一个很大的话题,JavaScript 对异步编程的支持又充满了各种历史包袱,比如早期各种三方 Library 自行实现了 Promise 但相互之间却存在一些 API 行为的差异,再比如 ES6 之后的 yield-generator 等语法最终基本上被 async/await 语法取代了等等。

这些历史包袱往往会让新人困惑不已,在这种情况下,抓住主线能够提高你的学习效率。在 JavaScript 语言发展演进的主线上,有 3 种异步编程模型可以定义为必修,同时它们也是前端面试中的高频技术考点,掌握它们对于夯实 JavaScript 基本功是十分有效的。那么这节课我们就以实际项目中运用起来行之有效的技术为出发点,逐一分析,希望能帮到你。

回调函数

先让我们看两个具体的业务场景:对于一名前端工程师或者 JavaScript 工程师而言,对异步流程的处理可谓是日常工作中的家常便饭,比如你一定遇到过下列使用场景:

  1. 在一段 JavaScript 逻辑中发起一个网络请求,向后端获取一个 RPC 接口的数据,在请求返回数据后对数据进行一系列"响应处理"。
  1. 实现一个下拉菜单(dropdown)UI 组件的交互,鼠标悬停到某一热区时显示菜单,并延迟 200ms,如果此时鼠标还未移动到菜单区域内则把菜单重新隐藏起来。

当你遇到"程序需要等待某一件事情发生之后才开始做某件事情"这样的场景时,本质上来说这就是一个最简单的异步场景。

上面的两个例子中,等待的事情分别是:

  1. 网络请求返回数据
javascript 复制代码
var netRequest = new XMLHttpRequest();
netRequest.addEventListener("load", reqCallback);
netRequest.open("GET", "https://fakejson.com/api/mock/foo.json");
netRequest.send();
function reqCallback() {
  // do something after request
}
  1. 鼠标离开触发热区 200ms
javascript 复制代码
setTimeout(function () {
  // do something after 200ms
}, 200);

不同于一些传统高级语言中的多线程方式实现异步,JavaScript 中采取了一种对业余 Coder 更友好的方式------"事件 - 回调"模型。或许这是由于它最初的设计目标就是为了提供给当时的静态网页做一些小规模的脚本程序准备的,不过今天这种异步的并发编程模型不仅对浏览器端 JavaScript 影响深远,更是成就了 Node.js 在服务端领域的进一步"外卷"。

回调函数的编程思想源自函数式编程(FP),在今天看来,将其应用于异步逻辑的处理其实是当初 JavaScript 技术栈的一种"超前设计",相比早期在学术界广泛应用而言,C++11 和 Java8 分别实现的 Lamda 表达式证明了函数式编程在工业级软件开发实践当中的价值。

但是凡事都有利有弊,以回调函数实现异步逻辑的编程模型在稍微复杂一些的业务逻辑当中就会暴露其弊端,最为人熟知的就是"回调地狱"(callback hell),试想你要在 JavaScript 中编写这样的逻辑:

  1. 先请求 /api/data1 接口获取某信息;
  2. 根据上一步接口的返回值作为 url 参数请求 /api/data2;
  3. 再根据这一步的接口返回值加上一些处理逻辑后请求 /api/data3;
  4. 以此类推继续上述步骤串行请求 5 个后端接口。

去掉其他无关代码的干扰,你大概会写出下面这样结构的代码:

javascript 复制代码
request('/api/data1', () => {
  request('/api/data2', () => {
    request('/api/data3', () => {
      request('/api/data4', () => {
        request('/api/data5', () => {
          // ...
        });
      });
    });
  });
});

我们暂且抛开代码让人眼花缭乱的缩进问题,回调地狱 最致命的问题在于它让函数的返回值功能失去了意义。此外,如果你习惯了用匿名函数直接作为 Request 参数的这种写法,多层嵌套的回调函数则让 JavaScript 作用域结构变得更加复杂,当代码规模逐渐变大后这对调试而言将会是一种灾难。

Promise 方式

为了解决回调模式的诸多弊端,JavaScript 社区中浮现出了一种被称为 Promise 的解决方案。准确地说 Promise 并不是一种语法特性,而是函数式编程中的一种编程思想。最初在一些 JavaScript 的第三方库开始使用,比如当年盛行的 jQuery 和 Angular 等框架。

让我们先来看一个例子,以下就是用 Promise 实现的一个网络请求逻辑:

javascript 复制代码
fetch('/api/data1')
  .then((resp) => {
    // 当网络请求反回后才会开始执行这里的逻辑
  });

你还可以使用 promise.all 和 promise.race 方法对多个网络请求进行并发管理,比如你想实现同时发出两个网络请求:

javascript 复制代码
const tasks = [];
tasks.push(fetch('/api/data1'));
tasks.push(fetch('/api/data2'));
Promise.all(tasks).then((resp1, resp2) => {
  // 当两个请求都反回之后才会开始执行这里的逻辑
});

在这段代码中,执行到第二行"tasks.push(fetch('/api/data1'));"代码时,JavaScript 线程会把请求提交给宿主环境,然后继续执行下一行代码。当第一时间可以执行的代码都已经被执行之后,JavaScript 线程会进入"真空状态",由宿主环境进行统一调度,在合适的时机去执行应该被执行的各种回调函数(Promise 其实也是注册了一个回调函数)。

划重点:我们上面提到的"可以执行的代码都已经被执行"其实就是一个宏任务或微任务,而"宿主环境进行统一调度"的机制就是你经常听到的所谓事件循环(Event Loop)。

接下来,如果我们希望同时发出两个网络请求,先回来哪个就先采用哪个返回结果,你可以使用 promise.race:

javascript 复制代码
const tasks = [];
tasks.push(fetch('/api/data1'));
tasks.push(fetch('/api/data2'));
Promise.race(tasks).then((resp) => {
  // 两个请求中有任何一个返回后马上开始执行这里的逻辑
});

在大量的业务实践中,诞生了 q、bluebird、when 等许多优秀的 Library。它们通常遵循一个业界公认的接口标准,比如 Promise/A+ 。事实上,现如今包括浏览器和 Node.js 等大部分 JavaScript 宿主环境都已经原生支持了 Promise。

当然,其实你完全可以自己尝试着手动封装一个 Promise 的 pollyfill,我就曾经在校招面试的题目中把这个作为一道考验 JavaScript 功底的题目, 很多时候面试并不是需要你像复习考试那样刷题背多少固定的套路,而是你真正对自己每天在使用的编程语言和函数库具有足够的了解之后自然会水到渠成

也许你会说,这道面试题有什么意义呢?ES6 之后 Promise 已经正式成为语言的标准之一了,何必自己再搞懂这些东西。事实上,写出一个自己的 Promise pollyfill 这个结果本身对你并不会有什么直接的获益, 对你真正有帮助的是尝试编写这样一个 Promise 库的过程,你会对函数式编程(FP)还有 JavaScript 的微任务(microtask)有更深的理解

async/await 语法

在 ES7 当中,一种更进一步的面向异步编程的方案被正式标准化------async/await 语法。并且这一次是真正语言语法级别的支持,其实在更早的时候就已经有了 generator/yield 语法,不过从今天来看 generator 或许只是一个过渡阶段,对于平时用 JavaScript 实现业务逻辑的同学来说,只要掌握 async/await 基本上足够了。

那么,async/await 语法究竟为我们进一步解决了什么问题呢?

先来看一段示例代码:

javascript 复制代码
function sleep(n) {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve(true);
    }, n);
  });
}
async function main() {
  console.log('first');
  await sleep(1000);
  console.log('second');
  await sleep(2000);
  console.log('then');
  await sleep(3000);
  console.log('finish');
}
main();

我们不难发现,其实每一个 await 关键字后面都跟随一个 Promise,而 await 语句本身会把当前的 JavaScript 代码切成两段,在 await 之前的代码执行完之后,JavaScript 线程进入如前面所说的那样的"真空期",等待由引擎调度开始执行 await 之后的代码逻辑。

而 async 关键字只是对一个 function 内包含 await 关键字的一个标识,你可以理解为是一个对 JavaScript 引擎的提示,像 Deno 这样的比较新的 JS/TS 运行环境中已经可以 async 了。

其实 async/await 只是一个语法糖,它让我们在 JavaScript 中可以更方便地实现异步流程。尽管有了这个新语法,我依然建议你尽可能地掌握传统的回调函数和 Promise 这两种 JavaScript 中旧的异步编程模式,一方面 async/await 会受到运行环境支持的局限,另一方面 Babel、TypeScript 等转换工具也会把 async/await 转换成低版本的 JavaScript 语法。

事件循环中的宏任务和微任务

说完了 JavaScript 中的几种实现异步的编程方式,最后我们再谈谈前面遗留的一个话题------宏任务(macrotask)和微任务(microtask)。

我们先看这样一道简单的题目:

javascript 复制代码
console.log('main task');
setTimeout(function() {
  console.log('setTimeout callback');
}, 0);
Promise.resolve().then(function() {
  console.log('Promise callback');
});
console.log('main task end');

大部分有 JavaScript 编码经验的人都知道,这段代码在第一遍执行时会先把主任务(main task)上的两个 console.log"main task"和"main task end"执行完:

在过程中遇到的 setTimeout 和 Promise.resolve.then 这种 API 调用,第一时间都只是把回调函数注册在事件循环(Event Loop)中,而不会直接执行,什么时候执行要看宿主环境的调度了。但是如果我要问你,这段代码中的两处异步 API 调用,注册的回调函数谁会先执行呢?

可能一些新手凭借第一反应就会说,既然事件循环的机制是 queue schedule,那么队列当然是先进先出、先来后到原则嘛,先执行 setTimeout 的回调函数,后执行 Promise 注册的回调。

这显然是错误的回答,当然如果你在面试中恰巧蒙上了正确回答"先 Promise 后 setTimeout",面试官一定会问你"为什么?"。所以显然需要知其然而知其所以然,这才是我们掌握这部分知识的终极目标。

答案就在于区分宏任务(macrotask)与微任务(microtask),根据浏览器标准组织 WHATWG 对浏览器中 JavaScript 事件循环的标准定义:将异步 API 中的 setTimeout、setInterval、I/O 操作等定义为一个宏任务,而微任务中最常见的就是 Promise 了,在 Node.js 里,还有像 process.nextTick 这样的 API 也属于微任务。

所有宏任务 API 注册的回调函数,必须等到当前这个宏任务完全结束才能执行;而一个微任务则会在当前宏任务内部执行,也就是说会早于下一个宏任务。

事实上,大部分 JavaScript 语言编写的程序,都是这样的一个结构:

JavaScript 引擎会在全局维护一个宏任务队列,第一次执行整段 JavaScript 代码可以被理解为图中的主逻辑执行,这个过程本身也可以理解为一个宏任务。当这个宏任务执行结束后,会先检查微任务队列,并执行、清空该队列,在此之后开始从宏任务队列中取出最前面的一个宏任务并重复上述过程,周而复始。这个过程也被称之为事件循环。

在浏览器环境中,上述过程中每个宏任务之间的间隙,JavaScript 线程都会处于休眠状态,就像页面中 JavaScript 注册了一系列原生的点击或其他 Event 之后,就不会有任何 JavaScript 代码执行,一直到某个事件被触发。每一个事件的回调,都会作为一个宏任务。浏览器中 JavaScript 的休眠状态通常可以被浏览器的 requestIdleCallback API 获取到,并进行统一调度。如果整个宏任务队列都被执行完了,JavaScript 引擎将长期处于等待状态,直到有下一个回调函数(宏任务)被触发。

而在 Node.js 这样的运行环境下,当宏任务队列为空时,如果没有监听端口、setInterval、或者 fs.watch 之类的操作,Node 进程会直接结束。

值得一提的是,你永远不需要考虑两个宏任务被同时执行的情况,这也就是我们通常说的 JavaScript 是单线程模型。因为多线程模型的场景需要考虑更多复杂的条件竞争、死锁等问题,这种设计在最初对 JavaScript 开发者来说是一种低学习成本、低调试成本的考虑。

严格意义上说,事件循环并非 JavaScript 语言的一部分,在 Chrome 的 V8 引擎的代码中甚至并不包含这一部分的实现。不过,在长期的技术演进当中,事件循环已经成为了大部分 JavaScript 开发者理所当然的一个认知了,因此很多新的 JavaScript 运行环境都会把浏览器中的 Event Loop 照搬一份,例如众所周知的 Node.js 平台底层依赖的 libuv。也正是因此,我们通常会说 JavaScript 适合做 I/O 密集型的场景,而不适合 CPU 密集型的场景。

总结

我们把在 JavaScript 这门语言中实现异步逻辑的一些常规手段进行了逐一列举和针对性讲解,其中 Promise 本身并不是 JavaScript 的一种语法,而是一种异步编程的编程思想,或者算一种设计模式。

事件循环(Event Loop)也并不是 JavaScript 语言的标准化组织 TC39 制定的,而是浏览器标准的一部分。只因为 JavaScript 的运行环境主要以浏览器为主,而 Node.js 也对其进行了相应机制的实现,所以我们在 JavaScript 中进行异步编程有必要了解宏任务和微任务。

异步编程这个话题非常庞大,我们今天的课程以抛砖引玉为主,希望能帮到接触 JavaScript 语言时间较短的朋友。如果你还想要系统地学习 JavaScript 中的异步逻辑的实现方式,可以把这一讲的课程作为一个学习的出发点之一。

相关推荐
倒霉男孩2 小时前
HTML视频和音频
前端·html·音视频
喜欢便码2 小时前
JS小练习0.1——弹出姓名
java·前端·javascript
chase。2 小时前
【学习笔记】MeshCat: 基于three.js的远程可控3D可视化工具
javascript·笔记·学习
暗暗那2 小时前
【面试】什么是回流和重绘
前端·css·html
小宁爱Python2 小时前
用HTML和CSS绘制佩奇:我不是佩奇
前端·css·html
weifexie3 小时前
ruby可变参数
开发语言·前端·ruby
千野竹之卫3 小时前
3D珠宝渲染用什么软件比较好?渲染100邀请码1a12
开发语言·前端·javascript·3d·3dsmax
sunbyte3 小时前
初识 Three.js:开启你的 Web 3D 世界 ✨
前端·javascript·3d
半兽先生3 小时前
WebRtc 视频流卡顿黑屏解决方案
java·前端·webrtc
南星沐4 小时前
Spring Boot 常用依赖介绍
java·前端·spring boot