背景
中午吃饭的时候跟同事讨论起了 async-await
,我认为 async-await
是 Promise 的语法糖,但同事认为更贴近 Generator。于是趁下午摸鱼的时间查了一下资料,并自己用简单的例子对比了一下转换前后的代码,大概有了一定的了解。
讲真,在刚看到转换结果的时候我整个人是懵逼的(虽然很早以前就看过了,毕竟每次 async-await
函数报错的时候调试器都会断在一个奇怪的文件里),因为这两段代码几乎没有太多的相似之处,转换之后还多了一段奇怪的函数(WTF?!):
// code before transformation
async function t(xxx) {
const x = await someFunction(xxx);
const y = x ? x + 1 : await fallback(x);
for (let i = 0; i < 5; i++) {
await anotherFunction(i);
}
return y;
}
// code after transformation
"use strict";
var t = function () {
var _ref = _asyncToGenerator(/*#__PURE__*/regeneratorRuntime.mark(function _callee(xxx) {
var x, y, i;
return regeneratorRuntime.wrap(function _callee$(_context) {
while (1) {
switch (_context.prev = _context.next) {
case 0:
_context.next = 2;
return someFunction(xxx);
case 2:
x = _context.sent;
if (!x) {
_context.next = 7;
break;
}
_context.t0 = x + 1;
_context.next = 10;
break;
case 7:
_context.next = 9;
return fallback(x);
case 9:
_context.t0 = _context.sent;
case 10:
y = _context.t0;
i = 0;
case 12:
if (!(i < 5)) {
_context.next = 18;
break;
}
_context.next = 15;
return anotherFunction(i);
case 15:
i++;
_context.next = 12;
break;
case 18:
return _context.abrupt("return", y);
case 19:
case "end":
return _context.stop();
}
}
}, _callee, this);
}));
return function t(_x) {
return _ref.apply(this, arguments);
};
}();
// WTF?!
function _asyncToGenerator(fn) { return function () { var gen = fn.apply(this, arguments); return new Promise(function (resolve, reject) { function step(key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { return Promise.resolve(value).then(function (value) { step("next", value); }, function (err) { step("throw", err); }); } } return step("next"); }); }; }
参考了几篇文章之后,思路逐渐清晰了起来。
其实 Babel 转换是利用了各种插件,本文讲述的是官方收录的,也是最常用的插件 transform-async-to-generator
(GitHub)。此外网上还有 transform-async-to-module-method
(NPM)和 transform-async-to-promises
(GitHub)等类似的插件,从名字就能看出来是干什么的,但使用率远远没有官宣的那么高。
从插件名字可以得知,大概的思路就是将 async-await
转换为 Generator,然后利用 Babel 本身将 Generator 转换为 ES5 的代码。
因个人能力有限,若大佬们发现文中有任何错误,请联系我,我会及时改正。
前期知识
- 了解 Promise 的基本用法
- 了解 ES6 Generator 的基本用法
- 能够在 Promise 与
async-await
之间互转
转换 async-await 为 Generator
Generator 的一个特点是:可以在函数运行时通过 yield
让出执行权,下次调用时会从上次的状态继续执行。这种“可中断”的特性与 async-await
函数很相似,转换起来也看似很容易:
- 将
async function t(xxx) {
转换为function* t(xxx) {
- 将
x = await y
转换为x = yield y
然后执行一下 t(xxx)
,发现在第一个 yield
的地方停止了(这不是废话嘛 23333)……所以需要一个机制使得它可以继续下去。
一个自动执行 Generator 的函数
可能有一部分同学听说过或使用过一个叫 co
的库,在 async-await
问世之前,人们喜欢用它来达到异步的效果,它的大概原理是:不断执行 Generator 直到其 done
的值为 true
,最终的返回值是一个 Promise,yield
出的值可以通过这个 Promise 带出去(v4 之后才使用的 Promise,之前都是用的 Thunk)。至于如何在不受支持的平台上实现 Promise,这儿就不说了,网上的文章太多了。
co
考虑的东西有很多,例如 yield
的值可以是(摘自 co
的 README):
- promises
- thunks (functions)
- array (parallel execution)
- objects (parallel execution)
- generators (delegation)
- generator functions (delegation)
但是我们可以比较轻松的实现“只支持 yield
一个 Promise”的简单版 co
:
function simpleCo(gen) {
const g = gen();
function next(data) {
const result = g.next(data);
if (result.done) {
return result.value;
}
return result.value.then(function (data) {
return next(data);
});
}
return next();
}
simpleCo(function* () {
const a = yield new Promise(resolve => {
setTimeout(() => resolve(2), 1000);
});
return a + 1;
}).then(res => console.log(res))
然后就发现,simpleCo
跟之前的代码转换得到的 _asyncToGenerator
极其相似,后者只是多做了很多细节判断而已(当然,还是不如 co
可以处理的事情多,毕竟 co
是专业的):
function _asyncToGenerator(fn) {
return function () {
var gen = fn.apply(this, arguments);
// wrap with a Promise ensures that we can always return
// a Promise even if yield value is not a Promise
return new Promise(function (resolve, reject) {
function step(key, arg) {
try {
var info = gen[key](arg);
var value = info.value;
} catch (error) {
// error handling
reject(error);
return;
} if (info.done) {
// all done, pass the return value out
resolve(value);
} else {
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/resolve
// if the value is a thenable (i.e. has a "then" method),
// the returned promise will "follow" that thenable,
// adopting its eventual state; otherwise the returned
// promise will be fulfilled with the value
return Promise.resolve(value).then(function (value) {
step("next", value);
}, function (err) {
step("throw", err);
});
}
}
return step("next");
});
};
}
对 step
的“递归”调用跟用 while(1)
调用本质上是一样的。之所以加引号,是因为这不是真正的递归,第二次 step
的调用不是在第一次 step
的栈帧内,而是在一个 Promise 结束之后。由于不是递归,因此不会出现栈溢出的问题。
原来这是个不断执行 Generator 的函数,而不是将 async-await
转换为 Generator 的函数!差点被函数名骗了。
但这个函数上方的函数中,那一坨 switch-case
是什么东西呢?可能有同学眼尖,发现这不就是个状态机么,每个 case
就是一个状态,0
相当于初始状态,5
或 "end"
相当于最终状态,对 _context.next
的赋值相当于状态转移。事实上,将 Generator 转换为 ES5 代码的方式就是使用状态机。
转换 Generator 为 ES5
ES5 函数只允许保存内部的值(使用闭包),但不允许保存执行状态,如果要达到保存状态的目的,在无法接触到底层(如果可以接触底层,就可以直接使用跟递归类似的保存现场的方法)的情况下,我认为最简单的方法就是状态机了,yield
就是“状态分界线”,也是“状态转移语句”。
但是没那么简单!如果 yield
出现在 For 循环中呢?JavaScript 可没法直接跳到 For 循环的某句话中。类似的情况还有很多,因此我们需要对代码做一些变动,也就是将它们转换为 goto
的形式(JavaScript 中没有 goto
关键字,这里只是用来举几个例子,详细的例子请看末尾的参考文章)。为了不至于弄错分号和 Label 语法中的冒号,下面的几段代码将禁止语句结尾的分号:
if (condition) success
else fail
// which will be transformed as ...
if (!condition) goto ELSE
success
goto ENDIF
ELSE:
fail
ENDIF:
for (init; condition; next) body
// which will be transformed as ...
init
while (condition) {
body
next
}
// which will be transformed as ...
init
WHILE:
if (!condition) goto ENDWHILE
body
next
goto WHILE
ENDWHILE:
上面代码中的 condition
、success
、fail
、init
、next
、body
均为基本语句的组合,里面可能会有 yield
或 return
。
于是将 Generator 转换为 ES5 函数的方法是(这一段并非原创,我只是让语义更清晰了一下,来源请见末尾的参考文章):
- 提升本地变量,每一个本地变量都要变成函数闭包外的变量,把变量声明语句变成变量初始化赋值语句
- 找到含有跳转的控制流语句(包括循环语句),改写这些语句,变成
goto
语句 - 把代码根据
yield
和label
分成若干个部分,作为状态机的状态;写个大的switch-case
,把状态填到每一个 Case - 把
yield
改写成“改变状态的语句加return
”,goto
改写成“改变状态的语句加 break” - 补充完整代码,返回一个可以调用
next
,并且next
会返回{ value, done }
事实上,transform-async-to-generator
使用的 regenerator
基本就是这么做的。
最终实践
有了上述的知识,我们便可以手工转换一下我们最开始的代码了!
async function t(xxx) {
const x = await someFunction(xxx);
const y = x ? x + 1 : await fallback(x);
for (let i = 0; i < 5; i++) {
await anotherFunction(i);
}
return y;
}
// transform to Generator and add a `co`-like function
function* _t(xxx) {
const x = yield someFunction(xxx);
const y = x ? x + 1 : yield fallback(x);
for (let i = 0; i < 5; i++) {
yield anotherFunction(i);
}
return y;
}
const t = simpleCo(_t);
// convert to a closure to avoid scope error
function _t2() {
var _x = 0, _y = 0, _i = 0;
return function* _t(xxx) {
_x = yield someFunction(xxx);
_y = _x ? _x + 1 : yield fallback(_x);
for (_i = 0; _i < 5; _i++) {
yield anotherFunction(_i);
}
return _y;
}
}
t = simpleCo(_t2);
// add goto statements in generator
// remove optional ";" here because it looks like ":"
function _t2() {
var _x = 0, _y = 0, _i = 0
return function* _t(xxx) {
_x = yield someFunction(xxx)
if (!(_x)) goto ELSE
temp = _x + 1
ELSE:
temp = yield fallback(_x)
_y = temp
_i = 0
WHILE:
if (!(_i < 5)) goto ENDWHILE
yield anotherFunction(_i)
_i++
goto WHILE
ENDWHILE:
return _y
}
}
t = simpleCo(_t2)
// build the state machine with `value` and `done` just like Generator
// here we use ctx as current context, it contains the last returned
// value, we need to modify `simpleCo` to support this
function _t2(ctx) {
var _x = 0, _y = 0, _i = 0
var state = 'START'
var done = false
var returnValue = undefined
function stop(val) {
done = true
returnValue = val
}
function _t(xxx) {
while (1) {
switch (state) {
case 'START':
state = 1
return someFunction(xxx)
case 1:
_x = ctx.lastReturnedValue
if (!_x) {
state = 'ELSE'
break
}
temp = _x + 1
state = 'AFTERIF'
break
case 'ELSE':
state = 3
return fallback(x)
case 3:
temp = ctx.lastReturnedValue
case 'AFTERIF':
_y = temp
_i = 0
case 'WHILE':
if (!(_i < 5)) {
state = 'ENDWHILE'
break
}
state = 6
return anotherFunction(_i)
case 6:
_i++
state = 'WHILE'
break
case 'ENDWHILE':
return stop(_y)
}
}
}
return {
next() {
return done ? {
value: returnValue,
done: true
} : {
value: _t(),
done: false
}
}
}
}
t = modifiedSimpleCo(_t2)
是不是瞬间觉得跟 Babel 转换出来的代码非常相似了!区别就在于,Babel 转换出来的代码,是将状态机的状态用 regeneratorRuntime.wrap
来控制的,它相当于对其包裹的函数的一个装饰器。
结论
经过了一番折腾之后,我发现 async-await
之所以可以用来作为 Promise 的语法糖,原因是其转换过程中用到了 Promise(参见上面的 simpleCo
),如果可以支持其它异步方式(例如 setTimeout
23333),那原则上来说也可以作为那些方式的语法糖,当然 Babel 官方肯定是不会这么做的啦。
至于为什么非 Promise 前面也可以加 await
,并不是因为转换后的代码中有特殊判定,而是因为 Promise.resolve
可以接收 thenable 的值和普通值,前者会自动 then
下去,后者则会作为 fullfill
状态时的值。
Update 2018.11.18
有大佬指出 TypeScript 转换出来的自动机可读性更高一些。不过我看了一下,原理是完全一样的,大家可以自行对比一下。
参考文章