本文旨在通过实例演示JS异步编程几种常见的解决方案,让你写出最优雅的异步代码。
异步是Javascript区别于其他编程语言的一个优秀的特性。由于JS是单线程执行的,如果没有引入异步,站点页面的性能将会是一个难以解决的、致命的问题。而随之node的兴起,异步更是被推崇到极致,跃身成为网络编程的强手。
一段完整的任务A执行到一半,暂停,去执行另外一段任务B,完了再回到断点处继续执行A,这是异步编程最直观的理解。这里的断点,最普遍的应用场景就是发出一个耗时不可知的I/O任务,http请求或文件读取。本文就这样一个逻辑场景的假定,来聊聊JS异步编程的解决方案(为简化思路,场景设计得略为蹩脚):
- JS读取文件a.txt,获取到数据dataA=1;
- 再读取文件b.txt,获取到数据dataB=2;
- 再读取文件c.txt,获取到数据dataC=3;
- 求和 sum = 6。
这类场景所呈现出来的顺序依赖的问题,大多数情况下是可以通过修改设计逻辑来解决的,但有些时候并不适合。我曾在项目中遇到过向服务端按序同步做出多个http请求的需求,并且此时重新制定API的成本已经相当大了,所以,对于这种常见菜式,如何高效实现异步是JS开发的关键。下边讨论的事件回调、发布/订阅模式、Promise、Generator,以及最出神入化的 Async/Await 新语法,都是异步编程可选的解决渠道。
事件回调
事件(event)与回调(callback)在JS中随处可见,回调是常用的解决异步的方法,易于理解,在许多库与函数中也容易实现。如果使用 node 原生支持的 fs.readFile()
来编写那会是这样的:
const fs = require('fs');
fs.readFile('a.txt', {encoding: 'utf8'}, function(err, dataA){
if(err) throw Error('fail');
console.log('dataA is %d', dataA);
fs.readFile('b.txt', {encoding: 'utf8'}, function(err, dataB){
if(err) throw Error('fail');
console.log('dataB is %d', dataB);
fs.readFile('c.txt', {encoding: 'utf8'}, function(err, dataC){
if(err) throw Error('fail');
console.log('dataC is %d', dataC);
console.log('sum is %d', parseInt(dataA) + parseInt(dataB) + parseInt(dataC));
})
})
});
// $node index.js
// dataA is 1
// dataB is 2
// dataC is 3
// sum is 6
readFile()
会在文件 I/O 返回结果之后触发回调函数,通过这种嵌套的方式,我们能够保证文件是按序读取的,这种方式在JS中十分常见,比如定时器setInterval()
、setTimeout()
。回调实现的异步代码易于理解,但问题也很明显,层层嵌套使得代码逐层缩进,严重降低了可读性,我们把这种现象称为回调金字塔。
发布/订阅模式
事件回调是通过触发实现的,而发布/订阅(pub/sub)模式实现的原理与事件回调基本一致。事实上,发布/订阅模式更像是一般化的事件回调,是对事件回调的拆解和拓展。和事件回调机制一样,发布/订阅模式需要提供回调函数(订阅),不同的是事件回调机制是自行触发,而发布/订阅模式把触发权限交给了开发者,因此你可以选择在任意时刻触发回调(发布)。
在事件机制上,node 内置了一个功能强大的events
模块,对发布/订阅模式提供了完美的支持,我们可以用它来实现这个应用场景:
const events = require('events');
const fs = require('fs');
const eventEmitter = new events.EventEmitter();
const files = [
{ fileName: 'a.txt', dataName: 'dataA' },
{ fileName: 'b.txt', dataName: 'dataB' },
{ fileName: 'c.txt', dataName: 'dataC' },
];
let index = 0;
// 订阅自定义事件
eventEmitter.on('next', function(data){
if(index>2) return console.log('sum is %d', parseInt(data));
fs.readFile(files[index].fileName, {encoding: 'utf8'}, function(err, newData){
if(err) throw Error('fail');
console.log(files[index].dataName+' is %d', newData);
index++;
// 驱动执行
eventEmitter.emit('next', parseInt(data)+parseInt(newData));
})
});
// 触发自定义事件执行
eventEmitter.emit('next', 0);
// $ node index.js
// dataA is 1
// dataB is 2
// dataC is 3
// sum is 6
上面代码挂载了一个自定义事件next
,通过一个守卫变量index
,使得next
事件总能在前一步文件读取完成后触发,并且在按序完成三次文件读取后输出和。
事件机制在JS编程中十分常见,原生的 XHR 对象就是通过事件实现 AJAX 请求的。但从上面的代码我们可以看出这种模式解决深度嵌套问题依然显得吃力,events
对象的引入、事件的订阅和发布,让代码掺杂了许多逻辑以外的东西,十分晦涩难懂。人类语言的语法逻辑是同步的,我们希望避开丑陋的异步代码,用同步的编写方式去实现异步逻辑。
Promise
在 ES6 出现之前,人们饱受“回调地狱”的煎熬,现在,我们大可以使用 ES6 提供的 Promise 对象实现异步,彻底地告别“回调地狱”。Promise 是对社区早有的 Promise/Deferred 模式的实现,该模式最早出现在 jQuery1.5 版本,使得改写后的 Ajax 支持链式表达。Promise 译为“承诺”,个人理解为 Promise 对象承诺在异步操作完成后调用 then 方法指定的回调函数,因此,Promise的本质依然是事件回调,是基于事件机制实现的。
const fs = require('fs');
new Promise(function(resolve, reject){
fs.readFile('a.txt', {encoding: 'utf8'}, function(err, newData){
if(err) reject('fail');
console.log('dataA is %d', newData);
resolve(newData);
});
}).then(function(data){
// 返回一个新的 promise 对象,使得下一个回调函数会等待该异步操作完成
return new Promise(function(resolve, reject){
fs.readFile('b.txt', {encoding: 'utf8'}, function(err, newData){
if(err) reject('fail');
console.log('dataB is %d', newData);
resolve(parseInt(data)+parseInt(newData));
});
});
}).then(function(data){
return new Promise(function(resolve, reject){
fs.readFile('c.txt', {encoding: 'utf8'}, function(err, newData){
if(err) reject('fail');
console.log('dataC is %d', newData);
resolve(parseInt(data)+parseInt(newData));
});
});
}).then(function(data){
console.log('sum is %d', parseInt(data));
}).catch(function(err){
throw Error('fail');
});
// $ node index.js
// dataA is 1
// dataB is 2
// dataC is 3
// sum is 6
上面的代码中,异步操作被按序编写在每个 Promise 对象的then()
方法中,then
表示“然后”,可见按照这种方式编写的代码逻辑和业务逻辑是一致的,保证了代码的可读性,但引入了 Promise 对象后,代码中出现了一堆的 then 和 catch 方法,这些代码是业务逻辑之外的代码,是影响阅读、不应该出现的。我们不止希望代码逻辑和业务逻辑是一致的,我们还想要最简洁、最清晰的表达方式。
Generator
Generator 函数是 ES6 标准引入的新特性,旨在提供一类完全不同于以往异步编写方式的解决方案。依照阮一峰老师在《Generator 函数的语法》一文中的说法,Generator 函数是一个状态机,封装了多个内部状态。Generator 函数提供了一种机制,通过yield
关键字和next()
方法来交付和归还线程执行权,实现代码异步。我们前边说过,异步直观理解是中断程序A去执行B之后再回到断点处继续执行A,本质上这就是一种执行权的借还。
Generator 函数不同于普通函数,Generator 函数执行到yield
关键字处会自动暂停,保护现场,返回一个遍历器(Iterator)对象,完成执行权的交付。之后,我们通过调用该遍历器对象的next()
方法,回归现场,驱动 Generator 函数从断点处继续执行,完成执行权归还。
归还执行权一般借助于 Promise 对象来实现。
const fs = require('fs');
const getData = function(fileName){
return new Promise(function(resolve, reject){
fs.readFile(fileName, {encoding: 'utf8'}, function(err, data){
if(err) throw Error('fail');
resolve(data);
})
});
}
const g = function* (){
try{
let dataA = yield getData('a.txt'); // yield 在暂停时刻并没有赋值,dataA 的值是在重新执行时刻由 next 方法的参数传入的
console.log('dataA is %d', dataA);
let dataB = yield getData('b.txt');
console.log('dataB is %d', dataB);
let dataC = yield getData('c.txt');
console.log('dataC is %d', dataC);
console.log('sum is %d', parseInt(dataA) + parseInt(dataB) + parseInt(dataC));
}catch(err){
console.log(err);
}
};
// 驱动 Generator 执行
function run (generator) {
let it = generator();
function go(result) {
// 判断是否遍历完成,标志位 result.done 为 true 表示遍历完成
if (result.done) return result.value;
// result.value 即为返回的 promise 对象
return result.value.then(function (value) {
return go(it.next(value));
}, function (error) {
return go(it.throw(error));
});
}
go(it.next());
}
run(g);
// $ node index.js
// dataA is 1
// dataB is 2
// dataC is 3
// sum is 6
上面代码中,getData()
函数返回了一个负责读取文件的 Promise 对象, Promise 对象会在读取到数据后驱动 Generator 函数继续执行,run()
函数是 Generator 函数的自动执行器。如果你把目光放到 Generator 函数上,你会惊讶地发现,这异步代码简直跟同步编写的一模一样!可以说Generator 函数的出现,为JS异步编程带来了另一种风景,没有累赘的代码,没有回调金字塔,代码逻辑与设计思路映射完全一致。
基于此设计的 thunkify + co 组合,可以说极大地简化了 Generator 的实现方式。thunkify 模块是一个偏函数,用于将参数包含【执行参数】和【回调函数】的函数(比如fs.readFile(path[, options], callback)
)转化为一个二级函数(形如readFile(path[, options])(callback)
),而 co 模块则完成对 Generator 函数的驱动,也就是上面代码中 getData()
和run()
实现的功能。
const co = require('co');
const thunkify = require('thunkify');
const fs = require('fs');
const readFile = thunkify(fs.readFile);
co(function* (){
let dataA = yield readFile('a.txt', {encoding: 'utf8'});
console.log('dataA is %d', dataA);
let dataB = yield readFile('b.txt', {encoding: 'utf8'});
console.log('dataB is %d', dataB);
let dataC = yield readFile('c.txt', {encoding: 'utf8'});
console.log('dataC is %d', dataC);
console.log('sum is %d', parseInt(dataA) + parseInt(dataB) + parseInt(dataC));
});
console.log('异步执行');
// $ node index.js
// 异步执行
// dataA is 1
// dataB is 2
// dataC is 3
// sum is 6
对照上面两种实现方法,可以发现 co 帮我们处理掉了许多业务逻辑之外的累赘代码。另外结果表明,程序依然还是完美地异步执行的,但我们已经基本看不出代码的异步特性了。
Async/Await
在追求巅峰造极的路上,JS永远是先锋。ES7标准引入的 Async/Await 语法,可以说是JS异步编程的最佳实现。Async/Await 语法本质上只是 Generator 函数的语法糖,像 co 一样,它内置了 Generator 函数的自动执行器,并且支持更简洁更清晰的异步写法。
const fs = require('fs');
// 封装成 await 语句期望的 promise 对象
const readFile = function(){
let args = arguments;
return new Promise(function(resolve, reject){
fs.readFile(...args, function(err, data){
// await 会吸收 resolve 传入的值作为返回值赋给变量
resolve(data);
})
})
};
const asyncReadFile = async function(){
let dataA = await readFile('a.txt', {encoding: 'utf8'});
console.log('dataA is %d', dataA);
let dataB = await readFile('b.txt', {encoding: 'utf8'});
console.log('dataB is %d', dataB);
let dataC = await readFile('c.txt', {encoding: 'utf8'});
console.log('dataC is %d', dataC);
console.log('sum is %d', parseInt(dataA) + parseInt(dataB) + parseInt(dataC));
};
asyncReadFile();
console.log('异步执行');
// $ node index.js
// 异步执行
// dataA is 1
// dataB is 2
// dataC is 3
// sum is 6
到这里,可能有些人已经不太敢相信这其实是一段异步代码了。Async/Await 同样需要借助 Promise 对象实现,但已经尽最大努力弱化了逻辑之外的辅助代码了。当异步逻辑更加复杂时,Async/Await 语法编写的异步代码冗余成分比例将大大减小,我们所能注意到的,就只剩下优雅的逻辑代码了。
以上多为个人心得,错漏之处请指出。
参考自阮一峰ECMAScript 6 入门