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 轉換出來的自動機可讀性更高一些。不過我看了一下,原理是完全一樣的,大家可以自行對比一下。


參考文章

Disqus 載入中……如未能載入,請將 disqus.com 和 disquscdn.com 加入白名單。

這是我們共同度過的

第 3866 天