JavaScript 异步编程语法

这篇文章讲的是异步编程语法的发展过程,异步方法的调用是如何逐渐变成同步语法的。

异步编程的方法,大概有下面四种

  • 回调函数
  • 事件监听
  • 发布/订阅
  • Promise 对象

ECMAScript 6 (简称 ES6 )作为下一代 JavaScript 语言,将 JavaScript 异步编程带入了一个全新的阶段。这组系列文章的主题,就是介绍更强大、更完善的 ES6 异步编程方法。

新方法比较抽象,初学时,我常常感到费解,直到很久以后才想通,异步编程的语法目标,就是怎样让它更像同步编程。

这边文章讲的是这几种语法之间的关联,如果阅读过程中对于任意一个语法有深入理解的需求,可以去看一下阮一峰老师的ES6语法入门

Generator

总的来说,async 是Generator函数的语法糖,并对Generator函数进行了改进。

Generator函数简介

Generator 函数是一个状态机,封装了多个内部状态。执行 Generator 函数会返回一个遍历器对象,可以依次遍历 Generator 函数内部的每一个状态,但是只有调用next方法才会遍历下一个内部状态,所以其实提供了一种可以暂停执行的函数。yield表达式就是暂停标志。

有这样一段代码:

1
2
3
4
5
6
7
function* helloWorldGenerator() {
yield 'hello';
yield 'world';
return 'ending';
}

var hw = helloWorldGenerator();

调用及运行结果:

1
2
3
4
hw.next()// { value: 'hello', done: false }
hw.next()// { value: 'world', done: false }
hw.next()// { value: 'ending', done: true }
hw.next()// { value: undefined, done: true }

由结果可以看出,Generator函数被调用时并不会执行,只有当调用next方法、内部指针指向该语句时才会执行,即函数可以暂停,也可以恢复执行。每次调用遍历器对象的next方法,就会返回一个有着valuedone两个属性的对象。value属性表示当前的内部状态的值,是yield表达式后面那个表达式的值;done属性是一个布尔值,表示是否遍历结束。

Generator函数暂停恢复执行原理

要搞懂函数为何能暂停和恢复,那你首先要了解协程的概念。

一个线程(或函数)执行到一半,可以暂停执行,将执行权交给另一个线程(或函数),等到稍后收回执行权的时候,再恢复执行。这种可以并行执行、交换执行权的线程(或函数),就称为协程。

协程是一种比线程更加轻量级的存在。普通线程是抢先式的,会争夺cpu资源,而协程是合作的,可以把协程看成是跑在线程上的任务,一个线程上可以存在多个协程,但是在线程上同时只能执行一个协程。它的运行流程大致如下:

  1. 协程A开始执行
  2. 协程A执行到某个阶段,进入暂停,执行权转移到协程B
  3. 协程B执行完成或暂停,将执行权交还A
  4. 协程A恢复执行

协程遇到yield命令就暂停,等到执行权返回(调用next方法),再从暂停的地方继续往后执行。它的最大优点,就是代码的写法非常像同步操作,如果去除yield命令,简直一模一样。

执行器

通常,我们把执行生成器的代码封装成一个函数,并把这个执行生成器代码的函数称为执行器,co 模块就是一个著名的执行器。

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

  1. 回调函数。将异步操作包装成 Thunk 函数,在回调函数里面交回执行权。
  2. Promise 对象。将异步操作包装成 Promise 对象,用then方法交回执行权。

回调函数的执行器(yield Thunk函数)

下面就是一个基于 Thunk 函数的 Generator 执行器。

1
2
3
4
5
6
7
8
9
10
11
12
13
function run(fn) {
var gen = fn();

function next(err, data) {
var result = gen.next(data);
if (result.done) return;
result.value(next);
}

next();
}

run(gen);

上面代码的 run 函数,就是一个 Generator 函数的自动执行器。内部的 next 函数就是 Thunk 的回调函数。 next 函数先将指针移到 Generator 函数的下一步(gen.next 方法),然后判断 Generator 函数是否结束(result.done 属性),如果没结束,就将 next 函数再传入 Thunk 函数(result.value 属性),否则就直接退出。

有了这个执行器,执行 Generator 函数方便多了。不管有多少个异步操作,直接传入 run 函数即可。当然,前提是每一个异步操作,都要是 Thunk 函数,也就是说,跟在 yield 命令后面的必须是 Thunk 函数。

1
2
3
4
5
6
7
8
var gen = function* (){
var f1 = yield readFile('fileA'); //readFile是一个Thunk函数,返回一个函数用于传入回调函数
var f2 = yield readFile('fileB');
// ...
var fn = yield readFile('fileN');
};

run(gen);

上面代码中,函数 gen 封装了 n 个异步的读取文件操作,只要执行 run 函数,这些操作就会自动完成。这样一来,异步操作不仅可以写得像同步操作,而且一行代码就可以执行。

