异步小专题

441 阅读15分钟

前言

JS 异步解决方案,始终是面试中的热点与重点。

不少同学对异步知识存在一些认知误区,甚至压根没有意识到这个知识体系的存在。一些本身技术水平不错的同学,在面试前做了大量的 Promise、async/await 相关的面试题,对单个知识点的特性和命题思路了如指掌,却在面对。“谈谈你所了解的 JS 异步方案”、或者稍微隐晦一点的 “Promise 到底解决了什么痛点?这样的痛点还可以如何解决?” 这样的问题时手足无措,实在让人惋惜。

事实上,大家在各种面经里喜闻乐见的 Promise、Generator、async/await 之流,之所以如此顺利地成为面试官们的心头好,无非就是因为它们可以帮助我们优雅地解决异步。面试官固然也会想要通过考察孤立的知识点来考察你基本功的扎实度,但本质上,他真正想要的往往都是那个 “更上一层楼” 的答案 —— 想知道你对 JS 异步 以及异步解决方案,到底理解到什么程度。

异步到底是啥?为啥这么重要?为了解决异步,我们有哪些可取的手段?手段本身有哪些利弊、手段与手段之间又存在着怎样的进化关系?接下来,我们就通过回答这一系列的问题,来帮大家建立起一个坚不可摧的 JS 异步知识体系。

异步编程模型与异步解决方案

生活中的同步与异步

计算机领域中的同步和异步,其实和我们生活当中排队买东西很相似。

比如说咱现在去 KFC 买炸鸡。你点餐、付款可能只需要一分钟,但是等炸鸡做好需要 10 分钟。如果这时候店员跟你说,按照咱们店的规定,客人必须一直站在这儿等着,直到餐品出完为止你才能走、换下一位顾客。这种情况下,你这 10min 除了站在收银台前面和店员大眼瞪小眼外、啥也不能干;排在你后面的那些顾客,更是难上加难。

1598596381.jpg

这种严格按顺序执行任务、做完一件才肯做另一件的行为方式,就是同步编程的特征。

不过要真这么玩,KFC 估计也撑不到现在。实际上我们点餐的过程中,点餐、付款的任务完成后,你大可不必原地等出餐,而是会领到一张取餐纸条。这中间你可以坐在旁边的椅子上玩手机、可以出去转转商场、可以顺手去隔壁电影院买张票 —— 你干啥都行。等出餐成功时,你取餐纸条上的号码会被公布在 KFC 大堂的屏幕上,此时凭纸条去取餐就好了。在这个过程中,点餐和出餐的过程分离到两条任务线里,点餐 1 分钟 1 分钟的点,出餐慢慢来出,只要出完之后通知到取餐人就行了。这就是异步的智慧。

1598596381.jpg

这不只是 KFC 的智慧,也是 JS 的智慧。

JS 中的同步与异步

JS 语言的任务执行模式就分为同步和异步。

大家基于买炸鸡这个故事来理解 JS 中的同步和异步:同步,就是说后一个任务必须严格等待前一个任务执行完再执行,任务的执行顺序和排列顺序是高度一致的(上一个人取到炸鸡之前,下一个人不许点餐);异步,则恰恰相反,任务的执行顺序不必遵循排列顺序。比如说前一个任务就算没执行完(炸鸡还没出餐),也没关系,先执行下一个任务就好(让下一个人先点餐),等前一个任务的执行结果啥时候出来了(炸鸡炸好了),我再把它临时穿插进来执行下(电脑屏幕上通知到取餐人)。

这其中,异步模式至关重要。大家知道,对我们前端来说,和 KFC 这样的服务行业一样,用户体验就是命。炸鸡店让客人苦等半天吃个炸鸡,你这个店要挨骂;我们页面让用户苦等 2 分钟等一个表单提交的返回结果,同样是极不友好的一种交互体验。假如我们的主线程里,充斥着用户事件、ajax 任务等高耗时的操作,这种情况下还不采用异步方案,页面的卡顿甚至卡死将是不可避免的。

异步进化史

异步在实现上,依赖一些特殊的语法规则。从整体上来说,异步方案经历了如下的四个进化阶段:

回调函数 —> Promise —> Generator —> async/await。

其中 Promise、Generator 和 async/await 都是在 ES2015 之后,慢慢发展起来的、具有一定颠覆性的新异步方案。相较于 “回调函数 “时期的刀耕火种而言,具有划时代的意义。

