非同步之三:Async 函式的使用及簡單實現

L小庸發表於2019-02-25

解決回撥地獄的非同步操作,Async 函式是終極辦法,但瞭解生成器和 Promise 有助於理解 Async 函式原理。由於內容較多,分三部分進行,這是第三部分,介紹 Async 函式相關。第一部分介紹 Generator,第二部分介紹 Promise。

在這部分中,我們會先介紹 Async 函式的基本使用,然後會結合前兩部分介紹的生成器和 Promise 實現一個 async 函式。

1)Async 函式概覽

1.1 概念

通過在普通函式前加async操作符可以定義 Async 函式:

// 這是一個 async 函式
async function() {}
複製程式碼

Async 函式體中的程式碼是非同步執行的,不會阻塞後面程式碼執行,但它們的寫法和同步程式碼相似。

Async 函式會 返回一個已完成的 promise 物件,實際在使用的時候會和await操作符配合使用,在介紹await之前,我們先看看 async 函式本身有哪些特點。

1.2 Async 函式基本用法

1.2.1 函式體內沒有 await

如果 async 函式體內如果沒有await操作符,那麼它返回的 promise 物件狀態和他的函式體內程式碼怎麼寫有關係,具體和 promise 的then()方法的處理方式相同:

1)沒有顯式 return 任何資料

此時預設返回Promise.resolve():

var a = (async () => {})();
複製程式碼

相當於

var a = (async () => {
  return Promise.resolve();
})();
複製程式碼

此時 a 的值:

a {
  [[PromiseStatus]]: `resolved`,
  [[PromiseValue]]: undefined
}
複製程式碼

2)顯式 return 非 promise

相當於返回Promise.resolve(data)

var a = (async () => {
  return 111;
})();
複製程式碼

相當於

var a = (async () => {
  return Promise.resolve(111);
})();
複製程式碼

此時 a 的值:

a {
  [[PromiseStatus]]: `resolved`,
  [[PromiseValue]]: 111
}
複製程式碼

3)顯式 return promise 物件

此時 async 函式返回的 promise 物件狀態由顯示返回的 promise 物件狀態決定,這裡以被拒絕的 promise 為例:

var a = (async () => Promise.reject(111))();
複製程式碼

此時 a 的值:

a {
  [[PromiseStatus]]: `rejected`,
  [[PromiseValue]]: 111
}
複製程式碼

但實際使用中,我們不會向上面那樣使用,而是配合await操作符一起使用,不然像上面那樣,和 promise 相比,並沒有優勢可言。特別的,沒有await操作符,我們並不能用 async 函式解決相互依賴的非同步資料的請求問題。

換句話說:我們不關心 async 返回的 promise 狀態(通常情況,async 函式不會返回任何內容,即預設返回Promise.resolve()),我們關心的是 async 函式體內的程式碼怎麼寫,因為裡面的程式碼可以非同步執行且不阻塞 async 函式後面程式碼的執行,這就為寫非同步程式碼創造了條件,並且書寫形式上和同步程式碼一樣。

1.2.2 await 介紹

await操作符使用方式如下:

[rv] = await expression;
複製程式碼

expression:可以是任何值,但通常是一個 promise;

rv: 可選。如果有且 expression 是非 promise 的值,則 rv 等於 expression 本身;不然,rv 等於 兌現 的 promise 的值,如果該 promise 被拒絕,則拋個異常(所以await一般被 try-catch 包裹,異常可以被捕獲到)。

但注意await必須在 async 函式中使用,不然會報語法錯誤

1.2.3 await 使用

看下面程式碼例子:

1)expression 後為非 promise

(async () => {
  const b = await 111;
  console.log(b); // 111
})();
複製程式碼

直接返回這個 expression 的值,即,列印 111

2)expression 為兌現的 promise

(async () => {
  const b = await Promise.resolve(111);
  console.log(b); // 111
})();
複製程式碼

返回兌現的 promise 的值,所以列印111

3)expression 為拒絕的 promise

(async () => {
  try {
    const b = await Promise.reject(111);

    // 前面的 await 出錯後,當前程式碼塊後面的程式碼就不執行了
    console.log(b); // 不執行
  } catch (e) {
    console.log("出錯了:", e); // 出錯了:111
  }
})();
複製程式碼

