小哥哥小姐姐,來嚐嚐 Async 函式這塊語法糖

ThinkJS發表於2018-08-22

編者注:眾所周知,JS 最大的特性就是非同步,非同步提高了效能但是卻給我們編寫帶來了一定困難,造就了令人髮指的回撥地獄。為了解決這個問題,一個又一個的解決方案被提出來。今天我們請來了 《JavaScript 高階程式設計》等多本書的知名譯者 @李鬆峰 老師給我們講解下各種非同步函式編寫的解決方案以及各種內涵。

本次內容是基於之前分享的文字版,若想看重點的話可以看之前的 PPT:ppt.baomitu.com/d/fd045abb
也可以檢視之前的分享視訊:cloud.live.360vcloud.net/theater/pla…


ES7(ECMAScript 2016)推出了Async函式(async/await),實現了以順序、同步程式碼的編寫方式來控制非同步流程,徹底解決了困擾JavaScript開發者的“回撥地獄”問題。比如,之前需要巢狀回撥的非同步邏輯:

const result = [];
// pseudo-code, ajax stand for an asynchronous request
ajax('url1', function(err, data){
    if(err) {...}
    result.push(data)
    ajax('url2', function(err, data){
        if(err) {...}
        result.push(data)
        console.log(result)
    })
})
複製程式碼

現在可以寫成如下同步程式碼的樣式了:

async function example() {
  const r1 = await new Promise(resolve =>
    setTimeout(resolve, 500, 'slowest')
  )
  const r2 = await new Promise(resolve =>
    setTimeout(resolve, 200, 'slow')
  )
  return [r1, r2]
}

example().then(result => console.log(result))
// ['slowest', 'slow']
複製程式碼

Async函式需要在function前面新增async關鍵字,同時內部以await關鍵字來“阻塞”非同步操作,直到非同步操作返回結果,然後再繼續執行。在沒有Async函式以前,我們無法想象下面的非同步程式碼可以直接拿到結果:

const r1 = ajax('url')
console.log(r1)
// undefined
複製程式碼

這當然是不可能的,非同步函式的結果只能在回撥裡拿到。可以說,Async函式是JavaScript程式設計師在探索如何高效非同步程式設計過程中踩“坑”之後的努力“自救”獲得的成果——不是“糖果”。然而,讀者小哥哥小姐姐可能有所不知,Async函式實際上是一個語法糖(果然是“糖果”嗎?),它的背後是ES6(ECMAScript 2015)中推出的Promise、Iterator和Generator,我們簡稱“PIG”。本文就帶各位好好品嚐品嚐這塊語法糖,感受一個PIG是如何成就Async函式的。

1. 當前JavaScript程式設計主要是非同步程式設計

當前JavaScript程式設計主要是非同步程式設計。為什麼這麼說呢?網頁或Web開發最早從2005年Ajax流行開始,逐步向重互動時代邁進。特別是SPA(Single Page Application,單頁應用)流行之後,一度有人提出“Web頁面要轉向Web應用,而且要媲美原生應用”。如今在前端開發元件化的背景下催生的Angular、React和Vue,都是SPA進一步演化的結果。

Web應用或開發重互動的特徵越來越明顯,意味著什麼?意味著按照瀏覽器這個執行時的特性,頁面在首次載入過程中,與JavaScript相關的主要任務就是載入基礎執行庫和擴充套件庫(包括給低版本瀏覽器打補丁的指令碼),然後初始化和設定頁面的狀態。首次載入之後,使用者對頁面的操作、資料I/O以及DOM更新,就全部交由非同步JavaScript指令碼管理。所以,目前JavaScript程式設計最大的應用是Web互動,而Web互動的核心就是非同步邏輯。

然而,ES6之前JavaScript中控制非同步流程的手段只有事件和回撥。比如下面的示例展示了通過原生XMLHttpRequest物件傳送非同步請求,然後給onloadonerror事件分別註冊成功和錯誤處理函式:

var req = new XMLHttpRequest();
req.open('GET', url);

req.onload = function () {
    if (req.status == 200) {
        processData(req.response);
    } 
};

req.onerror = function () {
    console.log('Network Error');
};

req.send(); 
複製程式碼

下面的程式碼展示了Node.js經典的“先傳錯誤”的回撥。但這裡要重點提一下,這種函數語言程式設計風格也叫CPS,即Continuation Passing Style,我翻譯成“後續操作傳遞風格”。因為呼叫readFile傳入了表示後續操作的一個回撥函式。這一塊就不展開了。

