async await和co库深入浅出

概述

本文介绍一下async await以及co库的原理,算是一点心得,最近几个月搞了一些事,但是都没有写成博客,这段时间集中整理出来。

JavaScript中的async await本身也是一个语法糖,本身async await也是基于Promise实现的,使用其的最大好处是能让异步代码看上去像同步代码一样更容易理解。

async await的基本用法

回顾一下JavaScript中的异步代码怎么写,以jQuery的get方法为例。

1
2
3
4
$.get(url, (data) => {
console.log('hello world 1');
});
console.log('hello world 2');

好了,所有人都知道先输出'hello world 2'再输出'hello world 1',但是回调函数嵌套太多时就会引起回调地狱,尤其是在NodeJS中。

后来发展的过程中出现了很多优秀的解决回调地狱问题的库,其中Promise脱颖而出,最终成为标准,现代浏览器以及NodeJS早就开始支持Promise了,它的用法如下。

1
2
3
4
5
6
7
8
9
new Promise((resolve, reject) => {
// do something asynchronous
// resolve if everything goes well
// reject if error
}).then(data => {
// handle data
}).catch(err => {
// handle error
});

有关Promise的内容可以参考之前的文章,网上也有不少相关资料,它的出现可以说很方便地解决了回调嵌套过多的问题,但是这样的代码看上去还是令人不舒服(其实也还好),于是社区各路大神又开始献计献策,他们从别的语言中得到了灵感,于是async await被引入到了JavaScript中。

相对Promise而言,async await的用法看上去就舒服很多了。

1
2
3
4
5
6
7
async function foo() {
const n1 = await f1();
console.log(n1);
const n2 = await f2();
console.log(n2);
return n1 + n2;
}

这里可以看到相比一般函数而言,这里多了async和await两个关键字进行修饰,其中async用于修饰function关键字,表示这是一个异步函数,而await呢,实际上后面跟的是一个Promise(前面说过async await是基于Promise实现的)。

具体是怎样的效果呢,f1f2两个函数实际上返回的是一个Promise,假定f1需要0.5s才能resolve,f2需要0.7s才能resolve,则当foo执行到f1时,会等0.5s直到它resolve了值,0.5s之后输出结果,再执行下一句,f2在0.7s之后resolve了一个值,输出之后,再将相加的结果返回,也就是是说最终foo执行需要大约1.2s左右的时间。

具体如下例所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function f1() {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(1), 5e2);
});
}
function f2() {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(2), 7e2);
});
}
async function foo() {
const n1 = await f1();
console.log(n1);
const n2 = await f2();
console.log(n2);
return n1 + n2;
}
foo().then(result => console.log(result));

// output:
// 1, after 0.5s
// 2, after another 0.7s
// 3, after 1.2s in total

注意上面代码的最后一行,foo执行后这里跟了一个then方法,聪明的读者一定想到了,async函数返回的结果实际上也是一个Promise。

async await的基本用法就是这样,再来看一下foo函数,里面的异步是不是更容易理解呢。

不过知其然还要知其所以然,我们一开始就说过,async await只是一个语法糖,只要使用generator和Promise的话,再加上一点点的黑魔法,我们能实现个八九不离十的效果出来。

什么,generator你也忘得差不多了?

生成器函数复习

generator之前也写过一篇文章,虽然很浅显,但是足够我们用了,在我们继续async await之前,先来复习一下它吧。

1
2
3
4
5
6
7
8
9
function* bar() {
const n1 = yield 3;
const n2 = yield 4;
return n1 + n2;
}
const gen = bar();
console.log(gen.next()); // { value: 3, done: false }
console.log(gen.next(5)); // { value: 4, done: false }
console.log(gen.next(6)); // { value: 11, done: true }

以上就是一个生成器的基本用法,其中有三点需要注意的地方。

  1. 定义生成器函数的时候function关键字后面要加星号,即function* foo的形式,function *foo的形式也可以,但是个人偏好第一种,因为这样直观地区别开了生成器函数和普通函数。
  2. 关键字yield是用于向外“产出”值的地方,生成器函数被调用后返回的实际上是一个可遍历的对象,通过该对象的next方法可以得到yield产出的值。这里我们第一次调用next,产出的值是3,也就是yield 3对应的{ value: 3, done: false},同理第二次调用产出的是4,最后一次呢?如果有return则是return的值,跟上例一样,如果没有呢?则为undefined,注意每次产出的结果是一个对象,该对象中有两个属性,分别是value和done,value的意思大家都知道了,done呢?其实就是表示当前生成器对象是否已经遍历完了,可以看到第三次调用时,done已经变成了true。
  3. 最后一点是yield表达式的值的问题,注意yield 3并不表示n1等于3,yield表达式的值,是下一次next被调用时传入的值,所以可以看到第二次调用next时我们传入了5,所以n1实际上等于5,同理n2等于6,所以最终返回的结果是11,这点一定要注意。

