R·ex / Zeng


音游狗、安全狗、攻城狮、业余设计师、段子手、苦学日语的少年。

Babel 如何转换 async-await 至 ES5

背景

中午吃饭的时候跟同事讨论起了 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-generatorGitHub)。此外网上还有 transform-async-to-module-methodNPM)和 transform-async-to-promisesGitHub)等类似的插件,从名字就能看出来是干什么的,但使用率远远没有官宣的那么高。

从插件名字可以得知,大概的思路就是将 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 的值可以是(摘自 coREADME):

  • 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:

上面代码中的 conditionsuccessfailinitnextbody 均为基本语句的组合,里面可能会有 yieldreturn

于是将 Generator 转换为 ES5 函数的方法是(这一段并非原创,我只是让语义更清晰了一下,来源请见末尾的参考文章):

  1. 提升本地变量,每一个本地变量都要变成函数闭包外的变量,把变量声明语句变成变量初始化赋值语句
  2. 找到含有跳转的控制流语句(包括循环语句),改写这些语句,变成 goto 语句
  3. 把代码根据 yieldlabel 分成若干个部分,作为状态机的状态;写个大的 switch-case,把状态填到每一个 Case
  4. yield 改写成“改变状态的语句加 return”,goto 改写成“改变状态的语句加 break”
  5. 补充完整代码,返回一个可以调用 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 转换出来的自动机可读性更高一些。不过我看了一下,原理是完全一样的,大家可以自行对比一下。


参考文章

版权声明:除文章开头有特殊声明的情况外,所有文章均可在遵从 CC BY 4.0 协议的情况下转载。

这是我们共同度过的

第 1114 天