重讀 ES6 - async+await 同步/異步方案

異步編程一直是JavaScript 編程的重大事項。關于異步方案, ES6 先是出現了 基于狀態管理的 Promise,然后出現了 Generator 函數 + co 函數,緊接著又出現了 ES7async + await 方案。

本文力求以最簡明的方式來疏通 async + await。

異步編程的幾個場景

先從一個常見問題開始:一個for 循環中,如何異步的打印迭代順序?

我們很容易想到用閉包,或者 ES6 規定的 let 塊級作用域來回答這個問題。

for (let val of [1, 2, 3, 4]) {
    setTimeout(() => console.log(val),100);
}
// => 預期結果依次為:1, 2, 3, 4

這里描述的是一個均勻發生的的異步,它們被依次按既定的順序排在異步隊列中等待執行。

如果異步不是均勻發生的,那么它們被注冊在異步隊列中的順序就是亂序的。

for (let val of [1, 2, 3, 4]) {
    setTimeout(() => console.log(val), 100 * Math.random());
}
// => 實際結果是隨機的,依次為:4, 2, 3, 1

返回的結果是亂序不可控的,這本來就是最為真實的異步。但另一種情況是,在循環中,如果希望前一個異步執行完畢、后一個異步再執行,該怎么辦?

for (let val of ['a', 'b', 'c', 'd']) {
    // a 執行完后,進入下一個循環
    // 執行 b,依此類推
}

這不就是多個異步 “串行” 嗎!

在回調 callback 嵌套異步操作、再回調的方式,不就解決了這個問題!或者,使用 Promise + then() 層層嵌套同樣也能解決問題。但是,如果硬是要將這種嵌套的方式寫在循環中,還恐怕還需費一番周折。試問,有更好的辦法嗎?

異步同步化方案

試想,如果要去將一批數據發送到服務器,只有前一批發送成功(即服務器返回成功的響應),才開始下一批數據的發送,否則終止發送。這就是一個典型的 “for 循環中存在相互依賴的異步操作” 的例子。

明顯,這種 “串行” 的異步,實質上可以當成同步。它和亂序的異步比較起來,花費了更多的時間。按理說,我們希望程序異步執行,就是為了 “跳過” 阻塞,較少時間花銷。但與之相反的是,如果需要一系列的異步 “串行”,我們應該怎樣很好的進行編程?

對于這個 “串行” 異步,有了 ES6 就非常容易的解決了這個問題。

async function task () {
    for (let val of [1, 2, 3, 4]) {
        // await 是要等待響應的
        let result = await send(val);
        if (!result) {
            break;
        }
    }
}
task();

從字面上看,就是本次循環,等有了結果,再進行下一次循環。因此,循環每執行一次就會被暫停(“卡住”)一次,直到循環結束。這種編碼實現,很好的消除了層層嵌套的 “回調地獄” 問題,降低了認知難度。

這就是異步問題同步化的方案。關于這個方案,如果說 Promise 主要解決的是異步回調問題,那么 async + await 主要解決的就是將異步問題同步化,降低異步編程的認知負擔。

async + await “外異內同”

早先接觸這套 API 時,看著繁瑣的文檔,一知半解的認為 async + await 主要用來解決異步問題同步化的。

其實不然。從上面的例子看到:async 關鍵字聲明了一個 異步函數,這個 異步函數 體內有一行 await 語句,它告示了該行為同步執行,并且與上下相鄰的代碼是依次逐行執行的。

將這個形式化的東西再翻譯一下,就是:

1、async 函數執行后,總是返回了一個 promise 對象
2、await 所在的那一行語句是同步的

其中,1 說明了從外部看,task 方法執行后返回一個 Promise 對象,正因為它返回的是 Promise,所以可以理解task 是一個異步方法。毫無疑問它是這樣用的:

task().then((val) => {alert(val)})
      .then((val) => {alert(val)})

2 說明了在 task 函數內部,異步已經被 “削” 成了同步。整個就是一個執行稍微耗時的函數而已。

綜合 1、2,從形式上看,就是 “task 整體是一個異步函數,內部整個是同步的”,簡稱“外異內同”。

整體是一個異步函數 不難理解。在實現上,我們不妨逆向一下,語言層面讓async關鍵字調用時,在函數執行的末尾強制增加一個promise 反回:

async fn () {
    let result;
    // ...
    //末尾返回 promise
    return isPromise(result)? 
            result : Promise.resolve(undefined);
}