“回调函数” 时期

所谓 “回调函数” 时期,这里严格来说指代的其实是 Promise 出现前的这么一个相对早期的阶段。在这个阶段里,回调是异步最常见、最基本的实现手段,却不是唯一的招数 —— 像事件监听、发布订阅这样的方式,也经常为我们所用。

事件监听

这种形式相信每位前端同学都不陌生,给目标 DOM 绑定一个监听函数,我们用的最多的是 addEventListener

document.getElementById('#myDiv').addEventListener('click', function (e) {
  console.log('我被点击了')
}, false);

通过给 id 为 myDiv 的一个元素绑定了点击事件的监听函数,我们把任务的执行时机推迟到了点击这个动作发生时。此时,任务的执行顺序与代码的编写顺序无关,只与点击事件有没有被触发有关。

发布订阅

发布订阅,是一种相当经典的设计模式。这里我们直接用 jQuery 中封装过的发布订阅做讲解,会更容易理解一些。比如说我们想在名为 trigger 的信号被触发后,做点事情,我们可以订阅 trigger 信号

function consoleTrigger() {
    console.log('trigger事件被触发')
}
jQuery.subscribe('trigger',consoleTrigger);

这样当 trigger 被触发时,上面对应的回调任务就会执行了

function publishTrigger() {
    jQuery.publish('trigger');
}

// 1s后,publishTrigger 方法执行,trigger 信号发布,consoleTrigger 就会执行了
setTimeout(publishTrigger, 1000)

大家会发现这种模式和事件监听下的异步处理非常相似,它们都把任务执行的时机和某一事件的发生紧密关联了起来。

回调函数

回调函数用的最多的地方其实是在 Node 环境下,我们难免需要和引擎外部的环境有一些交流:比如说我要利用网络模块发起请求、或者要对外部文件进行读写等等。这些任务都是异步的,我们通过回调的形式来实现它们。

// -- 异步读取文件
fs.readFile(filePath,'utf8',function(err,data){
    if(err) {
      throw err;
    }
    console.log(data); // 输出文件内容
});
const https = require('https');
 
// 发起网络请求
https.get('目标接口', (res) => {
  console.log(data)
}).on("error", (err) => {
  console.log("Error: " + err.message);
});

回调地狱

当回调只有一层的时候,看起来感觉没什么问题。但是一旦回调函数嵌套的层级变多了之后,代码的可读性和可维护性将面临严峻的挑战。比如当我们想发起连环网络请求时

onst https = require('https');

https.get('目标接口1', (res) => {
  console.log(data)
  https.get('目标接口2', (res) => {
    https.get('目标接口3', (res) => {
			console.log(data)
      https.get('目标接口4', (res) => {
        https.get('目标接口5', (res) => {
          console.log(data)
          .....
          // 无尽的回调
        })
      })
    })
  })
})

这种情形一点也不夸张。而且其实不只是在 http、在 ajax 这样的网络请求场景里有这种谜之代码,在 “Promise 前” 的那个上古时期,我们经常被这种深不见底的回调困扰

func1(function (resultA) {
  func2(resultA, function (resultB) {
    func3(resultB, function (resultC) {
      func4(resultC, function (resultD) {
        func5(resultD, function (resultE) {
          func6(resultE, function (resultF) {
            console.log(resultF);
            ...
            // 无尽的回调
          });
        });
      });
    });
  });
});

这样写代码非常糟糕,它会带来很多问题,最直接的就是:可读性和可维护性被破坏。

首先,你的代码会变得非常难以理解。我们这里为了大家理解方便,把每个回调的内部逻辑都写得极为简单。但是实际开发中,回调逻辑往往是有一定分量的。到时候就不是” 一行叠一行 “这么简单了,而是 “一坨叠一坨”。一眼望去,你很难看出这些回调之间到底是谁套谁。想改 A 处的代码,结果却不小心定位到了 B 处,这都是常有的事。

这时候如果你往里面再添油加醋,比如说加上 this、加上箭头函数、加上自由变量啥的,这段代码再过一个星期回来,你自己都很难看懂,更不要说后来的维护者了。

好在早期的前端世界,我们的展示层业务逻辑并没有十分复杂、Node 也还没有问世。那时,前端人普遍觉得用用事件监听、偶尔嵌套那么一两层的回调,小日子也能过得不错。但是随着逻辑的增长和复杂化、随着 Node 对大量异步操作的诉求日益强烈和明显,人们终于坐不住了,要对回调地狱这只小恶魔下手了。在这样的时代背景下,Promise 出现了。

