about getting rid of callback hell and implementation of more elegant async flow control with Promise, generator, co and async/await

打 js 代码过程中的大量异步操作和回调是个让人万分痛苦的事情,掉到这个坑里之后看自己的代码都会让人感到十分不安。为了让异步代码得到优化,出现了很多优秀的解决方案,其中最为炙手可热的包括 Promise, bluebird, Q, thunk, generator, co, async/await,以下对相应形式的代码组织进行简要介绍。Promise 和 thunk 两种 lazy evaluation 的实现成为了异步代码优化的基础,由于不久的某一天可能 co 的 yieldable 就不支持 thunk 了,且 Promise 的应用较 thunk 本身也更为广泛,本文只以 Promise 为基础进行介绍。如果想要系统了解 Promise 建议直接跳 mdn,在对 Promise 有了清晰的认识之后本文将对 es5 背景下对 Promise 的应用较有开创性的库进行介绍,以 bluebird 为主。在对 Promise 的原理,应用与不足熟悉之后本文将会对 es6 引入的 generator 进行介绍,generator 与相应的实例 runner 将异步流程带入了一个新的篇章,而 async/await 可以看作 generator 控制的一种语法糖,用 async 替换了 * 用 await 替换了 yield 使语意更为清晰,同时在语言内置了 runner 减少了与流程无关的代码。

index

  1. Promise
  2. bluebird/Q
  3. generator
  4. co
  5. async/await

Promise

Promise 和 thunk 两种 lazy evaluation 的实现成为了异步代码优化的基础,由于不久的某一天可能 co 的 yieldable 就不支持 thunk 了,且 Promise 的应用较 thunk 本身也更为广泛,本文只以 Promise 为基础进行介绍。

Promise 是一种用于异步操作的对象,一个 Promise 实例代表一种在代码执行时并不能得知结果的操作,但是操作的结果会在将来 Promise 内的操作完成后返回。当一个 Promise 被实例化以后它一定处于以下三种状态之一:

  • pending: 初始状态,没有完成也没有被拒绝
  • fulfilled: 意味着操作成功完成
  • rejected: 操作失败

一个 pending 的 Promise 会根据操作的执行情况最终转化为 fulfilled 或者 rejected 且不可逆。

在 es6 中 Promise 是一个全局的构造函数,Promise 构造函数的原型方法和构造方法都会返回 Promise 实例,所以 Promise 是非常方便链式调用的。

new Promise(/* executor */ function (resolv, reject) { ... });
Promise.resolve(value | Promsie | thenable);
Promise.reject(reason);
Promise.then(onFulfilled, onRejected);
Promise.catch(onRejected);
Promise.all(iterable);
Promise.race(iterable);

当一个 Promise 产生之后他就进入了 pending 状态,直到内部操作遇到 resolve 或是 reject 该 Promise 状态改变并将 value 或是 reason 传入 onFulfilledonRejected, 这两个操作由实例的原型方法 then 或是 catch 传入。由于 then 方法和 catch 方法都返回 Promise 对象(具体的返回规则见下文)。所以在 Promise 的使用过程中链式调用是相当容易的,因而为连续的异步流程控制提供了一种解决方案。

Promise.resolve([1, 2, 3])
.then((arr) => {
    console.log('in then 1', arr)
    return Promise.resolve(arr.map((num) => num * num))
})
.then((arr) => {
    console.log('in then 2', arr)
    throw new Error('make an error')
})
.catch((e) => {
    console.log('in catch 1', e)
    return Promise.reject('another error')
})
.catch((e) => {
    console.log('in catch 2', e)
    return Promise.resolve("let's make this to then method")
})
.then((msg) => {
    console.log('in then 3', msg)
})

// in then 1 [1, 2, 3]
// in then 2 [1, 4, 9]
// in catch 1 Error: make an error(…)
// in catch 2 another error
// in then 3 let's make this to then method

在 then 和 catch 方法中返回值会被转化为 Promise 实例,Error 类以外的值会被直接 resolve 而被 throw 的 Error 类的值将会被 Promise.reject 返回。

Promise.resolve([1, 2, 3])
.then((result) => {
    console.log('in then 1', result)
    throw new Error('make an error')
})
.catch((e) => {
    console.log('in catch 2', e)
    return 'let us make some promise'
})
.then((msg) => {
    console.log('in then 2', msg)
})

// in then 1 [1, 2, 3]
// in catch 1 Error: make an error(…)
// in then 2 let us make some promise

以上是整个 Promise 的流程。以下接住一个 页面 dialog 与 ajax 请求的例子进行说明。 dialog: 需要设计一个在点击后能够更具 确认/取消 情况执行异步操作的对象。 ajax: 以 $.ajax() 为例。