// Node.js
fs.readFile('file.txt', function (error, data) {
    if (error) {
       // ...
    }
    console.log(data);
  }
);
複製程式碼

事件和回撥有很多問題,主要是它們只適用於簡單的情況。邏輯一複雜,程式碼的編寫和維護成本就成倍上升。比如,大家熟知的“回撥地獄”。更重要的是,回撥模式的非同步本質與人類同步、順序的思維模式是相悖的。

為了應對越來越複雜的非同步程式設計需求,ES6推出瞭解決上述問題的Promise。

2. Promise

Promise,人們普遍的理解就是:“Promise是一個未來值的佔位符”。也就是說,從語義上講,一個Promise物件代表一個對未來值的“承諾”(promise),這個承諾將來如果“兌現”(fulfill),就會“解決”(resolve)為一個有意義的資料;如果“拒絕”(reject),就會“解決”為一個“拒絕理由”(rejection reason),就是一個錯誤訊息。

Promise物件的狀態很簡單,一生下來的狀態是pending(待定),將來兌現了,狀態變成fulfilled;拒絕了,狀態變成rejectedfulfilledrejected顯然是一種“確定”(settled)狀態。以上狀態轉換是不可逆的,所以Promise很單純,好控制,哈哈。

小哥哥小姐姐,來嚐嚐 Async 函式這塊語法糖

以下是Promise相關的所有API。前3個是建立Promise物件的(稍後有例子),後4箇中的前2個是用於註冊反應函式的(稍後有例子),後2個是用於控制併發和搶佔的:

小哥哥小姐姐,來嚐嚐 Async 函式這塊語法糖

以下是通過Prmoise(executor)建構函式建立Promise例項的詳細過程:要傳入一個“執行函式”(executor),這個執行函式又接收兩個引數“解決函式”(resolver)和“拒絕函式”(rejector),程式碼中分別對應變數resolvereject,作用分別是將新建物件的狀態由pending改為fulfilledrejected,同時返回“兌現值”(fulfillment)和“拒絕理由”(rejection)。當然,resolvereject都是在非同步操作的回撥中呼叫的。呼叫之後,執行時環境(瀏覽器引擎或Node.js的libuv)中的事件迴圈排程機制會把與之相關的反應函式——兌現反應函式或拒絕反應函式以及相關的引數新增到“微任務”佇列,以便下一次“循檢”(tick)時排程到JavaScript執行緒去執行。

小哥哥小姐姐,來嚐嚐 Async 函式這塊語法糖

如前所述,Promise物件的狀態由pending變成fulfilled,就會執行“兌現反應函式”(fulfillment reaction);而變成rejected,就會執行“拒絕反應函式”(rejection reaction)。如下例所示,常規的方式是通過p.then()註冊兌現函式,通過p.catch()註冊拒絕函式:

p.then(res => { // 兌現反應函式
  // res === 'random success'
})
p.catch(err => { // 拒絕反應函式
  // err === 'random failure'
})
複製程式碼

當然還有非常規的方式,而且有時候非常規方式可能更好用:

// 通過一個.then()方法同時註冊兌現和拒絕函式
p.then(
  res => {
    // handle response
  },
  err => {
    // handle error
  }
)
// 通過.then()方法只註冊一個函式:兌現函式
p.then(res => {
  // handle response
})
// 通過.then()方法只傳入拒絕函式,兌現函式的位置傳null
p.then(null, err => {
  // handle error
})
複製程式碼

關於Promise就這樣吧。ES6除了Promise,還推出了Iterator(迭代器)和Generator(生成器),於是就有成就Async函式的PIG組合。下面我們分別簡單看一看Iterator和Generator。

3. Iterator

要理解Iterator或者迭代器,最簡單的方式是看它的介面:

interface IteratorResult {
  done: boolean;
  value: any;
}
interface Iterator {
  next(): IteratorResult;
}
interface Iterable {
  [Symbol.iterator](): Iterator
}
複製程式碼

先從中間的Iterator看。

什麼是迭代器?它是一個物件,有一個next()方法,每次呼叫next()方法,就會返回一個迭代器結果(看第一個介面IteratorResult)。而這個迭代器結果,同樣還是一個物件,這個物件有兩個屬性:donevalue,其中done是一個布林值,false表示迭代器迭代的序列沒有結束;true表示迭代器迭代的序列結束了。而value就是迭代器每次迭代真正返回的值。