全面掌握现代异步解决方案

Promise

长久以来,我们一直期望着一种既能实现异步、又可以确保我们的代码好写又好看的解决方案出现。带着这样的目标,经过反复的探索,我们终于迎来了 Promise。

用 Promise 实现异步,我们这样做(这里我改造了一个网络请求的过程)

const https = require('https');

function httpPromise(url){
  return new Promise(function(resolve,reject){
    https.get(url, (res) => {
      resolve(data);
    }).on("error", (err) => {
      reject(error);
    });
  })
}

httpPromise().then(function(data){}).catch(function(error){})

可以看出,Promise 会接收一个执行器,在这个执行器里,我们需要把目标的异步任务给“填进去”。

在 Promise 实例创建后,执行器里的逻辑会立刻执行,在执行的过程中,根据异步返回的结果,决定如何使用 resolve 或 reject 来改变 Promise实例的状态。 Promise 实例有三种状态:

  • pending 状态,表示进行中。这是 Promise 实例创建后的一个初始态;
  • fulfilled 状态,表示成功完成。这是我们在执行器中调用 resolve 后,达成的状态;
  • rejected 状态,表示操作失败、被拒绝。这是我们在执行器中调用 reject 后,达成的状态。

在上面这个例子里,当我们用 resolve 切换到了成功态后,Promise 的逻辑就会走到 then 中的传入的方法里去;用 reject 切换到失败态后,Promise 的逻辑就会走到 catch 传入的方法中去。

这样的逻辑,本质上与回调函数中的成功回调和失败回调无异。但这种写法毫无疑问大大地提高了代码的质量。最直接的例子就是当我们进行大量的异步链式调用时,回调地狱不复存在了。取而代之的,是层级简单、赏心悦目的 Promise 调用链

httpPromise(url1)
    .then(res => {
        console.log(res);
        return httpPromise(url2);
    })
    .then(res => {
        console.log(res);
        return httpPromise(url3);
    })
    .then(res => {
      console.log(res);
      return httpPromise(url4);
    })
    .then(res => console.log(res));

Generator

除了 Promise, ES2015 还为我们提供了 Generator 这个好帮手~ 。如果你对 Generator 是什么、以及其语法特性暂时还没有太多的了解,可以点击es6.ruanyifeng.com/#docs/gener… 先进行预备知识的学习。

Generator 一个有利于异步的特性是,它可以在执行中被中断、然后等待一段时间再被我们唤醒。通过这个“中断后唤醒”的机制,我们可以把 Generator看作是异步任务的容器,利用 yield 关键字,实现对异步任务的等待。

比如咱们上面用 Promise 链式调用例子,其实完全可以用 yield 来这么写

function* httpGenerator() {
  let res1 = yield httpPromise(url1)
  console.log(res);
  let res2 = yield httpPromise(url2)
  console.log(res);
  let res3 = yield httpPromise(url3)
  console.log(res);
  let res4 = yield httpPromise(url4)
  console.log(res);
}

当然啦,单纯这么改还不够,我们还需要在调用层面再完善一下才能让这个生成器如期运行起来。

但在完善之前,咱们就单纯看这种写法,是不是比 Promise 链式调用更好看、更清晰了?这时候你一眼看过去就知道这段逻辑在干嘛,而不必再对所谓的“链”作分析。干干净净、一目了然!

现在我们想办法让 httpGenerator 按照我们的预期跑起来:我们知道,Generator 要想跑起来,需要为它创建迭代器,然后去执行这个迭代器的 next 方法。在 httpGenerator 这个例子里,我们要想把整个函数体的逻辑走完,就必须让迭代器的 next 反复调用、直到返回值中的 done 为 true 为止。这个过程,我们当然不能手动调用,而要让程序来帮我们做

function runGenerator(gen) {
    var it = gen(), ret;

    // 创造一个立即执行的递归函数
    (function iterate(val){
        ret = it.next(val);

        if (!ret.done) {
            // 如果能拿到一个 promise 实例
            if ("then" in ret.value) {
                // 就在它的 then 方法里递归调用 iterate
                ret.value.then( iterate );
            }
        }
    })();
}

runGenerator(httpGenerator)