/*!
 * 传统的 Dialog 写法,为了节省篇幅此处只提供 Dialog 的调用模式
 * 场景 弹出 Dialog 等待用户确认,如果确认进行 ajax 请求,如果成功页面跳转。
 */

// 传统回调版本代码
new Dialog({
    id: 'someDialog',
    content: {
        desc: '是否确认跳转',
        confirm: '确认',
        cancel:  '取消'
    },
    confirm: function () {
        $.ajax({
            url: 'some/webapi'
            success: function (res) {
                if (res.status >= 200 && res.status <= 300) {
                    window.href = window.host + 'another/route'
                }
                else {
                    Dialog.close('someDialog')
                }
            },
            fail: function (e) {
                Dialog.contentChange({
                    id: 'someDialog',
                    content: ...
                })
                Dialog.close()
            }
        })
    },
    cancel: function () {
        somePageData = ''
        Dialog.close('someDialog')
    }
})

// Promise 版本代码
new Dialog({
    id: 'someDialog',
    content: {
        desc: '是否确认跳转',
        confirm: '确认',
        cancel:  '取消'
    }
}).init()
.then(function (clicked) {
    if (clicked === 'confirm') {
        // 之后的代码将会提供如何改造 $.ajax
        return Promise.resolve(asyncGet('some/webapi'))
    }
    else {
        somePageData = ''
        Dialog.close('someDialog')
    }
})
.then(function (res) {
    if (res.status >= 200 && res.status <= 300) {
        window.href = window.host + 'another/route'
    }
    else {
        Dialog.close('someDialog')
    }
})
.catch(function (e) {
    Dialog.contentChange({
        id: 'someDialog',
        content: ...
    })
    Dialog.close()
})

改造 $.ajax 以 get 方法为例,读者们可以附带研究一下 Promise 的构造函数的调用方式,关于 Promise 的各种重要方法请参见 推荐阅读,在此特别感谢 mdn 提供关于 Promise 的清晰讲解。

function asyncGet (url) {
    return new Promise (resolve, reject) {
        $.get({
            url: url,
            success: function (data) {
                resolve(data)
            },
            error: function (e) {
                reject(e)
            }
        })
    }
}

在使用 Promsie 改造代码以后比较明显的好处是 1. 代码的组织更接近与操作的实际流程; 2. 金字塔显然是没有普通的异步深了。不过 .then.catch 这个机制明显还是存在一定的无关代码。 在了解了 Promise 的基本使用之后, all 和 race 两种方法为流程控制提供了更多的可能性。 all 可以并发执行一系列 Promise 并在所有 Promise resolve 后将结果以数组的形式返回,可谓异步的 map;在错误处理方面,当这些 Promise 有任意一个 reject 后,错误将会抛出同时终止这一系列的 Promise 操作。而错误也可以统一处理减少了很多的重复代码。 race 方法与 all 的编写机制类似,但是 race 的核心流程却有很大差异, 对于这一系列 Promise 首先返回的操作将被传入后续操作,而其他的 Promise 操作随即停止。在 Promise 的最后,还是强烈推荐大家阅读以下文档。

recommending reading

  1. Promise()
  2. Promise.prototype.then()
  3. Promise.prototype.catch()
  4. Promise.resolve()
  5. Promise.all()

bluebird/Q

to be optimized 在 es5 期间,Promise 是不被直接支持的,而面对 Promise 的诱惑,小伙伴们又是无法拒绝的,因而催生了一帮牛逼人写了实现 Promise 的库,同时附上很多相关工具。在这些库当中,以 bluebird 和 Q 的使用最为广泛。

Q

Q 围绕 Promise/A+ 实现了 Promise 的基本流程与大量工具函数,详情参见 Q Api

blurbird

先安利

  • api 设计上简洁实用
  • bluebird 的 debug 相对容易, bug 比较容易被 trace
  • bluebird 兼容性很好,服务多平台
  • 符合 A+ 规范,跑的比原生的快
  • 对原有的传 cb 的函数改造起来非常方便

bluebird 在 Promise 标准的基础上提供了快捷一些改造异步方法(先传参数,最后传回调的方法)的工具函数。如何与原有的 callback (node style -> argument first and cb last)函数愉快相处。详细使用大家还是自己看文档 Promisification 部分。

generator