再看最後一個介面Iterable,翻譯成“可迭代物件”,它有一個[Symbol.iterator]()方法,這個方法會返回一個迭代器。

可以結合前面的介面定義和下面這張圖來理解可迭代物件(實現了“可迭代協議”)、迭代器(實現了“迭代器協議”)和迭代器結果這3個簡單而又重要的概念(暫時理解不了也沒關係,後面還有一個無窮序列的例子,可以幫助大家理解)。

小哥哥小姐姐,來嚐嚐 Async 函式這塊語法糖

可迭代物件是一個我們非常熟悉的概念,陣列、字串以及ES6新增的集合型別Set和Map都是可迭代物件。這意味著什麼呢?意味著我們可以通過E6新增的3個用於操作可迭代物件的語法:

  • for...of
  • [...iterable]
  • Array.from(iterable)

注意 E6以前就有的以下語法不適用於可迭代物件:

  • for...in
  • Array#forEach

接下來我們看例子。

for (const item of sequence) {
  console.log(item)
  // 'i'
  // 't'
  // 'e'
  // 'r'
  // 'a'
  // 'b'
  // 'l'
  // 'e'
}

console.log([...sequence])
// ['i', 't', 'e', 'r', 'a', 'b', 'l', 'e']

console.log(Array.from(sequence))
// ['i', 't', 'e', 'r', 'a', 'b', 'l', 'e']
複製程式碼

以上示例分別使用for...of、擴充套件操作符(...)和Array.from()方法來迭代了前面定義的sequence這個可迭代物件。

下面再看一個通過迭代器建立無窮序列的小例子,通過這個例子我們再來深入理解與迭代器相關的概念。

const random = {
  [Symbol.iterator]: () => ({
    next: () => ({ value: Math.random() })
  })
}

// 執行這行程式碼會怎麼樣?
[...random]
// 這行呢?
Array.from(random)
複製程式碼

這個例子使用兩個ES6的箭頭函式定義了兩個方法,建立了三個物件。

最內層的物件{ value: Math.random() }很明顯是一個“迭代器結果”(IteratorResult)物件,因為它有一個value屬性和一個……,等等,done屬性呢?這裡沒有定義done屬性,所以每次迭代(呼叫next())時訪問IteratorResult.done都會返回false;所以這個迭代器結果的定義相當於{ value: Math.random() , done: false }。顯然,done永遠不可能是true,所以這是一個無窮隨機數序列!

interface IteratorResult {
  done: boolean;
  value: any;
}
複製程式碼

再往外看,返回這個迭代器結果物件的箭頭函式被賦值給了外層物件的next()方法。根據Iterator介面的定義,如果一個物件包含一個next()方法,而這個方法的返回值又是一個迭代器結果,那麼這個物件是什麼?沒錯,就是迭代器。好,第二個物件是一個迭代器!

interface Iterator {
  next(): IteratorResult;
}
複製程式碼

再往外看,返回這個迭代器物件的箭頭函式被賦值給了外層物件的[Symbol.iterator]()方法。根據Iterable介面的定義,如果一個物件包含一個[Symbol.iterator]()方法,而這個方法的返回值又是一個迭代器,那麼這個物件是什麼?沒錯,就是可迭代物件。

interface Iterable {
  [Symbol.iterator](): Iterator
}
複製程式碼

好,到現在我們應該徹底理解迭代器及其相關概念了。下面繼續看例子。前面的例子定義了一個可迭代物件random,這個物件的迭代器可以無限返回隨機數,所以:

// 執行這行程式碼會怎麼樣?
[...random]
// 這行呢?
Array.from(random)
複製程式碼

是的,這兩行程式碼都會導致程式(或執行時)崩潰!因為迭代器會不停地執行,阻塞JavaScript執行執行緒,最終可能因佔滿可用記憶體導致執行時停止響應,甚至崩潰。

那麼訪問無窮序列的正確方式是什麼?答案是使用解構賦值或給for...of迴圈設定退出條件:

const [one, another] = random  // 解析賦值,取得前兩個隨機數
console.log(one)
// 0.23235511826351285
console.log(another)
// 0.28749457537196577

