JavaScript 异步编程

我们知道,无论是浏览器tab还是nodejs,默认情况下JavaScript通常都运行在一个进程中, 采用了 单线程+ 事件循环的机制来执行浏览器相关的处理任务(JavaScript Event Loop)。

程序执行过程中,所产生的处理任务(例如HTML解析, 交互响应等等),会被依次加入到任务队列中。 而Event Loop 则负责轮询并从队列中取出一个任务交给 JavaScript Engine执行。

JavaScript 的 Event Loop 机制保证了事件任务的按序执行, 同时,Engine 每次只能执行一个任务,并保证只有在当前任务执行完毕后才会开始执行下一个任务。 所以任务执行过程中对数据操作不会出现并发问题。

然而,采用串行的方式执行任务,会导致当遇到一个任务执行耗时特别长的时候, 出现用户UI交互相关的任务无法得到及时处理的问题。 举个例子

<a id="block" href="">Block for 5 seconds</a>
<p>
<button>This is a button</button>
<div id="statusMessage"></div>
<script>
    document.getElementById('block')
    .addEventListener('click', onClick);

    function onClick(event) {
        event.preventDefault();

        setStatusMessage('Blocking...');

        // 模拟一个需要执行5s的耗时任务
        setTimeout(function () {
            sleep(5000);
            setStatusMessage('Done');
        }, 0);
    }
    function setStatusMessage(msg) {
        document.getElementById('statusMessage').textContent = msg;
    }
    function sleep(milliseconds) {
        var start = Date.now();
        while ((Date.now() - start) < milliseconds);
    }
</script>

上面例子中,setTimeout会在任务队列中添加一个任务,sleep模拟了一个5s的耗时操作, 在这个耗时任务执行完毕之前,用户的交互操作是无法被响应的。

为了避免UI响应被阻塞,我们的程序尽量不要在主线程中同步执行耗时操作。 更好的做法是在主线程中将耗时操作任务交给子线程去异步执行,当任务执行完毕后再通知主线程获取任务结果。

Callback

在ES6之前,对于异步执行结果的处理方式基本上都是通过注册事件监听(event)或者回调函数来完成的。

基本实现思想就是,主线程中发起异步任务的同时,通过回调函数的方式,指定异步任务执行完毕后,需要执行的任务。 对于结果的处理通常会被封装在一个Callback函数中,当异步任务执行完毕,向事件队列中加入一个新的任务来执行Callback函数。

在大多数简单的业务场景下,使用这种方法非常方便,程序可读性也很好。 然而一旦业务逻辑复杂起来,就会陷入Callback Hell: 程序中回调函数嵌套了回调函数,可维护性骤然下降。 另外,如果程序还需要考虑到异常和错误的处理问题,使用Callback回调也比较繁琐。 因为通常无法提供一个通用的异常处理回调,我们不得不去逐一判断异常类型,然后写很多错误处理函数。

将callback函数作为函数的入参,并在合适的时候回调的编程风格也被称为Continuation-passing style

Promise

在程序逻辑复杂的情况下,由于函数嵌套层级过深导致程序可读性和可维护性下降,Callback相关的异步结果处理方式不够优雅。 作为Callback的替代方案,ES6引入的Promise为调用方提供了处理异步操作结果的新姿势。 (关于如何使用Promise的基础知识)

在程序控制流中,调用方通常并不关心异步任务的执行过程,而只负责发起任务和获取结果并处理; 而异步任务则负责执行异步操作,并根据最终不同的结果状态(成功或失败)通知给调用方。

Promise的基本设计思路就是,使用Promise对象将异步任务部分的处理逻辑和结果封装起来, 而Promise对象本身作为结果返回给调用方,调用方只需指定不同结果状态(成功或失败)后的处理流程即可。

这是一个很常见的“生产者-消费者”场景:Promise负责产出和分发结果(成功或失败),调用方负责处理不同的结果。

const promise = new Promise(function(resolve, reject) {
  // 异步操作发起
  // 异步操作回调
  {
    if (/* 异步操作成功 */){
        resolve(value);// 分发成功结果
    } else {
        reject(error);// 分发失败结果
    }
  }
});

// 调用方
promise.then(value=>{
    // 处理成功结果
}, error=>{
    // 处理失败结果
});

当我们通过Promise来封装一个异步结果,这意味着Promise对象处于三种状态的一种:

  • Pending: 异步任务还未执行完毕
  • Fulfilled: 异步任务执行成功
  • Rejected: 异步任务执行失败

从时序上来讲,当异步任务执行完毕,根据结果的不同,通过调用resolvereject函数, Promise的状态从Pending切换到Fulfilled或Rejected,且这个状态切换是单向不可逆的。

一旦状态发生切换,就会通知给调用方(消费者)执行对应的后续操作,因此,这个状态时序的保证是Promise能够处理异步流程的关键。

链式调用

then()方法挂载在Promise原型对象上,用来让调用方来指定对于不同状态的返回结果该如何处理:

onFulfilledonRejectd函数分别处理了fulfilledrejected两种状态的回调。 同时,then方法也返回了一个新的promise对象,它可以让调用方使用链式调用的方式继续处理业务逻辑。

那这个新的promise对象的状态是什么样的呢?假定promise对象P,它的then方法新返回的promise对象是Q, Q的状态由P的onFulfilledonRejectd函数的执行决定:

p.then(value=>{
    try{
        ...
        return a
    }catch(e){
         throw c
    }
}, error=>{
    return b;
}).then(val=>{
    // val is a or b
    // 捕获onFulfilled和onRejectd的返回结果
},error2=>{
    // error2 is c
    // 捕获onFulfilled和onRejectd执行过程中的异常
})

可以看到,只要是onFulfilledonRejectd函数正常返回的值(例如a,b)都会被Q的onFulfilled捕获执行; 如果执行过程中发生了异常,则会被Q的onRejectd捕获到。

还有一种情况需要单独拉出来说明一下,如果P的onFulfilled方法中返回了一个promise对象,那么这个promise对象就会被作为then方法的返回对象Q。 这样的机制有助于把promise的嵌套调用扁平化,避免出现callback那种嵌套地狱。

虽然Promise的出现改进了回调的嵌套问题, 尽管如此,一堆then方法的链式调用仍然显得代码冗余,可读性仍然有待提高。 使用ES6的Generator + Promise 的方式可以让我们写出更符合直觉的代码逻辑(线性的执行),配合流程管理模块,让代码能够自动地执行。 而ES7中更是将这一机制变成了async 函数来使用。

async 函数

ES7引入的async关键字让我们在异步编程的过程中更加方便的使用Promise, 它用于将一个函数声明为异步函数,表示当前函数中可能有耗时操作,需要异步执行。

async 函数的返回值是一个promise对象,这个对象P在异步函数执行之初就被创建了出来。 异步函数执行过程中,returnthrow操作都会使函数停止执行立即返回:

  • 异步函数 return 一个普通值x(非Promise对象),则异步函数的返回结果为Promise<x>,状态为fulfilled;
  • 异步函数 return 一个新的Promise Q, 则异步函数的返回结果为Q;
  • 异步函数 throw 一个error 则返回一个状态为rejected的Promise<err>

await 操作符

await 操作符通常会和async函数一起搭配使用,用来同步获取一个Promise对象的执行结果, 它比yeild具有更好的语义。 由于await 操作会阻塞当前操作,因此必须放在async 函数中使用。

总结

我们可以看到,如何解决异步结果分发和处理是JavaScript的异步编程中的核心问题。 搭配 Promise 与 async 函数 我们不仅可以写出更加符合直觉的代码逻辑,同时还能更优雅地处理异常操作。