随着 es2017
即将到来,async/await
的时代也就不远了。在之前的文章中 我建议要充分掌握 Promise ,因为它是建立 async/await
的基础。理解 Promise 有助于理解 async/await 的基础概念,并有助于你编写更好的 async 函数。
但是,即使你已紧跟 async 潮流(这是我个人喜欢的)并且完全理解 Promise,在异步函数中继续使用它们仍然还有一些非常令人信服的理由。async/await 绝对不会让你完全摆脱每一种情况。为什么?很简单:
你可能仍然需要去编写一些运行在浏览器上的代码。
单纯用 async/await 来编写并行代码有时候是不可能或不容易的。
为浏览器编写代码?Babel 不就可以解决吗?
很明显除非你是纯粹为 node 服务端编写,否则你将不得不考虑在浏览器中运行你的 javascript。通过 Babel 编译,可以使 ES2015+ 编写的代码运行在较老的浏览器中,或者还可以使用 Facebook 的一款优秀编译器 Regenerator,Babel 甚至会能将 async/await 编译为向下兼容的代码。
问题得到解决,然后呢?好吧,这并不完全是。
关键点在于,生成的代码并不一定是你想在客户端上运行的。例如下面一个简单的 async 函数,它使用异步映射函数对数组进行连续映射:
async function serialAsyncMap(collection, fn) {
let result = [];
for (let item of collection) {
result.push(await fn(item));
}
return result;
}
这是由 Babel/Regenerator 编译后的 56 行代码:
var serialAsyncMap = function () {
var _ref = _asyncToGenerator(regeneratorRuntime.mark(function _callee(collection, fn) {
var result, _iterator, _isArray, _i, _ref2, item;
return regeneratorRuntime.wrap(function _callee$(_context) {
while (1) {
switch (_context.prev = _context.next) {
case 0:
result = [];
_iterator = collection, _isArray = Array.isArray(_iterator), _i = 0, _iterator = _isArray ? _iterator : _iterator[Symbol.iterator]();
这仅仅是编译结果中的 7 行代码。顺便说下,在 bundle 前甚至引入了 regeneratorRuntime
或 _asyncToGenerator
。虽然 Regenerator 是一个很好的技术功能,但是它并不能编译出最精简的代码来运行在浏览器中,我猜测它并不是最优或性能最佳的代码。它也很难阅读、理解和调试。
假设如果我们使用原生 Promise 来编写相同的函数:
function serialAsyncMap(collection, fn) {
let results = [];
let promise = Promise.resolve();
for (let item of collection) {
promise = promise.then(() => {
return fn(item).then(result => {
results.push(result);
});
});
}
return promise.then(() => {
return results;
});
}
或者更简洁的版本:
function serialAsyncMap(collection, fn) {
let results = [];
let promise = Promise.resolve();
for (let item of collection) {
promise = promise.then(() => fn(item))
.then(result => results.push(result));
}
return promise.then(() => results);
}
原生 Promise 的确比 Regenerator 编译后的 async/await 代码更加精简、易读及便于调试。调试与环境中 source-map 的支持力度密切相关(通常是必不可少的调试环境,如低版本的IE浏览器)。
有其它选择吗?
确实有几种来替代 Regenerator 编译 async/await 的方式,它提取 async 代码并尝试转换成更传统的 .then
和 .catch
标记。以我的经验来说,对于简单的函数这些转换运作较良好,在 await
后 return
,最多再加上 try/catch
块。但更复杂的 async 函数(加上一些条件 await
语句或循环)编译后的代码就像是一坨意大利面。
至少对我来说,这还不够好;如果我不能简单的看着编译后的代码想象出编译的结果看起来会是什么样,那我能够轻松调试代码的机会就也变得很小。
浏览器完全支持 async/await 是需要很长时间的,所以请不要屏息等待并在客户端编写你熟悉的 Promise 代码。
好吧好吧,所以在客户端我仍然需要写 Promise,但只要我运行在 node 服务端就可以使用 async/await 了,对吗?
没错,但也不一定。
通常你可以在 JS 服务器端用一些 async 函数和 await 语法,比如做一些 http 请求,都没什么问题。你甚至可以使用 Promise.all
来并行异步任务(尽管我认为这样有点不适当,用 async/await 运行并行更好)。
但当你想写一些比 “串联运行一些异步任务” 或 “并行运行一些异步任务” 更复杂的事情时会发生什么呢?
举一个例子
我们想做一个披萨,考虑以下几点。
我们单独做生面团。
我们单独做调味酱。
我们想先品尝下调味酱再决定用哪种奶酪搭配比萨饼。
所以,让我们从一个超简单的纯 async/await 解决方案开始:
async function makePizza(sauceType = 'red') {
let dough = await makeDough();
let sauce = await makeSauce(sauceType);
let cheese = await grateCheese(sauce.determineCheese());
dough.add(sauce);
dough.add(cheese);
return dough;
}
这有一个很大的优势:它十分简单、很容易阅读和理解。首先我们做生面团,然后我们做调味酱,然后我们磨奶酪。简单!
但是这并不完全是最佳的。我们得一步一步地做事情,实际我们应该让 JS 引擎同时运行这些任务。因此而不是:
|-------- dough --------> |-------- sauce --------> |-- cheese -->
我们想要的东西更像:
|-------- dough -------->
|-------- sauce --------> |-- cheese -->
用这种方式,这个任务完成得更快了。让我们再试一下:
async function makePizza(sauceType = 'red') {
let [ dough, sauce ] =
await Promise.all([ makeDough(), makeSauce(sauceType) ]);
let cheese = await grateCheese(sauce.determineCheese());
dough.add(sauce);
dough.add(cheese);
return dough;
}
好的,使用 Promise.all
后我们的代码看起来更酷些,至少现在是最佳的,对吧?嗯...并不是。我甚至在等待生面团和调味酱做好后才能开始磨奶酪。如果我很快做好调味酱怎么办?现在我的执行看起来像这样:
|-------- dough -------->
|--- sauce ---> |-- cheese -->
注意,在我想要磨奶酪前,还需要等待生面团和调味酱什么时候做好?在磨奶酪前我只需把调味酱做好,所以我在这里浪费时间。然后让我们回到绘图板,尝试使用 Promise.all,而不是 async/await:
function makePizza(sauceType = 'red') {
let doughPromise = makeDough();
let saucePromise = makeSauce(sauceType);
let cheesePromise = saucePromise.then(sauce => {
return grateCheese(sauce.determineCheese());
});
return Promise.all([ doughPromise, saucePromise, cheesePromise ])
.then(([ dough, sauce, cheese ]) => {
dough.add(sauce);
dough.add(cheese);
return dough;
});
}
这样操作起来好多了。一旦所有的依赖关系实现,现在每个任务将会尽快地完成。所以唯一可以阻止我磨奶酪的事情就是等待调味酱。
|--------- dough --------->
|---- sauce ----> |-- cheese -->
但是为了这样做,我们不得不全部退出编写 async/await 代码并且全部用 Promise。我们尝试着回到 async/await 上。
async function makePizza(sauceType = 'red') {
let doughPromise = makeDough();
let saucePromise = makeSauce(sauceType);
let sauce = await saucePromise;
let cheese = await grateCheese(sauce.determineCheese());
let dough = await doughPromise;
dough.add(sauce);
dough.add(cheese);
return dough;
}
好吧,所以现在我们是最佳的,并回到 async/await 块... 但这仍然感觉像一个倒退。我们必须预先设置每个 Promise,所以要做好心理准备。我们同时还依赖这些 Promise 来运行,在任意 await 前将指定任务设置好。这在阅读代码时体现并不是很明显,并且将来可能会被意外的分解或破坏。所以这可能是我最不喜欢的实现。
让我们再试一次。我们可以再试一次:
async function makePizza(sauceType = 'red') {
let prepareDough = memoize(async () => makeDough());
let prepareSauce = memoize(async () => makeSauce(sauceType));
let prepareCheese = memoize(async () => {
return grateCheese((await prepareSauce()).determineCheese());
});
let [ dough, sauce, cheese ] =
await Promise.all([
prepareDough(), prepareSauce(), prepareCheese()
]);
dough.add(sauce);
dough.add(cheese);
return dough;
}
这是我最喜欢的解决方案。不用预先设置 Promise,它们隐式地并行运行,我们可以设置三个 memoized 任务(确保每次只运行一次),并在 Promise.all
中调用它们以并行运行。
这里除 Promise.all
外,我们几乎没有涉及其他的 Promise,尽管它们在 async/await 的底层运转。这个模式我在另一篇关于缓存和并行的文章中更详细地介绍过。但在我看来,这导致了最佳并行和可读性/可维护性的完美结合。
当然,我总是愿意被大家证明是错的,所以如果你有一个更喜欢的 makePizza
实现,请让我知道!
所以我们很快地做了一个披萨,点在哪里呢?
点在于,如果你在计划写完全并行的代码,即便是用最新 node.js 版本,知道怎么将 Promise 和 async/await 混合在一起仍然是一个非常必要的技能。无论你最喜欢的 makePizza
实现是怎样的,你仍然需要考虑如何将 Promise 链接组合在一起,使函数运行时尽可能减少不必要的延迟。
async/await 就到这里,如果你不了解 Promise 在你的代码中如何运行, 你将会卡在这里并找不到明显的方式来优化你的并行任务。
到了这里……
不要害怕,让辅助函数从你的业务逻辑中抽象出来 Promise/并行逻辑。一旦你了解 Promise 的工作原理,这样可以使你的代码摆脱意大利面一样的杂乱,并且使你的异步程序/业务逻辑函数更清晰的体现出想要做什么,而不是总是挤在样板里。
执行此功能,如果用户登录,它将每十秒钟检查一次,并在 Promise 中检测到以下情况时进行 resolves:
function onUserLoggedIn(id) {
return ajax(`user/${id}`).then(user => {
if (user.state === 'logged_in') {
return;
}
return new Promise(resolve => {
return setTimeout(resolve, 10 * 1000));
}).then(() => {
return onUserLoggedIn(id);
})
});
}
这并不是我想要执行的函数 - 业务逻辑和 promise/delay 逻辑非常紧密地耦合在一起。在我想对函数做些调整前不得不去阅读和理解这整段内容。
为了改进这一点,我可以将 async/promise 逻辑拆成一些独立的辅助函数,并使我的业务逻辑更简洁:
function delay(time) {
return new Promise(resolve => {
return setTimeout(resolve, time));
});
}
function until(conditionFn, delayTime = 1000) {
return Promise.resolve().then(() => {
return conditionFn();
}).then(result => {
if (!result) {
return delay(delayTime).then(() => {
return until(conditionFn, delayTime);
});
}
});
}
或者这些辅助函数的超简洁版本:
let delay = time =>
new Promise(resolve =>
setTimeout(resolve, time)
);
let until = (cond, time) =>
cond().then(result =>
result || delay(time).then(() =>
until(cond, delay)
)
);
然后 onUserLoggedIn
变的与流程控制逻辑不那么紧密地耦合在一起。
function onUserLoggedIn(id) {
return until(() => {
return ajax(`user/${id}`).then(user => {
return user.state === 'logged_in';
});
}, 10 * 1000);
}
现在我更希望能够在将来轻松的阅读和理解 onUserLoggedIn
。只要我记得接口的 until
函数,就不用每次重新梳理它的逻辑。我可以把它扔到一个 promise-utils
文件中并忽略它是如何执行的,最重要的是可以把注意力集中在自己的应用逻辑。
是的,我们讨论的是 async/await,对吧?嗯,今天是我们的幸运日,由于 async/await 和 Promise 是完全能共同使用的,我们刚刚无意中创造了一个可以继续使用的辅助函数,甚至具有 async 功能:
async function onUserLoggedIn(id) {
return await until(async () => {
let user = await ajax(`user/${id}`);
return user.state === 'logged_in';
}, 10 * 1000);
}
所以无论代码是基于 Promise 还是基于 async/await,规则都是一样的。如果你发现并行逻辑陷入你的 async 业务函数中,一定要考虑是否可以抽出一点。当然要在合理范围内。
所以,如果你想从这篇文章中有所收货,就是这些:如果你正在编写 async/await 代码,你不仅应该理解 Promise 是如何工作的,而且在必要时你还应该使用它们来构建你的 async/await 代码。单独的 async/await 不会给你足够的功能来完全避免 Promise 思维。
Thanks!
— Daniel
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。