背景
中午吃飯的時候跟同事討論起了 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 轉換出來的自動機可讀性更高一些。不過我看了一下,原理是完全一樣的,大家可以自行對比一下。
參考文章