R·ex / Zeng


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

Babel 如何转换 async-await 至 ES5

注意:本文发布于 1953 天前,文章中的一些内容可能已经过时。

背景

中午吃饭的时候跟同事讨论起了 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 转换出来的自动机可读性更高一些。不过我看了一下,原理是完全一样的,大家可以自行对比一下。


参考文章

Disqus 加载中……如未能加载,请将 disqus.com 和 disquscdn.com 加入白名单。

这是我们共同度过的

第 3015 天