genertor 函数可以暂停执行,可以在执行过程中与外部交互数据,拥有这样特征的事物让大家仿佛看到了灵活优雅的控制异步流程的春天,万事俱备只欠东风,用什么在合适的情况下执行 generator 内的流程? 这个问题很快得到了解决,现在使用比较广泛的主要是由著名程序员 tj 编写的 co, 其实现并不复杂,但却为 generator 在异步流程的优化过程中插上了翅膀。

废话了不少,先来介绍一下 generator 吧。

Generator函数有多种理解角度。从语法上,首先可以把它理解成,Generator函数是一个状态机,封装了多个内部状态 执行Generator函数会返回一个遍历器对象,也就是说,Generator函数除了状态机,还是一个遍历器对象生成函数。返回的遍历器对象,可以依次遍历Generator函数内部的每一个状态。 形式上,Generator函数是一个普通函数,但是有两个特征。一是,function关键字与函数名之间有一个星号;二是,函数体内部使用yield语句,定义不同的内部状态(yield语句在英语里的意思就是“产出”)。

function* helloWorldGenerator() {
    yield 'hello';
    yield 'world';
    return 'ending';
}

var gen = helloWorldGenerator();

gen.next();
//  {value: "hello", done: false}

gen.next();
// {value: "world", done: false}

gen.next();
// {value: "ending", done: true}

gen.next()
// { value: undefined, done: true }

第一次调用,Generator函数开始执行,直到遇到第一个yield语句为止。next方法返回一个对象,它的value属性就是当前yield语句的值hello,done属性的值false,表示遍历还没有结束。

第二次调用,Generator函数从上次yield语句停下的地方,一直执行到下一个yield语句。next方法返回的对象的value属性就是当前yield语句的值world,done属性的值false,表示遍历还没有结束。

第三次调用,Generator函数从上次yield语句停下的地方,一直执行到return语句(如果没有return语句,就执行到函数结束)。next方法返回的对象的value属性,就是紧跟在return语句后面的表达式的值(如果没有return语句,则value属性的值为undefined),done属性的值true,表示遍历已经结束。

第四次调用,此时Generator函数已经运行完毕,next方法返回对象的value属性为undefined,done属性为true。以后再调用next方法,返回的都是这个值。

总结一下,调用Generator函数,返回一个遍历器对象,代表Generator函数的内部指针。以后,每次调用遍历器对象的next方法,就会返回一个有着value和done两个属性的对象。value属性表示当前的内部状态的值,是yield语句后面那个表达式的值;done属性是一个布尔值,表示是否遍历结束。

以上是 generator 对象在执行过程中向外传输数据的流程,需要注意的是,yield 语句本身是没有返回值的,或者说总是返回 undefined 但是经常可以看到 var resData = yield something.json() 这就让人觉得稍稍有些费解了,这个 resData 的值究竟是从哪儿来的?其实来源于输出下一个 yield 的 next 方法的参数。 换言之, yield 语句好比一根管道,其右侧是此次 next 的输出,其左侧是下一次执行时传入的数据。


function * gen () {
    var a = yield '1st yield';
    console.log(a)
    var b = yield '2nd yield'
    console.log(b)
    return 'good game'
}

var g = gen()

g.next()
// {value: "1st yield", done: false} <- next, yield 输出

g.next('the arg is a')
// the arg is a  <- console.log(a)
// {value: "2nd yield", done: false} <- next, yield 输出

g.next('the arg is b')
// the arg is b  <- console.log(b)
// {value: "good game", done: true} <- next, return 输出

g.next()
// {value: undefined, done: true}

相信通过以上实例,对于 generator 对象实例的执行过程以及数据流动有了比较明晰的认识。此外关于 generator 还需要了解的便是不同实例之间的嵌套组合方式。首先,在一个 gennearator 实例内调用另一个 generator 是没有作用。如果需要将流程控制交给其他实例可以使用yield * 语句,如果不加 * 则将遍历器对象作为一个实例输出。

function* foo() {
    yield 'a';
    yield 'b';
}

function* bar() {
    yield 'x';
    yield * foo()
    yield 'y';
}

// 等同于
function* bar() {
    yield 'x';
    yield 'a';
    yield 'b';
    yield 'y';
}

在对 generator 对象有了基本了解后,便来到了核心问题,如何利用 generator 函数进行流程控制?答案是首先需要一个执行器,在一个 yield 执行完成后将关乎下一步操作的数据传会 generator 内部并启动执行过程,换言之就是在适当的时机调用 genInstance.next(resData)。前文已经提到 tj/co 这里直接进入下一部分吧。

co

执行器实现源码解读,阮老师的这篇文很齐全,这里将关键部分做一个摘录。

co模块的原理 为什么co可以自动执行Generator函数?