Thunk 函数并不是 Generator 函数自动执行的唯一方案。因为自动执行的关键是,必须有一种机制,自动控制 Generator 函数的流程,接收和交还程序的执行权。回调函数可以做到这一点,Promise 对象也可以做到这一点。

Promise对象的执行器(yield Promise)

一个基于 Promise 对象的简单自动执行器(或者说一个只能yield Promise的执行器):

1
2
3
4
5
6
7
8
9
10
11
12
function run(gen){
var g = gen(); //调用生成器,生成迭代器g

function next(data){ //定义执行器的next函数
var result = g.next(data); //调用迭代器g的next函数,运行直至遇到下一个yield
if (result.done) return result.value; //如果迭代器执行完成,则返回结果
result.value.then(function(data){ //result.value是个promise,在then中继续调用执行器的next方法,并传入promise。resolve的data
next(data);
});
}
next(); // 直接调用一次执行器的next方法
}

我们使用时,可以这样使用即可,

1
2
3
4
5
6
7
8
9
10
11
12
13
function* foo() {
let response1 = yield fetch('https://xxx') //返回promise对象
console.log('response1')
console.log(response1)
let response2 = yield fetch('https://xxx') //返回promise对象
console.log('response2')
console.log(response2)
}
run(foo);
//1. g = foo();生成器foo执行后生成迭代器g
//2. 第一次运行执行器的next方法,不传入参数,此时的result=g.next(data)的data为undefined,result为{value: fetch('https://xxx'), done: false}
// 3. done为false,继续执行,result.value.then其实就是fetch('https://xxx').then,then的回调函数中传入的data就是fetch函数的结果,回调函数中再次调用执行器的next方法,此时把fetch的结果传入。
// 4. 第二次运行执行器的next方法,传入的data为上一步fetch返回的结果,通过g.next(data)继续运行foo函数,并把data赋值给response1,然后打印data,知道遇到第二个yield,此时执行器的next函数中的result.value就等于第二个fetch。重复上一步。

上面代码中,只要 Generator 函数还没执行到最后一步,next函数就调用自身,以此实现自动执行。通过使用生成器配合执行器,就能实现使用同步的方式写出异步代码了,这样也大大加强了代码的可读性。

*上面这种方式看起来就很像async/await了,把上面的yield换成await,function 换成async,其实就是await在等待执行器将运行权限返回回来。**有一点通过内置执行器的不断回调将回调或者then给捋直了,捋成了同步的样子,通过yield将线程的执行权交出去,执行器中的异步函数返回数据后,将数据连同执行权通过next方法交会给生成器中

CO函数

有一个 Generator 函数,用于依次读取两个文件。

1
2
3
4
5
6
var gen = function* (){
var f1 = yield readFile('/etc/fstab');
var f2 = yield readFile('/etc/shells');
console.log(f1.toString());
console.log(f2.toString());
};

co 函数库可以让你不用编写 Generator 函数的执行器。

1
2
var co = require('co');
co(gen);

上面代码中,Generator 函数只要传入 co 函数,就会自动执行。

co 函数返回一个 Promise 对象,因此可以用 then 方法添加回调函数。

1
2
3
co(gen).then(function (){
console.log('Generator 函数执行完成');
})

上面代码中,等到 Generator 函数执行结束,就会输出一行提示。

CO函数源码

首先,co 函数接受 Generator 函数作为参数,返回一个 Promise 对象。

1
2
3
4
5
6
function co(gen) {
var ctx = this;

return new Promise(function(resolve, reject) {
});
}

在返回的 Promise 对象里面,co 先检查参数 gen 是否为 Generator 函数。如果是,就执行该函数,得到一个内部指针对象;如果不是就返回,并将 Promise 对象的状态改为 resolved 。

1
2
3
4
5
6
7
8
function co(gen) {
var ctx = this;

return new Promise(function(resolve, reject) {
if (typeof gen === 'function') gen = gen.call(ctx);
if (!gen || typeof gen.next !== 'function') return resolve(gen);
});
}

接着,co 将 Generator 函数的内部指针对象的 next 方法,包装成 onFulefilled 函数。这主要是为了能够捕捉抛出的错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function co(gen) {
var ctx = this;

return new Promise(function(resolve, reject) {
if (typeof gen === 'function') gen = gen.call(ctx);
if (!gen || typeof gen.next !== 'function') return resolve(gen);

onFulfilled();
function onFulfilled(res) {
var ret;
try {
ret = gen.next(res);
} catch (e) {
return reject(e);
}
next(ret);
}
});
}

最后,就是关键的 next 函数,它会反复调用自身。

1
2
3
4
5
6
7
8
function next(ret) {
if (ret.done) return resolve(ret.value);
var value = toPromise.call(ctx, ret.value);
if (value && isPromise(value)) return value.then(onFulfilled, onRejected);
return onRejected(new TypeError('You may only yield a function, promise, generator, array, or object, '
+ 'but the following object was passed: "' + String(ret.value) + '"'));
}
});

