今天就讨论一个node里面很简单却又是很值得关注的话题--异步回调,相信很多小伙伴都熟知著名的回调地狱,社区这几年也是人才辈出,产出无数种优雅解决地狱的方案,最后ECMA一声号令,大家不用慌,爸爸出来镇场!这伙人效率颇高,一下子整出了个ES7标准,其中比较重要的特性就是async/await
了,大家对这个比较熟悉,我就不多说什么,私以为,这是最潇洒的解决的地狱的方式了,用起来也是无比自然,以同步的思维来书写异步代码,然而,当时仔细一想,还是有个疑问,这玩意其实只是语法糖,本质还是ES6的Generator
,然而生成器只是一个可迭代对象,退一步讲,跟一个数组类似,只是元素是函数的内部的一些片段,达到一种协程的效果,所以背后是谁在掌控着这一切?
比如我写一个异步函数
async () => {
let res = await method_return_promise();
}
后面函数返回的promise
会被解析,然后结果传入res
中,这一切是怎么发生的呢,首先我们需要脱去它美丽的衣服~~
function* () {
let res = yield method_return_promise();
}
ok,我们把它还原成一个生成器,然后考虑它将怎样被执行,yield
运算符的优先级是非常低的,在右边的表达式中,该运算符右边的表达式先被执行,在这里这个函数被调用,返回一个promise
对象,然后函数中断,保留堆栈,执行权回到我们的调度器,何为调度器,其实就是我们控制生成器的代码段。
一般来说生成器的执行只是简单的迭代
g.next();
g.next();
g.next();
我们需求其实就是等待promise解析,然后将其结果注入左边的结果变量,根据生成器也是传递结果的特性,可以这么干,当然它最后还是返回一个promise
,它将解析函数返回的对象,如果返回的不是promise
,那么就返回一个解析状态的promise
let ret = g.next();
//Generator yields, so it comes back to dispatcher
if (!ret.done) {
ret.value.then((res) => {
//now resume Generator, inject our result
let ret = g.next(res);
//then go on
next(ret);
});
}
这样,上面那个await表达式的神奇魔法就这样实现啦~
然而,我们要想做好执行器,还需要注意一个隐患--异常处理,上面的代码并没有任何的异常捕获,真实的环境IO,网络等环境很容易出现异常,我们岂能让这些异常来无影去无踪呢,所以我们需要加上try catch来完成最终的版本,
function runner(fn) {
return new Promise((resolve, reject) => {
let gen = fn();
next(gen.next());
function next(ret) {
//it returns a promise in the end
if (ret.done) return resolve(ret.value);
return ret.value.then((res) => {
let ret;
try {
//inject result to left value
ret = gen.next(res);
} catch (e) {
return reject(e);
}
next(ret);
}, (err) => {
try {
ret = gen.throw(err);
} catch (e) {
return reject(e);
}
next(ret);
});
}
});
}
然后我们写的生成器就可以正常运行啦,
runner(gen).then((res) => {
console.log(res);
}, (err) => {
console.error(err);
});
再套上语法糖,就是神奇的async/await
,不过问题还稍微复杂一点,上面讨论的基本是返回一个promise对象,然而await运算符还给我们自带了一些promise转换功能,比如我们可以这样
await 6;
await {val: 6};
所以我们需要一个转换过程,进行promisify
,首先定义一些规则,数字,字符串以及undefined,null等类型,我们直接返回它本身,省的夜长梦多,至于对象,我们需要遍历它的属性,每个属性再进行递归的转换,
for (let i; i < keys.length; i++) {
toPromise.call(this, obj[key]);
}
如果这个对象为普通对象,即任意一个属性都不是promise
类型,好咯,原样返回,然而,只要有一个属性为promise
类型,都需要等待它解析才能得到这个对象(在then
方法参数得到),根据这个思路,很容易得到,
function objectToPromise(obj){
let results = new obj.constructor();
let keys = Object.keys(obj);
let promises = [];
for (let i = 0; i < keys.length; i++) {
let key = keys[i];
let promise = toPromise.call(this, obj[key]);
if (promise && isPromise(promise)) defer(promise, key);
else results[key] = obj[key];
}
//if all props of the object are not promise, return it originally
//otherwise, we must wait until all promises are resolved
return Promise.all(promises).then(() => {
return results;
});
function defer(promise, key) {
// predefine the key in the result
results[key] = undefined;
promises.push(promise.then(res => {
results[key] = res;
}));
}
}
OK,对象解决了,还有一个很关键的问题,看看我们常用的node操作IO的API,似乎没几个返回promise对象的哦,这是个令人沮丧的事实,这些作者并不关心回调地狱,我们熟知的操作IO的API大概长这样,
read(path, (err, res) => {
//do something
})
这怎么破,首先我们会想,不是可以把它装在一个promise里面吗?当然可以,
wrappedRead = () => {
return new Promise((resolve, reject) => {
read(path, (err, res) => {
if (err) reject(err);
else resolve(res);
});
});
};
好像很有道理的样子,但这样意味着我们每一个API都要进行这样的封装(我在这里就不寄希望什么bluebird之流了),这种麻烦的事不符合我的风格,所以需要优化,我需要设计一个通用的接口,输入一个任意接口函数,即可变形为一个promise对象!
首先,我们想为什么接口不能通用化,即设计接口先考虑什么是不确定的因素,在这里,很明显,参数!API的共同特征是带一个回调,以及前面的若干个参数,所以这里我可以利用函数curry
(不明白的可以先学习下js函数式编程)对函数进行化简,我们的目标是一个thunk
,这也是一个函数式概念,一个含有单参数,并且参数是函数表达式的函数就是一个thunk
,对于它,我们所有的接口就只剩下共性了,即仅有一个回调参数,这下设计接口将非常简单,
function thunkToPromise(fn) {
let ctx = this;
return new Promise((resolve, reject) => {
fn.call(ctx, function (err, res) {
if (err) return reject(err);
resolve(res);
});
});
}
很好,现在最后的问题就是怎样化简了,运用curry
,过程也很简单,
let toThunk = (fn) => {
return function () {
let args = Array.prototype.slice.call(arguments);
return (cb) => {
args.push(cb);
fn.apply(this, args);
};
};
};
巧妙运用闭包,就能这样将多参函数层层化简为thunk
,就这么几行代码,就设计了一个通用的接口,
yield thunkToPromise(toThunk(anyAPI));
这样await表达式将支持任意的接口,当然这只是我对它背后的设想,目前它甚至连thunk都没发执行,这个有待日后更完善吧哈哈~
ok,想必到这里大家对这个神奇特性的背后,甚至一般的基于生成器异步执行核心算法都比较清楚了,日后我们在异步编程中应付这些类型的bug应该可以很快秒杀,羡煞旁人~~OK,本次的分享就到这,更多关于JS函数式的内容敬请期待~