for (const value of random) {
  if (value > 0.8) { // 退出條件,隨機數大於0.8則中斷迴圈
    break
  }
  console.log(value)
}
複製程式碼

當然,使用無窮序列還有更高階的方式,鑑於本文的目的,在此就不多介紹了。下面我們再說最後一個ES6的特性Generator。

4. Generator

依例,上介面:

interface Generator extends Iterator {
    next(value?: any): IteratorResult;
    [Symbol.iterator](): Iterator;
    throw(exception: any);
}
複製程式碼

能看來出生成器是什麼嗎?僅從它的介面來看,它既是一個迭代器,又是一個可迭代物件。沒錯,生成器因此又是迭代器的“加強版”,為什麼?因為生成器還提供了一個關鍵字yield,它返回的序列值會自動包裝在一個IteratorResult(迭代器結果)物件中,省去了我們手工編寫相應程式碼的麻煩。下面就是一個生成器函式的定義:

function *gen() {
  yield 'a'
  yield 'b'
  return 'c'
}
複製程式碼

哎,介面定義的生成器不是一個物件嗎,怎麼是一個函式啊?

實際上,說生成器是物件或是函式都不確切。但我們知道,呼叫生成器函式會返回一個迭代器(介面描述的就是這個物件),這個迭代器可以控制返回它的生成器函式封裝的邏輯和資料。從這個意義上說,生成器由生成器函式及其返回的迭代器兩部分組成。再換句話說,生成器是一個籠統的概念,是一個統稱。(別急,一會你就明白這樣理解生成器的意義何在了。)

本節剛開始說了,生成器(返回的物件)“既是一個迭代器,又是一個可迭代物件”。下面我們就來驗證一下:

const chars = gen()
typeof chars[Symbol.iterator] === 'function' // chars是可迭代物件
typeof chars.next === 'function'  // chars是迭代器
chars[Symbol.iterator]() === chars  // chars的迭代器就是它本身
console.log(Array.from(chars))  // 可以對它使用Array.from
// ['a', 'b']
console.log([...chars]) // 可以對它使用Array.from
// ['a', 'b']
複製程式碼

通過程式碼中的註釋我們得到了全部答案。這裡有個小問題:“為什麼迭代這個生成器返回的序列值中不包含字元'c'呢?”

原因在於,yield返回的迭代器結果物件的done屬性值都為false,所以'a''b'都是有效的序列值;而return返回的雖然也是迭代器結果物件,但done屬性的值卻是truetrue表示序列結束,所以'c'不會包含在迭代結果中。(如果沒有return語句,程式碼執行到生成器函式末尾,會隱式返回{ value: undefined, done: true}。相信這一點不說你也知道。)

以上只是生成器作為“加強版”迭代器的一面。接下來,我們要接觸生成器真正強大的另一面了!

生成器真正強大的地方,也是它有別於迭代器的地方,在於它不僅能在每次迭代返回值,而且還能接收值。(當然,生成器的概念裡本身就有生成器函式嘛!函式當然可以接收引數嘍。)等等,可不僅僅是可以給生成器函式傳參,而是還可以給yield表示式傳參!

function *gen(x) {
  const y = x * (yield)
  return y
}

const it = gen(6)
it.next()
// {value: undefined, done: false}
it.next(7)
// {value: 42, done: true}
複製程式碼

在上面這個簡單的生成器的例子中。我們定義了一個生成器函式*gen(),它接收一個引數x。函式體內只有一個yield表示式,好像啥也沒幹。但是,yield表示式似乎是一個“值的佔位符”,因為程式碼在某個時刻會計算變數x與這個“值”的乘積,並把該乘積賦值給變數y。最後,函式返回y

這有點費解,下面我們一步一步分析。

  1. 呼叫gen(6)建立生成器的迭代器it(前面說了,生成器包含迭代器及返回它的生成器函式),傳入數值6。
  2. 呼叫it.next()啟動生成器。此時生成器函式的程式碼執行到第一個yield表示式處暫停,並返回undefined。(yield並沒閒著,它看後面沒有顯式要返回的值,就只能返回預設的undefined。)
  3. 呼叫it.next(7)恢復生成器執行。此時yield接收到傳入的數值7,立即恢復生成器函式程式碼的執行,並把自己替換成數值7。
  4. 程式碼計算:6 * 7,得到42,並把42賦給變數y,最後返回y
  5. 生成器函式最終返回的值就是:{value: 42, done: true}