前面说过,Generator就是一个异步操作的容器。它的自动执行需要一种机制,当异步操作有了结果,能够自动交回执行权。两种方法可以做到这一点。

(1)回调函数。将异步操作包装成Thunk函数,在回调函数里面交回执行权。

(2)Promise 对象。将异步操作包装成Promise对象,用then方法交回执行权。

co模块其实就是将两种自动执行器(Thunk函数和Promise对象),包装成一个模块。使用co的前提条件是,Generator函数的yield命令后面,只能是Thunk函数或Promise对象。

关于 yield 之后能跟的对象,这里需要做一个更正,引用 co 的文档。

The yieldable objects currently supported are:

  • promises
  • thunks (functions)
  • array (parallel execution) 并发执行
  • objects (parallel execution) 并发执行
  • generators (delegation)
  • generator functions (delegation)

Nested yieldable objects are supported, meaning you can nest promises within objects within arrays, and so on!

接下来我们结合具体代码讲解 co 的调用过程。

  1. 直接调用 co(fn*).then( val => )
co(function* () {
    var result = yield new Promise(function (resolve, reject) {
        setTimeout(function () {
            console.log('in the timer')
            resolve('the timer resolved')
        }, 1000)
    })
    console.log(result)
    result = yield Promise.resolve('another')
    return result;
}).then(function (value) {
      console.log(value);
}, function (err) {
    console.error(err.stack);
});

// in the timer
// the timer resolved
// another
  1. 包装函数 var fn = co.wrap(fn*)

将一个 generator 对象转为一个普通函数,并返回一个 Promise 。

var fn = co.wrap(function* (val) {
    return yield Promise.resolve(val);
});

fn(true).then(function (val) {
    console.log(val)
});

// true

async/await

async/await 并非 es6 中包含的特性,正式的标准需要待到 es7 来到,其可以看做 generator + runner 这种控制模式的语法糖 async => co.wrap(function * ()) await => yield。async/await 的优势在于更加直截了当的语意,省略掉外部引入的执行器。在 async/await 的使用过程中有一些值得注意的点(同时针对 generator + co)

第一点,await命令后面的Promise对象,运行结果可能是rejected,所以最好把await命令放在try…catch代码块中。

async function myFunction() {
  try {
    await somethingThatReturnsAPromise();
  } catch (err) {
    console.log(err);
  }
}

// 另一种写法
async function myFunction() {
  await somethingThatReturnsAPromise()
  .catch(function (err) {
    console.log(err);
  };
}

第二点,多个await命令后面的异步操作,如果不存在继发关系,最好让它们同时触发。

let foo = await getFoo();
let bar = await getBar();

上面代码中,getFoo和getBar是两个独立的异步操作(即互不依赖),被写成继发关系。这样比较耗时,因为只有getFoo完成以后,才会执行getBar,完全可以让它们同时触发,以缩短程序的执行时间。

// 写法一
let [foo, bar] = await Promise.all([getFoo(), getBar()]);

// 写法二
let fooPromise = getFoo();
let barPromise = getBar();
let foo = await fooPromise;
let bar = await barPromise;

第三点,await命令只能用在async函数之中,如果用在普通函数,就会报错。(yield 只能用在 generator 内)

async function dbFuc(db) {
  let docs = [{}, {}, {}];

  // 报错
  docs.forEach(function (doc) {
    await db.post(doc);
  });
}

上面代码会报错,因为await用在普通函数之中了。但是,如果将forEach方法的参数改成async函数,也有问题。

async function dbFuc(db) {
  let docs = [{}, {}, {}];

  // 可能得到错误结果
  docs.forEach(async function (doc) {
    await db.post(doc);
  });
}

上面代码可能不会正常工作,原因是这时三个db.post操作将是并发执行,也就是同时执行,而不是继发执行。正确的写法是采用for循环。

async function dbFuc(db) {
  let docs = [{}, {}, {}];

  for (let doc of docs) {
    await db.post(doc);
  }
}

如果确实希望多个请求并发执行,可以使用Promise.all方法。

async function dbFuc(db) {
  let docs = [{}, {}, {}];
  let promises = docs.map((doc) => db.post(doc));

  let results = await Promise.all(promises);
  console.log(results);
}

// 或者使用下面的写法

async function dbFuc(db) {
  let docs = [{}, {}, {}];
  let promises = docs.map((doc) => db.post(doc));

  let results = [];
  for (let promise of promises) {
    results.push(await promise);
  }
  console.log(results);
}

ES6将await增加为保留字。使用这个词作为标识符,在ES5是合法的,在ES6将抛出SyntaxError。

最后,祝大家异步代码写的愉快。