如果await後面的 promise 被拒絕或本身程式碼執行出錯都會丟擲一個異常,然後被 catch 到,並且,和當前await同屬一個程式碼塊的後面的程式碼不再執行。

2)Async 函式處理非同步請求

2.1 相互依賴的非同步資料

在 promise 中我們處理相互依賴的非同步資料使用鏈式呼叫的方式,雖然相比回撥函式已經優化很多,但書寫及理解上還是沒有同步程式碼直觀。我們看下 async 函式如何解決這個問題。

先回顧下需求及 promise 的解決方案:

需求:請求 URL1 得到 data1;請求 URL2 得到 data2,但 URL2 = data1[0].url2;請求 URL3 得到 data3,但 URL3 = data2[0].url3

使用 promise 鏈式呼叫可以這樣寫程式碼:

promiseAjax 在 第二部分介紹 promise 時在 3.1 中定義的,通過 promise 封裝的 ajax GET 請求。

promiseAjax(`URL1`)
  .then(data1 => promiseAjax(data1[0].url2))
  .then(data2 => promiseAjax(data2[0].url3);)
  .then(console.log(data3))
  .catch(e => console.log(e));
複製程式碼

如果使用 Async 函式則可以像同步程式碼的一樣寫:

async function() {
  try {
    const data1 = await promiseAjax(`URL1`);
    const data2 = await promiseAjax(data1[0].url);
    const data3 = await promiseAjax(data2[0].url);
  } catch (e) {
    console.log(e);
  }
}
複製程式碼

之所以可以這樣用,是因為只有當前await等待的 promise 兌現後,它後面的程式碼才會執行(或者丟擲錯誤,後面程式碼都不執行,直接去到 catch 分支)。

這裡有兩點值得關注:

1)await幫我們處理了 promise,要麼返回兌現的值,要麼丟擲異常;
2)await在等待 promise 兌現的同時,整個 async 函式會掛起,promise 兌現後再重新執行接下來的程式碼。

對於第 2 點,是不是想到了生成器?在 1.4 節中我們會通過生成器 + promise 自己寫一個 async 函式。

2.2 無依賴關係的非同步資料

Async 函式沒有Promise.all()之類的方法,我們需要寫多幾個 async 函式。

可以藉助Promise.all()在同一個 async 函式中並行處理多個無依賴關係的非同步資料,如下:

async function fn1() {
  try {
    const arr = await Promise.all([
      promiseAjax("URL1"),
      promiseAjax("URL2"),
    ]);

    // ... do something
  } catch (e) {
    console.log(e);
  }
}
複製程式碼

感謝 @賈順名評論

但實際開發中如果非同步請求的資料是業務不相關的,不推薦這樣寫,原因如下:

把所有的非同步請求放在一個 async 函式中相當於手動加強了業務程式碼的耦合,會導致下面兩個問題:

1)寫程式碼及獲取資料都不直觀,尤其請求多起來的時候;
2)Promise.all裡面寫多個無依賴的非同步請求,如果 其中一個被拒絕或發生異常,所有請求的結果我們都獲取不到

如果業務場景是不關心上面兩點,可以考慮使用上面的寫法,不然,每個非同步請求都放在不同的 async 函式中發出。

下面是分開寫的例子:

async function fn1() {
  try {
    const data1 = await promiseAjax("URL1");

    // ... do something
  } catch (e) {
    console.log(e);
  }
}

async function fn2() {
  try {
    const data2 = await promiseAjax("URL2");

    // ... do something
  } catch (e) {
    console.log(e);
  }
}
複製程式碼

3)Async 模擬實現

3.1 async 函式處理非同步資料的原理

我們先看下 async 處理非同步的原理:

  • async 函式遇到await操作符會掛起;
  • await後面的表示式求值(通常是個耗時的非同步操作)前 async 函式一直處於掛起狀態,避免阻塞 async 函式後面的程式碼;
  • await後面的表示式求值求值後(非同步操作完成),await可以對該值做處理:如果是非 promise,直接返回該值;如果是 promsie,則提取 promise 的值並返回。同時告訴 async 函式接著執行下面的程式碼;
  • 哪裡出現異常,結束 async 函式。