這個例子中只有一個yield,假如還有更多的yield,則第4步會到第二個yield處再次暫停生成器函式的執行,返回一個值,之後重複第3、4步,即還可以通過再呼叫it.next()向生成器函式中傳入值。

我們簡單總結一下,每次呼叫it.next(),可能有下列4種情況導致生成器暫停或停止執行:

  1. yield表示式返回序列中下一個值
  2. return語句返回生成器函式的值({ done: true }
  3. throw語句完全停止生成器執行(後面會詳細解釋)
  4. 到達生成器函式最後,隱式返回{ value: undefined, done: true}

注意 這裡的returnthrow既可以在生成器函式內部呼叫,也可以在生成器函式外部通過生成器的迭代器呼叫,比如:it.return(0)it.throw(new Error('Oops'))。後面我們會給出相應的例子。

由此,我們瞭解到,生成器的獨到之處就在於它的yield關鍵字。這個yield有兩大神奇之處:一、它是生成器函式暫停和恢復執行的分界點;二、它是向外和向內傳值(包括錯誤/異常)的媒介。

提到錯誤/異常,下面我們就來重點看一看生成器如何處理異常。畢竟,錯誤處理是使用回撥方式編寫非同步程式碼的時候最讓JavaScript程式設計師頭疼的地方之一。

4.1 同步錯誤處理

首先,我們看“由內而外”的錯誤傳遞,即從生成器函式內部把錯誤拋到迭代器程式碼中。

function *main() {
  const x = yield "Hello World";
  yield x.toLowerCase(); // 導致異常!
}

const it = main();
it.next().value; // Hello World
try {
  it.next( 42 );
} catch (err) {
  console.error(err); // TypeError
}
複製程式碼

如程式碼註釋所提示的,生成器函式的第二行程式碼會導致異常(至於為什麼,讀者可以自己“人肉”執行程式碼,推演一下)。由於生成器函式內部沒有做異常處理,因此錯誤被拋給了生成器的迭代程式碼,也就是it.next(42)這行程式碼。好在這行程式碼被一個try/catch包著,錯誤可以正常捕獲並處理。

接下來,再看“由外而內”(準確地說,應該是“由外而內再而外”)的錯誤傳遞。

function *main() {
  var x = yield "Hello World";
  console.log('never gets here'); 
}

const it = main();
it.next().value; // Hello World
try {
  it.throw('Oops'); // `*main()`會處理嗎? 
} catch (err) {   // 沒有!
  console.error(err); // Oops
}
複製程式碼

如程式碼所示,迭代程式碼通過it.throw('Oops')丟擲異常。這個異常是拋到生成器函式內的(通過迭代器it)。拋進去之後,yield表示式發現自己收到一個“燙手的山芋”,看看周圍也沒有異常處理邏輯“護駕”,於是眼疾手快,迅速又把這個異常給拋了出來。迭代器it顯然是有準備的,它本意也是想先看看生成器函式內部有沒有邏輯負責異常處理(看註釋“ // *main()會處理嗎?”),“沒有!”,它自己的try/catch早已等候多時了。

4.2 非同步迭代生成器

前面我們看到的對生成器的迭代傳值,包括傳遞錯誤,都是同步的。實際上,生成器的yield表示式真正(哦,又一個“真正”)強大的地方在於:它在暫停生成器程式碼執行以後,不是必須等待迭代器程式碼同步呼叫it.next()方法給它返回值,而是可以讓迭代器在一個非同步操作的回撥中取得返回值,然後再通過it.next(res)把值傳給它。

明白了嗎?yield可以等待一個非同步操作的結果。從而讓本文開始提到的這種看似不可能的情況變成可能:

const r1 = ajax('url')
console.log(r1)
// undefined
複製程式碼

怎麼變呢,在非同步操作前加個yield呀:

const r1 = yield ajax('url')
console.log(r1)
// 這次r1就是真正的響應結果了
複製程式碼

我們還是以一個返回Promise的非同步操作為例來說明這一點比較好。因為基於回撥的非同步操作,很容易可以轉換成基於Promise的非同步操作(比如jQuery的$.ajax()或通過util.promisify把Node.js中的非同步方法轉換成Promise)。

例子來了。這是一個純Promise的例子。

function foo(x,y) {
  return request(
    "http://some.url.1/?x=" + x + "&y=" + y
  );
}

foo(11, 31)
  .then(
    function(text){
      console.log(text);
    },
    function(err){
      console.error(err);
    }
);
複製程式碼

函式foo(x, y)封裝了一個非同步request請求,返回一個Promise。呼叫foo(11, 31)傳入引數後,request就向拼接好的URL傳送請求,返回待定(pending)狀態的Promise物件。請求成功,則執行then()中註冊的兌現反應函式,處理響應;請求失敗,則執行拒絕反應函式,處理錯誤。

接下來我們要做的,就是將上面的程式碼與生成器結合,讓生成器只關注傳送請求和取得響應結果,而把非同步操作的等待和回撥處理邏輯作為實現細節抽象出來。(“作為細節”,對,我們的目標是隻關注請求和結果,過程嘛,都是細節,哈哈~。)

function foo(x, y) {
  return request(
    "http://some.url.1/?x=" + x + "&y=" + y
  );
}
function *main() {
  try {
    const result = yield foo(11, 31);  // 非同步函式呼叫!
    console.log( result );
  } catch (err) {
    console.error( err );
  }
}
const it = main(); 
const p = it.next().value; // 啟動生成器並取得Promise `p`

p.then( // 等待Promise `p`解決
  function(res){
    it.next(res);  // 把`text`傳給`*main()`並恢復其執行
  },
  function(err){
    it.throw(err);  // 把`err`拋到`*main()`
  }
);
複製程式碼

注意,生成器函式(*main)的yield表示式中出現了非同步函式呼叫:foo(11, 31)。而我們就要做的,就是在迭代器程式碼中通過it.next()拿到這個非同步函式呼叫返回的Promise,然後正確地處理它。怎麼處理?我們看程式碼。

建立生成器的迭代器之後,const p = it.next().value;返回了Promise p。在p的兌現反應函式中,我們把拿到的響應res通過it.next(res)呼叫傳回了生成器函式中的yieldyield拿到響應結果res之後,立即恢復生成器程式碼的執行,把res賦值給變數result。於是,我們成功地在生成器函式中,以同步程式碼的書寫方式取得了非同步請求的響應結果!神奇不?

(當然,如果非同步請求發生錯誤,在p的拒絕反應函式中也會通過it.throw(err)把錯誤拋給生成器函式。但這個現在不重要。)

好啦,目標達成:我們利用生成器的同步程式碼,實現了對非同步操作的完美控制。然而,還有一個問題。上面例子中的生成器只包裝了一個非同步操作,如果是多個非同步操作怎麼辦呢?這時候,最好有一段通用的用於處理生成器函式的程式碼,無論其中包含多少非同步操作,這段程式碼都能自動完成對Promise的接收、等待和響應/錯誤傳遞等這些“細節”工作。

那不就是一個基於Promise的生成器執行程式嗎?

5. 通用的生成器執行程式

綜前所述,我們想要的是這樣一個結果:

function example() {
  return run(function *() {
    const r1 = yield new Promise(resolve =>
      setTimeout(resolve, 500, 'slowest')
    )
    const r2 = yield new Promise(resolve =>
      setTimeout(resolve, 200, 'slow')
    )
    return [r1, r2]
  })
}

example().then(result => console.log(result))
// ['slowest', 'slow']
複製程式碼

即定義一個通用的執行函式run,它負責處理傳給它的生成器函式中包裝的任意多個非同步操作。針對每個操作,它都會正確地返回非同步結果,或者向生成器函式中丟擲異常。而執行這個函式的最終結果,也是返回一個Promise,這個Promise包含生成器函式返回的所有非同步操作的結果(上例)。

已經有聰明人實現了這樣的執行程式,下面我們就給出兩個實現,大家可以自己嘗試去執行一下,然後“人肉”執行,加深理解。

注意 在ES7推出Async函式之前,飽受回撥之苦的JavaScript程式設計師就是靠類似的執行程式結合生成器給自己“續命”的。事實上,在ES6之前(沒有Promise、沒有生成器)的“蠻荒時代”,不屈不撓又足智多謀的JavaScript程式設計師們就已經摸索出/找到了Thenable(Promise的前身)和類似生成器的實現方法(比如regenerator),讓瀏覽器能支援自己以同步風格編寫非同步程式碼的高效幹活兒夢。

苦哉!偉哉!悲夫,絞兮乎!

這是一個:

function run(gen) {
  const it = gen();
  return Promise.resolve()
    .then( function handleNext(value){
      let next = it.next( value );
      return (function handleResult(next){
        if (next.done) {
          return next.value;
        } else {
          return Promise.resolve( next.value )
            .then(
              handleNext,
			  function handleErr(err) {
                return Promise.resolve(
                  it.throw( err )
                )
                 .then( handleResult );
              }
            );
        } // if...else
      })(next); // handleResult(next)
    }); // handleNext(value)
}
複製程式碼

供參考的“人肉”執行過程

(呼叫run的程式碼見本節開頭。)

這個run函式接收一個生成器函式作為引數,然後立即建立了生成器的迭代器it(看上面run函式的程式碼)。

然後,它返回一個Promise,是通過Promise.resolve()直接建立的。

我們給這個Promise的.then()方法傳入了一個兌現反應函式(這個函式一定會被呼叫,因為Promise是兌現的),名叫handleNext(value),它接收一個引數value。第一次呼叫時,不會傳入任何值,因此value的值是undefined

接下來,第一次呼叫it.next(value)啟動生成器,傳入undefined。生成器的第一個yield會返回一個待定狀態的Promise,至少500ms之後才會解決。

此時變數next的值是{ value: < Promise [pending]>, done: false}

接著,把next傳給下面的IIFE(Immediately Invoked Function Expression,立即呼叫函式表示式),這個函式叫handleResult(處理結果)。

handleResult(next)內部,首先檢查next.done,不等於true,進入else子句。此時通過Promise.resolve(next.value)包裝next.value:等待返回的Promise解決,解決之後拿到字串值'Slowest',然後傳給兌現反應函式handleNext(value)

至此,第一個非同步操作的前半程處理完畢。接著,再次呼叫handleNext(value)傳入字串'Slowest'。迭代器再次呼叫next(value)'Slowest'傳回生成器函式中的第一個yieldyield取得這個字串,立即恢復生成器執行,把這個字串賦值給變數r1。生成器函式中的程式碼繼續執行,到第二個yield處暫停,此時建立並返回第二個最終值為'slow'的Promise,但此時Promise是待定狀態,200毫秒後才會解決。

繼續,在迭代器程式碼中,變數next再次拿到一個物件{ value: <Promise [pending]>, done: false}。再次進入IIFE,傳入next。檢查next.done不等於false,在else塊中把next.value封裝到一個Promise.resolve(next.value)中……

看,下面又是一個:

function run(generator) {
  return new Promise((resolve, reject) => {
    const it = generator()
    step(() => it.next())
    function step(nextFn) {
      const result = runNext(nextFn)
      if (result.done) {
        resolve(result.value)
        return
      }
      Promise
        .resolve(result.value)
        .then(
          value => step(() => it.next(value)), 
          err => step(() => it.throw(err))
        )
    }
    function runNext(nextFn) {
      try {
        return nextFn()
      } catch (err) {
        reject(err)
      }
    }
  })
}
複製程式碼

6. 為什麼說Async函式是語法糖

有了這個執行函式,我們可以比較一下下面兩個example()函式:

小哥哥小姐姐,來嚐嚐 Async 函式這塊語法糖

第一個example()是通過生成器執行程式控制非同步程式碼;第二個example()是一個非同步(Async)函式,通過async/await控制非同步程式碼。

它們的區別只在於前者多了一層run函式封裝,使用yield而不是await,而且沒有async關鍵字修飾。除此之外,核心程式碼完全一樣!

現在,大家再看到類似下面的非同步函式,能想到什麼?

async function example() {
  const r1 = await new Promise(resolve =>
    setTimeout(resolve, 500, 'slowest')
  )
  const r2 = await new Promise(resolve =>
    setTimeout(resolve, 200, 'slow')
  )
  return [r1, r2]
}

example().then(result => console.log(result))
// ['slowest', 'slow']
複製程式碼

是的,Async函式或者說async/await就是基於Promise、Iterator和Generator構造的一塊充滿苦澀和香甜、讓人回味無窮的“語法糖”!記住,Async function = Promise + Iterator + Generator,或者“Async函式原來是PIG”。

7. 參考資料

  • ECMAScript 2018
  • Practical Modern JavaScript
  • You Don't Know JS: Async & Performance
  • Understanding ECMAScript 6
  • Exploring ES6

相關文章