大家一起来看下 runGenerator 这个方法,当我们把 httpGenerator 传进去后,会发生如下过程:

  1. 为传入的 Generator 创建它对应的迭代器 it。然后,我们第一次调用 iterate 函数,入参为空。

  2. terate 函数内部,调用 it 的 next 方法,生成器函数开始执行,执行到第一个 yield 关键字处的逻辑执行完后暂停。它会返回一个包含了 httpPromise(url1) 这个调用返回的 promise对象(我们下文称 promise1)、以及一个 done: false 的标识,用来表示当前生成器函数内部的逻辑还没执行完(大致如下):

{ 
  value:
   Promise {
     <pending>,
     ...// 省略一系列 promise 对象关联信息
   },
  done: false
}

因为 done 为 false,所以我们会进一步判断当前拿到的是否是一个 promise 对象(根据它有没有 then 属性)。判断为真后,我们在 promise1 的 then 方法里传入 iterate 函数本身(递归)。

  1. promise1 的 then 方法里的 iterate 函数调用,拿到了 promise1 的返回结果(即针对 url1 的请求结果)作为入参。it.next 被第二次调用,生成器函数被“唤醒”了。注意,被“唤醒”后的生成器函数,按照流程走,它执行的第一个语句就是
let res1 = yield httpPromise(url1)

这一步会把 next(val) 中的 val 传给 res1,而 val,恰恰就是 promise1 的返回结果。一切正如我们所预期。 而后,生成器函数会继续执行到第二个 yield 关键字处,执行完后暂停。

此时 next 方法返回一个包含了 httpPromise(url2) 这个调用返回的 promise 对象(我们下文称 promise2)、以及一个 done: false 的标识(用来表示当前生成器函数内部的逻辑还没执行完)。因为 done 为 false,所以我们会进一步判断当前拿到的是否是一个 promise 对象(根据它有没有 then 属 性)。判断为真后,我们在 promise2 的 then 方法里传入 iterate 函数本身(递归)。

  1. 循环上述过程过程,直到生成器内部逻辑执行完为止。 通过“自动执行”生成器函数对应迭代器的 next 方法,我们把异步的写法进一步优化了。它不再需要地狱般的回调,甚至不再需要 Promise 长长的链式调用,而是可以像写同步代码一样简单、清晰地实现异步特性!

不过仔细想想,咱们这个 runGenerator 其实非常简陋,它虽然体现了自动执行的思想,却不具备通用性,无法兼容更多场景——确实,要写出一个完整周到的 runGenerator 函数,不是一件轻松的事情。但是有一个好用的 runGenerator,又确实是广大开发者的强诉求。于是我们有了一个叫 co 的库,专门来封装自执行这一层的逻辑:

const co = require('co');
co(httpGenerator());

这里的 co,大家就可以把它看作是一个加强版的 runGenerator。我们只需要在代码里引入 co 库,然后把写好的 generator 传进去,就可以轻松地实现 generator 异步了。

Async/Await

就当大家正在纷纷感慨 co 真好使,generator + promise + co 的异步方案真优雅时,更强的家伙出现了。这玩意儿甚至甩开了 co、甩开了 generator,有了它,你什么都不用操心,只需要写几个关键字,就能把异步代码处理得像同步代码一样优雅!这玩意儿就是 async/await。

它的用法非常简单。首先,我们用 async 关键字声明一个函数为“异步函数”

async function httpRequest() {}

然后,我们就可以在这个函数内部使用 await 关键字了

async function httpRequest() {
  let res1 = await httpPromise(url1)
  console.log(res1)
}

这个 await 关键字很绝,它的意思就是“我要异步了,可能会花点时间,后面的语句都给我等着”。

当我们给 httpPromise(url1) 这个异步任务应用了 await 关键字后,整个函数会像被“yield”了一样,暂停下来,直到异步任务的结果返回后,它才会被“唤醒”,继续执行后面的语句。

是不是觉得这个“暂停”、”唤醒“的操作,和 generator 异步非常相似?事实上,async/await 本身就是 generator 和 promise 异步方案的语法糖。它的诞生主要就是为了这个单纯而美好的目的——让你写得更爽,让你写出来的代码更美。

注:async/await 和 generator 方案,相较于 Promise 而言,有一个重要的优势:Promise 的错误需要通过回调函数捕获,try catch 是行不通的。而 async/await 和 generator 允许 try/catch。这也是一个可以作为命题点细节,大家留心把握。