內部是同步的 是怎么做到的?實際上 await 調用,是讓后邊的語句(函數)做了一個遞歸執行,直到獲取到結果并使其 狀態 變更,才會 resolve 掉,而只有 resolve 掉,await 那一行代碼才算執行完,才繼續往下一行執行。所以,盡管外部是一個大大的 for 循環,但是整個 for 循環是依次串行的。

因此,僅從上述框架的外觀出發,就不難理解 async + await 的意義。使用起來也就這么簡單,反而 Promise 是一個必須掌握的基礎件。

秉承本次《重讀 ES6》系列的原則,不過多追求理解細節和具體實現過程。我們繼續鞏固一下這個 “形式化” 的理解。

async + await 的進一步理解

有這樣的一個異步操作 longTimeTask,已經用 Promise 進行了包裝。借助該函數進行一系列驗證。

const longTimeTask = function (time) {
  return new Promise((resolve, reject) => {
    setTimeout(()=>{
      console.log(`等了 ${time||'xx'} 年,終于回信了`);
      resolve({'msg': 'task done'});
    }, time||1000)
  })
}

async 函數的執行情況

如果,想查看 async exec1 函數的返回結果,以及 await 命令的執行結果:

const exec1 = async function () {
  let result = await longTimeTask();
  console.log('result after long time ===>', result);
}
// 查看函數內部執行順序
exec1();
// => 等了 xx 年,終于回信了
// => result after long time ===> Object {msg: "task done"}

//查看函數總體返回值
console.log(exec1());
// => Promise {[[PromiseStatus]]: "pending",...}
// => 同上

以上 2 步執行,清晰的證明了 exec1 函數體內是同步、逐行逐行執行的,即先執行完異步操作,然后進行 console.log() 打印。而 exec1() 的執行結果就直接是一個 Promise,因為它最先會蹦出來一串 Promise ...,然后才是 exec1 函數的內部執行日志。

因此,所有驗證,完全符合 整體是一個異步函數,內部整個是同步的 的總結。

await 如何執行其后語句?

回到 await ,看看它是如何執行其后邊的語句的。假設:讓 longTimeTask() 后邊直接帶 then() 回調,分兩種情況:
1)then() 中不再返回任何東西
2) then() 中繼續手動返回另一個 promise

const exec2 = async function () {
  let result = await longTimeTask().then((res) => {
    console.log('then ===>', res.msg);
    res.msg = `${res.msg} then refrash message`;
    // 注釋掉這條 return 或 手動返回一個 promise
    return Promise.resolve(res);
  });
  console.log('result after await ===>', result.msg);
}
exec2();
// => 情況一 TypeError: Cannot read property 'msg' of undefined
// => 情況二 正常

首先,longTimeTask() 加上再多得 then() 回調,也不過是放在了它的回調列隊 queue 里了。也就是說,await 命令之后始終是一條 表達式語句,只不過上述代碼書寫方式比較讓人迷惑。(比較好的實踐建議是,將 longTimeTask 方法身后的 then() 移入 longTimeTask 函數體封裝起來)

其次,手動返回另一個 promise 和什么也不返回,關系到 longTimeTask() 方法最終 resolve 出去的內容不一樣。換句話說,await 命令會提取其后邊的promiseresolve 結果,進而直接導致 result 的不同。

值得強調的是,await 命令只認 resolve 結果,對 reject 結果報錯。不妨用以下的 return 語句替換上述 return 進行驗證。

return Promise.reject(res);

最后

其實,關于異步編程還有很多可以梳理的,比如跨模塊的異步編程、異步的單元測試、異步的錯誤處理以及什么是好的實踐。All in all, 限于篇幅,不在此匯總了。最后,async + await 確實是一個很優雅的方案。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 異步編程對JavaScript語言太重要。Javascript語言的執行環境是“單線程”的,如果沒有異步編程,根本...
    呼呼哥閱讀 7,334評論 5 22
  • 弄懂js異步 講異步之前,我們必須掌握一個基礎知識-event-loop。 我們知道JavaScript的一大特點...
    DCbryant閱讀 2,750評論 0 5
  • 簡單介紹下這幾個的關系為方便起見 用以下代碼為例簡單介紹下這幾個東西的關系, async 在函數聲明前使用asyn...
    _我和你一樣閱讀 21,272評論 1 24
  • Promiese 簡單說就是一個容器,里面保存著某個未來才會結束的事件(通常是一個異步操作)的結果,語法上說,Pr...
    雨飛飛雨閱讀 3,380評論 0 19
  • Promise的含義: ??Promise是異步編程的一種解決方案,比傳統的解決方案——回調函數和事件——更合理和...
    呼呼哥閱讀 2,189評論 0 16