本文梳理了JavaScript异步编程的发展脉络,从早期的回调函数(Callbacks)及其引发的回调地狱问题,到Promise/A+标准的引入,再到生成器函数与co库的尝试,最终演进为ES7中标准的Async/Await语法。文章详细解析了各阶段的技术特点、优缺点及最佳实践,帮助开发者理解异步机制的演变逻辑。
JavaScript异步编程演进概述
《The Evolution of Asynchronous JavaScript》一文系统梳理了JavaScript异步函数的演进历史,涵盖了从最初的回调函数,到Promise/A+规范、生成器函数,直至当前主流的Async函数的发展路径。感谢景庄对该外文资料的翻译,以下内容基于该译文展开。
接下来,我们将共同回顾JavaScript异步函数在过去几年中的发展历程。
回调函数Callbacks的局限
回调函数Callbacks
讨论异步处理,必然要从回调函数说起。
异步JavaScript
在JavaScript中,异步编程主要依赖于函数作为一等公民的特性来实现。这意味着我们可以将函数作为参数传递给另一个函数(即高阶函数),并在其内部调用该传入的函数,这种机制即为回调函数。回调函数的主要作用是在函数内部执行特定动作,它通常不返回任何值(避免使用return)。例如:
Something.save(function(err) {
if (err) {
//error handling
return; // 没有返回值
}
console.log('success');
});
上述示例展示了错误优先的回调函数(error-first callbacks)模式,这是Node.js的核心特征之一。Node.js的核心模块及NPM仓库中的大多数模块在开发时均遵循这一规范。
然而,过度依赖回调函数会带来诸多挑战:
若代码组织不当,极易陷入“回调地狱”(callback hell),导致代码可读性极差,难以维护。同时,错误处理代码容易被遗漏。
此外,回调函数无法通过return语句返回值,也无法使用throw关键字抛出异常。
正是由于上述局限性,JavaScript社区一直在探索使异步开发更简便的可行方案。
一种早期的解决方案是async模块。对于长期与回调打交道的开发者而言,若需实现并行执行、串行执行,或使用异步函数映射数组元素,操作往往十分复杂。Caolan McMahon编写的async模块正是为了解决这些问题。
借助async模块,代码编写方式可简化为:
async.map([1, 2, 3], AsyncSquaringLibrary.square,
function(err, result){
// result will be [1, 4, 9]
});
尽管async模块提供了一定便利,但代码可读性依然有限,因此Promise应运而生。
Promise标准的引入
Promise
当前的JavaScript异步标准可追溯至2012年,并在ES6中成为可用特性。然而,“Promise”这一术语并非JavaScript社区首创,它源自Daniel P. Friedman于1976年发表的一篇论文。
Promise代表一个异步操作的最终结果。
若使用Promise实现前述功能,代码风格如下:
Something.save()
.then(function() {
console.log('success');
})
.catch(function() {
//error handling
})
可以看出,Promise内部依然利用了回调函数。在then和catch方法中传入的回调函数,分别在Promise状态变为resolved或rejected时执行。Promise的优势在于支持链式调用以完成一系列任务,例如:
saveSomething()
.then(updateOtherthing)
.then(deleteStuff)
.then(logResults);
当原生Promise功能不足时,可借助如bluebird等Promise库。这些库通常提供比原生方案更丰富的功能,且不受限于Promise/A+标准。
为何不直接使用糖语法(sugar methods)?建议先阅读《Promise: The Extension Problem》一文。更多关于Promise的详细信息,可参考Promise/A+标准。
若大部分库仅暴露回调接口,该如何使用Promise?
解决方法很简单,只需使用Promise包裹包含回调的函数调用即可。例如:
原始的回调风格代码可能如下:
function saveToTheDb(value) {
db.values.insert(value, function (err, user) {
if (err) throw err;
// todo: insert user to db
});
}
将其改造为支持Promise风格的调用后:
function saveToTheDb(value) {
return new Promise(function(resolve, reject) {
db.values.insert(value, function(err, user) { // remember error first ;)
if (err) {
return reject(err); // don't forget to return here
}
resolve(user);
})
}
}
目前已有相当多的库和框架同时支持回调风格和Promise风格的API。若需对外提供库,最佳实践是同时提供两种接口。可通过以下方式实现:
function foo(cb) {
if (cb) {
return cb();
}
return new Promise(function (resolve, reject) {
});
}
或者更简单地,从仅提供Promise风格接口开始,并使用如callbackify等工具实现向后兼容。Callbackify的实现原理与上述代码类似,但采用了更通用的方法,建议阅读其源代码。
生成器函数与co库
生成器Generators/ yield
JavaScript生成器是ES6(即ES2015)引入的新特性,相对较新。设想以下场景:
在函数执行过程中,可在某一点暂停执行,执行其他操作后,再携带新值返回该函数继续执行。这正是JavaScript生成器函数旨在解决的问题。调用生成器函数时,它不会立即执行,而是返回一个迭代器,需手动调用next方法进行迭代。迭代器会遍历每个中断点。
function* foo () {
var index = 0;
while (index < 2) {
yield index++; //暂停函数执行,并执行yield后的操作
}
}
var bar = foo(); // 返回的其实是一个迭代器
console.log(bar.next()); // { value: 0, done: false }
console.log(bar.next()); // { value: 1, done: false }
console.log(bar.next()); // { value: undefined, done: true }
若希望更轻松地使用生成器编写异步代码,可使用由tj开发的co库。
Co是一个基于生成器的流程控制工具,适用于Node.js和浏览器。借助Promise,可使用更优雅的方式编写非阻塞代码。使用co改写前述示例:
co(function* (){
yield Something.save();
}).then(function() {
// success
})
.catch(function(err) {
//error handling
});
如何实现并行操作?答案比想象中简单,如下(本质即Promise.all):
yield [Something.save(), Otherthing.save()];
Async/ await
Async/Await标准语法
ES7(尚未正式标准化)引入了Async函数的概念。目前若需使用,需借助babel等语法转换器将其编译为ES5代码(注意:此处讨论的是async关键字,而非NPM中的async包)。
简言之,使用async关键字,可轻松实现此前使用生成器和co函数完成的工作(当然,不包括那些hack手段)。
既然ES7引入了async关键字,yield是否变得不再重要?
实际上,使用yield处理异步只是一种hack。yield的本质是懒序列(lazy sequences)和迭代器。而await能完美分离这两点:让yield回归其最初目的,使用await执行异步操作。
Async函数底层基于Promise实现,这也是async函数返回Promise的原因。
因此,使用async函数完成前述工作,可重新编写为:
async function save(Something) {
try {
await Something.save(); // 等待await后面的代码执行完,类似于yield
} catch (ex) {
//error handling
}
console.log('success');
}
如所见,使用async函数需在函数声明前添加async关键字。此后,可在函数内部使用await关键字,其作用与之前的yield类似。
使用async函数完成并行任务的方式与yield类似,唯一区别是Promise.all不再隐式执行,需显式调用:
async function save(Something) {
await Promise.all[Something.save(), Otherthing.save()]
}
Koa也支持async函数。若使用Koa,现在即可借助babel启用这一特性。
import koa from koa;
let app = koa();
app.experimental = true;
app.use(async function (){
this.body = await Promise.resolve('Hello Reader!')
})
app.listen(3000);
以上内容分享了JavaScript异步函数的发展历程,希望对大家有所帮助。