除此之外generator还经常和for of一起用,关于generator的更多知识大家可以去查找相关资料,这里就不多做介绍了,接下来我们开始来学习一个重要的相关库,一个新的黑魔法的大门就要向我们敞开了。

co库及其源码浅析

co库是一个利用generator和Promise让异步代码更直观的辅助库,它的使用形式如下。

1
2
3
4
5
6
7
8
9
10
import co from 'co';

co(function* () {
const result = yield Promise.resolve(true);
return result;
}).then(function (value) {
console.log(value);
}, function (err) {
console.error(err.stack);
});

这种形式的写法和上面使用async await的写法几乎是一样的,如果使用async await的话,代码如下。

1
2
3
4
5
6
7
8
(async function () {
const result = await Promise.resolve(true);
return result;
})().then(function (value) {
console.log(value);
}, function (err) {
console.error(err.stack);
});

co库虽然看上去很神奇,但是它的原理却并不复杂,它的工作机制主要是利用generator会转让控制权来实现的。

可以看到co库接收了一个generator函数,实际上co本身也只是一个高阶函数,传入其中的generator函数的执行会由这个高阶函数来控制。

它会一个阶段一个阶段地执行generator,每一个阶段执行完成之后,它又会通过Promise的then方法,将值传给generator中当前的yield表达式,这样控制权就又交回给了generator,如此往复执行,知道generator生成的值的done为true为止。

以下就是一个简单的co库的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const co = function (gen) {
const g = gen();
if (Object.prototype.toString.call(g) !== '[object Generator]') {
throw new Error('parameter must be a generator function');
}
(function next(val) {
const stage = g.next(val);
if (stage.done) {
return stage.value;
}
if (!!stage.value.then) {
stage.value.then(next);
} else {
stage.value(next);
}
})();
};

很明显,传进来的gen是一个generator函数,并且首先就被执行生成了一个generator对象,然后就是一个名为next的IIFE,IIFE首先会执行生成器对象的next方法,直到stage.donetrue(生成器遍历结束)之前,如果他会去执行next返回回来的Promise的then方法,而传入then方法中的参数就是名为next的IIFE自身,这种巧妙的设计使得stage.value(某个yield的出来的Promise)在resolve之前,generator对象不会继续往后执行,直到stage.value.then(next)被执行之后,再次执行g.next(val),这时才会将控制权交还给generator对象。

如果读者还是觉得上面的解释比较绕口,可以将上述代码复制粘贴到控制台,然后再传一个generator函数给co,在程序执行之初打上断点,通过一步一步调试的方法了解整个执行过程,多调试几遍,再结合上面的文字,相信很容易就能理解co库的原理。

await单独使用的情况

值得一提的是,async await并不是一直都是成双成对出现的,我们也可以只是用await来处理异步操作,比如如下代码。

1
2
3
4
5
6
const result = await new Promise((resolve, reject) => {
setTimeout(() => {
resolve('hello await');
}, 1e3);
});
console.log(result);

上述代码实际上会在1s之后输出'hello await',但熟悉JavaScript执行原理的同学们肯定会想,按理说result应该是异步返回的,console.log(result)不应该输出undefined吗?

实际上await单独使用也是一个语法糖,上面的代码会被JavaScript引擎预处理为一个IIFE,最终执行的代码如下所示。

1
2
3
4
5
6
(async () => {void (result = await new Promise((resolve, reject) => {
setTimeout(() => {
resolve('hello await');
}, 1e3);
}));
console.log(result);})()

可以看到我们单独使用await时,代码片段最终还是被包裹成了一个async await函数。

总结

async await关键字本质上和co的实现是类似的,它并没有引入更特殊的机制,它的出现只是为了让我们的异步代码看上去更易于理解,目前nodejs的web主流库之一koa2,就大量利用了async await的写法。

本文主要介绍了一下async await的使用和co库的实现原理,希望能对看到这篇文章的朋友,尤其是初学者有所启发,零外如有谬误,欢迎指正。

参考

  1. ES6学习笔记9:生成器函数 - 传不习乎
  2. ES6学习笔记16:Promise - 传不习乎
  3. tj/co - GitHub