await後面的那個非同步操作,往往是返回 promise 物件(比如 axios),然後交給 await 處理,畢竟,async-await 的設計初衷就是為了解決非同步請求資料時的回撥地獄問題,而使用 promise 是關鍵一步。

async 函式本身的行為,和生成器類似;而await等待的通常是 promise 物件,也正因如此,常說 async 函式是 生成器 + promise 結合後的語法糖。

既然我們知道了 async 函式處理非同步資料的原理,接下來我們就簡單模擬下 async 函式的實現過程。

3.2 async 函式簡單實現

這裡只模擬 async 函式配合await處理網路請求的場景,並且請求最終返回 promise 物件,async 函式本身返回值(已完成的 promise 物件)及更多使用場景這裡沒做考慮。

所以接下來的 myAsync 函式只是為了說明 async-await 原理,不要將其用在生產環境中。

3.2.1 程式碼實現

/**
 * 模擬 async 函式的實現,該段程式碼取自 Secrets of the JavaScript Ninja (Second Edition),p159
 */
// 接收生成器作為引數,建議先移到後面,看下生成器中的程式碼
var myAsync = generator => {
  // 注意 iterator.next() 返回物件的 value 是 promiseAjax(),一個 promise
  const iterator = generator();

  // handle 函式控制 async 函式的 掛起-執行
  const handle = iteratorResult => {
    if (iteratorResult.done) return;

    const iteratorValue = iteratorResult.value;

    // 只考慮非同步請求返回值是 promise 的情況
    if (iteratorValue instanceof Promise) {
      // 遞迴呼叫 handle,promise 兌現後再呼叫 iterator.next() 使生成器繼續執行
      // ps.原書then最後少了半個括號 `)`
      iteratorValue
        .then(result => handle(iterator.next(result)))
        .catch(e => iterator.throw(e));
    }
  };

  try {
    handle(iterator.next());
  } catch (e) {
    console.log(e);
  }
};
複製程式碼

3.2.2 使用

myAsync接收的一個生成器作為入參,生成器函式內部的程式碼,和寫原生 async 函式類似,只是用yield代替了await

myAsync(function*() {
  try {
    const a = yield Promise.resolve(1);
    const b = yield Promise.resolve(a + 10);
    const c = yield Promise.resolve(b + 100);
    console.log(a, b, c); // 輸出 1,11,111
  } catch (e) {
    console.log("出錯了:", e);
  }
});
複製程式碼

上面會列印1 11 111

如果第二個yield語句後的 promise 被拒絕Promise.reject(a + 10),則列印出錯了:11

3.2.3 說明:

  • myAsync 函式接受一個生成器作為引數,控制生成器的 掛起 可達到使整個 myAsync 函式在非同步程式碼請求過程 掛起 的效果;
  • myAsync 函式內部通過定義handle函式,控制生成器的 掛起-執行

具體過程如下:

1)首先呼叫generator()生成它的控制器,即迭代器iterator,此時,生成器處於掛起狀態;
2)第一次呼叫handle函式,並傳入iterator.next(),這樣就完成生成器的第一次呼叫的;
3)執行生成器,遇到yield生成器再次掛起,同時把yield後表示式的結果(未完成的 promise)傳給 handle;
4)生成器掛起的同時,非同步請求還在進行,非同步請求完成(promise 兌現)後,會呼叫handle函式中的iteratorValue.then()
5)iteratorValue.then()執行時內部遞迴呼叫handle,同時把非同步請求回的資料傳給生成器(iterator.next(result)),生成器更新資料再次執行。如果出錯直接結束;
6)3、4、5 步重複執行,直到生成器結束,即iteratorResult.done === true,myAsync 結束呼叫。

如果看不明白,可參考下 第一部分 生成器相關和 第二部分 Promise 相關。

參考

【1】[美]JOHN RESIG,BEAR BIBEAULT and JOSIP MARAS 著(2016),Secrets of the JavaScript Ninja (Second Edition),p159,Manning Publications Co.
【2】async function-MDN
【3】await-MDN
【4】理解 JavaScript 的 async/await

相關文章