上面代码中,next 函数的内部代码,一共只有四行命令。

第一行,检查当前是否为 Generator 函数的最后一步,如果是就返回。

第二行,确保每一步的返回值,是 Promise 对象。

第三行,使用 then 方法,为返回值加上回调函数,然后通过 onFulfilled 函数再次调用 next 函数。

第四行,在参数不符合要求的情况下(参数非 Thunk 函数和 Promise 对象),将 Promise 对象的状态改为 rejected,从而终止执行。

async/await

ES7 中引入了 async/await,这种方式能够彻底告别执行器和生成器,实现更加直观简洁的代码。根据 MDN 定义,async 是一个通过异步执行并隐式返回 Promise 作为结果的函数。可以说async 是Generator函数的语法糖,并对Generator函数进行了改进。

前文中的代码,用async实现是这样:

1
2
3
4
5
6
7
8
const foo = async () => {
let response1 = await fetch('https://xxx')
console.log('response1')
console.log(response1)
let response2 = await fetch('https://xxx')
console.log('response2')
console.log(response2)
}

一比较就会发现,async函数就是将 Generator 函数的星号(*)替换成async,将yield替换成await,仅此而已。

async函数对 Generator 函数的改进,体现在以下四点:

  1. 内置执行器。Generator 函数的执行必须依靠执行器,而 async 函数自带执行器,无需手动执行 next() 方法。
  2. 更好的语义。async和await,比起星号和yield,语义更清楚了。async表示函数里有异步操作,await表示紧跟在后面的表达式需要等待结果。
  3. 更广的适用性。co模块约定,yield命令后面只能是 Thunk 函数或 Promise 对象,而async函数的await命令后面,可以是 Promise 对象和原始类型的值(数值、字符串和布尔值,但这时会自动转成立即 resolved 的 Promise 对象)。
  4. 返回值是 Promise。async 函数返回值是 Promise 对象,比 Generator 函数返回的 Iterator 对象方便,可以直接使用 then() 方法进行调用。

这里的重点是自带了执行器,相当于把我们要额外做的(写执行器/依赖co模块)都封装了在内部。比如:

1
2
3
async function fn(args) {
// ...
}

等同于:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function fn(args) {
return spawn(function* () {
// ...
});
}

function spawn(genF) { //spawn函数就是自动执行器,跟简单版的思路是一样的,多了Promise和容错处理
return new Promise(function(resolve, reject) {
const gen = genF(); // 执行生成器
function step(nextF) {
let next;
try {
next = nextF(); // next就是gen.next()
} catch(e) {
return reject(e);
}
if(next.done) { //如果所有await执行完了,则async就可以resolve了
return resolve(next.value);
}
Promise.resolve(next.value).then(function(v) { //如果还有await,则将下一个await的内容(可以是promise,或者一个值等)变为Promsie
step(function() { return gen.next(v); }); //在then中执行gen.next(v),将执行权连同数据v交回给async函数继续执行到下一个await。
}, function(e) {
step(function() { return gen.throw(e); });
});
}
step(function() { return gen.next(undefined); }); //调用step,传入一个函数,函数返回gen.next()
});
}

综上所述,也就是说async/await其实就是将生成器和执行器封装在了一起,把本来要执行的函数封装进了生成器中,并用内置的执行器去执行它。

总结

Generator可以在等待异步方法(回调函数,Promise)返回的时候将线程执行权暂时交出去,而执行器可以帮忙执行生成器的同时去等待异步方法返回后调用生成器的next方法,将异步方法的返回数据连同执行权交还给生成器。

总的来说,我们可以通过回调,Promise等方法进行异步编程,Promise解决了回调噩梦,但是还是和同步编程语法有所区别,所以就利用了生成器Generator,在yiled Promise的结果的时候先将线程的执行权交出去,等到Promise resolve时,通过next方法把执行权连同Promise的结果一起交给生成器继续执行直到下一个yield或结束。

此时看起来已经像是同步编程了,yield在等待异步的结果返回后继续执行,但是上述的这个过程中需要我们编写一个执行器去执行生成器,在yield的时候将执行权交出去,在yield的Promise resolve后调用Generator的next把执行权交回来,这个执行器一开始比较出名的是CO,它能够帮助我们执行Generator,但是yield的只能是Thunk或Promise。

于是后来就出现了async/await,用async定义的函数,会被放入一个生成器中,然后返回一个Promise。它不仅让语法更好理解,而且内置了一个支持范围更广的执行器(Promise)去执行这个生成器,这个Promise就是一个执行器,然后利用执行器内部函数的不断递归回调去调用生成器的next方法,去逐个执行所有的异步函数,直到所有的await执行完成后调用Promise的resolve,将结果返回。

参考链接:

http://www.ruanyifeng.com/blog/2015/05/co.html