掌握 Node.js 中的 async/await

kfzs7850 7年前發布 | 36K 次閱讀 Node.js Node.js 開發

你會在本文中學到如何使用 async 函數(async/await) 來簡化回調,以及基于 Promise 的 Node.js 應用。

異步語言結構已經在其它語言中存在好一陣了,比如 C# 的 async/await,Kotlin 的協程(Coroutine) 以及 Go 中的 Goroutine。隨著 Node.js 8 的發布,期待已久的異步函數功能終于來臨。

在本教程結束的時候,你應該可以回答下面的問題:

Node.js 中的 async/await 是自發明面包切片以來最美好的事情嗎?

Node 中的異步函數是什么鬼?

異步函數聲明返回 AsyncFunction 對象。這在某種意義上來說與 Generator 相似——它們的執行可以被中止。唯一的不同之處在于他們異步函數總是返回 Promise 而不是 { value: any, done: Boolean } 對象。實際上,異步函數與你從 co 包中體驗到的功能非常相似。

在異步函數中你可以等待(await) 任何 Promise 或捕獲其拒絕(reject) 的原因。

因此,如果你有像下面這們用 Promise 實現的邏輯:

〔譯者注:文本中的代碼都根據譯者推薦的格式進行了格式化,這不影響代碼的意義〕

function handler(req, res) {
    return request("https://user-handler-service")
        .catch((err) => {
            logger.error("Http error", err);
            error.logged = true;
            throw err;
        })
        .then((response) => Mongo.findOne({ user: response.body.user }))
        .catch((err) => {
            !error.logged && logger.error("Mongo error", err);
            error.logged = true;
            throw err;
        })
        .then((document) => executeLogic(req, res, document))
        .catch((err) => {
            !error.logged && console.error(err);
            res.status(500).send();
        });
}

你可以使用 async/await 將其修改得更像在編寫同步代碼:

async function handler(req, res) {
    let response;
    try {
        response = await request("https://user-handler-service");
    } catch (err) {
        logger.error("Http error", err);
        return res.status(500).send();
    }

    let document;
    try {
        document = await Mongo.findOne({ user: response.body.user });
    } catch (err) {
        logger.error("Mongo error", err);
        return res.status(500).send();
    }

    executeLogic(document, req, res);
}

在較舊的 V8 中,如果沒有處理拒絕的 Promise,它只是悄悄地被丟棄了。現在至少你會從 Node 中得到一個警告,因此你不必為此擔心而去創建一個監聽程序。然而,如果你不能處理錯誤而讓應用處于一個未知的狀態,那么推薦的做法是讓你的應用崩潰掉:

process.on("unhandledRejection", (err) => {
    console.error(err);
    process.exit(1);
});

使用異步函數的模式

確實有一些案例,如果能像寫同步程序一樣方便的進行異步操作就好了。使用 Promise 和回調來處理它們會需要復雜的模式或引用其它庫。

這里有一些例子,需要在循環中異步獲取數據,或使用 if-else 條件。

通過指數補償進行重試

使用 Promise 實現重試邏輯非常笨拙:

function requestWithRetry(url, retryCount) {
    if (retryCount) {
        return new Promise((resolve, reject) => {
            const timeout = Math.pow(2, retryCount);

            setTimeout(() => {
                console.log("Waiting", timeout, "ms");
                _requestWithRetry(url, retryCount)
                    .then(resolve)
                    .catch(reject);
            }, timeout);
        });
    } else {
        return _requestWithRetry(url, 0);
    }
}

function _requestWithRetry(url, retryCount) {
    return request(url, retryCount)
        .catch((err) => {
            if (err.statusCode && err.statusCode >= 500) {
                console.log("Retrying", err.message, retryCount);
                return requestWithRetry(url, ++retryCount);
            }
            throw err;
        });
}

requestWithRetry("http://localhost:3000")
    .then((res) => {
        console.log(res);
    })
    .catch(err => {
        console.error(err);
    });

這個代碼看著就頭痛。我們可以使用 async/await 來重寫這段代碼,這樣的代碼會簡單得多。

function wait(timeout) {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve();
        }, timeout);
    });
}

async function requestWithRetry(url) {
    const MAX_RETRIES = 10;
    for (let i = 0; i <= MAX_RETRIES; i++) {
        try {
            return await request(url);
        } catch (err) {
            const timeout = Math.pow(2, i);
            console.log("Waiting", timeout, "ms");
            await wait(timeout);
            console.log("Retrying", err.message, i);
        }
    }
}

這樣的代碼更讓人賞心悅目,不是嗎?

中間值

這個例子不像之前的例子那么可怕,但是如果你有 3 個異步函數在順序上存在依賴關系,你恐怕不得不從幾個不太好看的解決辦法中選擇一個。

functionA 返回 Promise,而調用 functionB 需要那個值,然后 functionC 需要從 functionA 和 functionB 的 Promise 中帶回來的值。

辦法 1: .then 圣誕樹

function executeAsyncTask() {
    return functionA()
        .then((valueA) => {
            return functionB(valueA)
                .then((valueB) => {
                    return functionC(valueA, valueB);
                });
        });
}

在這個辦法中,我們通過 3 層代碼獲得 valueA ,同時從上一個 Promise 獲得 valueB 。我們不能讓這個圣誕樹扁平化,否則就不會形成閉包,在調用 functionC 的時候就拿不到 valueA 。

〔譯者注:翻譯過程中去掉了一些疑似廣告的鏈接〕

辦法 2:移到上層作用域

function executeAsyncTask() {
    let valueA;
    return functionA()
        .then((v) => {
            valueA = v;
            return functionB(valueA);
        })
        .then((valueB) => {
            return functionC(valueA, valueB);
        });
}

在圣誕樹中,我們使用了更高導的作用域使 valueA 有效。這里的情況類似,只不過我們現在把 valueA 定義在所有 .then 之外,然后我們可以將第一個 Promise 的確定值賦給它。

這個辦法當然有效,即扁平化了 .then 鏈又保持了正確的語義。然而,它也帶來了新的缺陷,比如 valueA 會在函數其它地方使用。我們需要使用兩個變量 —— valueA 和 v —— 它們是同一個值。

辦法 3:不必要的數組

function executeAsyncTask() {
    return functionA()
        .then(valueA => {
            return Promise.all([valueA, functionB(valueA)]);
        })
        .then(([valueA, valueB]) => {
            return functionC(valueA, valueB);
        });
}

把 valueA 與 functionB 產生的 Promise 一起放在數組中,當然是為了使樹扁平化。它們可能是完全不同的類型,所以它們根本不應該放在一個數組中的可能性非常大。

辦法 4:寫一個輔助函數

const converge = (...promises) => (...args) => {
    let [head, ...tail] = promises;
    if (tail.length) {
        return head(...args)
            .then((value) => converge(...tail)(...args.concat([value])));
    } else {
        return head(...args);
    }
};

functionA(2)
    .then((valueA) => converge(functionB, functionC)(valueA));

你當然可以耍點小聰明,寫一個輔助函數來隱藏上下文,但這樣的代碼會非常難讀,對于那些并不精通函數式編程技法的人來說,可能不太容易理解。

使用 async/await 就什么問題都沒有了:

async function executeAsyncTask() {
    const valueA = await functionA();
    const valueB = await functionB(valueA);
    return function3(valueA, valueB);
}

使用 async/await 處理多個并行請求

這與前面的例子相似。這里你是想同時執行幾個異步任務,并不同的地方使用它們的結果值,使用 async/await 很容易解決:

async function executeParallelAsyncTasks() {
    const [valueA, valueB, valueC]
        = await Promise.all([
            functionA(),
            functionB(),
            functionC()
        ]);
    doSomethingWith(valueA);
    doSomethingElseWith(valueB);
    doAnotherThingWith(valueC);
}

正如我們在這個示例中看到的,我們不需要把這些值移到上層作用域,也不需要創建毫無語義的數組來傳遞這些值。

數組迭代方法

你可以結合異步函數使用 map 、 filter 和 reduce ,不過它們的行為并不直觀。猜猜下面的腳本會在控制臺打印出什么:

  1. map
function asyncThing(value) {
    return new Promise((resolve, reject) => {
        setTimeout(() => resolve(value), 100);
    });
}

async function main() {
    return [1, 2, 3, 4].map(async (value) => {
        const v = await asyncThing(value);
        return v * 2;
    });
}

main()
    .then(v => console.log(v))
    .catch(err => console.error(err));
  1. filter
function asyncThing(value) {
    return new Promise((resolve, reject) => {
        setTimeout(() => resolve(value), 100);
    });
}

async function main() {
    return [1, 2, 3, 4].filter(async (value) => {
        const v = await asyncThing(value);
        return v % 2 === 0;
    });
}

main()
    .then(v => console.log(v))
    .catch(err => console.error(err));
  1. reduce
function asyncThing(value) {
    return new Promise((resolve, reject) => {
        setTimeout(() => resolve(value), 100);
    });
}

async function main() {
    return [1, 2, 3, 4].reduce(async (acc, value) => {
        return await acc + await asyncThing(value);
    }, Promise.resolve(0));
}

main()
    .then(v => console.log(v))
    .catch(err => console.error(err));

答案:

[ Promise { }, Promise { }, Promise { }, Promise { } ]
[ 1, 2, 3, 4 ]
10

如果你記錄 map 迭代過程中的返回值,你會看到我們期望的數組: [ 2, 4, 6, 8 ] 。唯一的問題在于,每個都被 AsyncFunction 封裝成了 Promise。

因此,如果你想得到正確的值,就需要用 Promise.all 對返回的數組進行解封。

main()
    .then(v => Promise.all(v))
    .then(v => console.log(v))
    .catch(err => console.error(err));

本來,你應該先等待 Promise 確定值,然后映射值:

function main() {
    return Promise.all([1, 2, 3, 4]
        .map((value) => asyncThing(value)));
}

main()
    .then(values => values.map((value) => value * 2))
    .then(v => console.log(v))
    .catch(err => console.error(err));

這樣看起來簡單一些,不是嗎?

如果在一個長時間運行的異步任務中,需要迭代一些一些長時間運行的同步邏輯,那么 async/await 仍然會非常有用。

這樣一來,只要有一個值你就可以開始計算 —— 不必等到所有 Promise 都解決了才開始計算。即使結果仍然被封裝在 Promise 中,它們仍然比順序執行快得多。

filter 呢?很明顯沒對...

很好,你猜到了:雖然返回值是 [ false, true, false, true ] ,但它們被封裝成 Promise,所以會從原來的數組取回所有值〔譯者注:因為 Promise 對象會被判為 true 〕。很不幸,要修正這個錯誤,就需要要確定所有值,然后再過濾。

〔譯者注:譯者饒有興趣的來補充了一個實現,貌似不簡單〕

// 譯者補充的實現 (只修改了 main 函數)
async function main() {
    const promises = [1, 2, 3, 4]
        .map(async value => {
            const v = await asyncThing(value);
            return {
                value: value,
                predicate: v % 2 === 0
            };
        });

    return (await Promise.all(promises))
        .filter(m => m.predicate)
        .map(m => m.value);
}

歸約(reduce)相當直接。但要記住,你需要用 Promise.resolve 封裝初始值,返回的積累值也會被封裝,需要等待( await )。

如果你希望使用函數式編程模式, async/await 可能不適合你。

.. 因為它的意圖明確地用于命令式編程模式。

為了讓 .then 鏈看起來更純粹,你可以使用 Ramda 的 pipeP composeP 函數.

重寫基于回調的 Node.js 應用程序

異步函數默認返回 Promise ,所以你可以重寫基于回調的函數,讓它們使用 Promise,然后等待( await ) 解決。你可以使用 Node.js 的 util.promisify 函數將基于回調的函數轉換為基于 Promise 的函數。

重寫基于 Promise 的應用程序

簡單的 .then 鏈可以直接升級,所以你立刻就能使用 async/await 。

function asyncTask() {
    return functionA()
        .then((valueA) => functionB(valueA))
        .then((valueB) => functionC(valueB))
        .then((valueC) => functionD(valueC))
        .catch((err) => logger.error(err));
}

可以改為

async function asyncTask() {
    try {
        const valueA = await functionA();
        const valueB = await functionB(valueA);
        const valueC = await functionC(valueB);
        return await functionD(valueC);
    } catch (err) {
        logger.error(err);
    }
}

使用 async/await 重寫 Node.js 應用

  • 如果你喜歡經典的 if-else 條件和 for/while 循環,

  • 如果你認同 try-catch 塊處理錯誤的方式,

你會很愉快的使用 async/await 來改寫服務。

正如我們看到的那樣,它可以讓某些模式更容易編寫也更容易閱讀,所以它肯定在某些情況下比 Promise.then() 鏈更合適。然而,如果你陷入了過去幾年的函數式編程熱,你可能想忽略這一語言特性。

那么你們都在想什么呢? async/await 是發明面包切片之后最好的事情,還是像 es2015 的 class 一樣有爭議呢?

你是否已經在生產中使用 async/await ,或者準備堅決不會碰它? 讓我們在下面的評論中討論吧。

 

來自:http://www.zcfy.cc/article/mastering-async-await-in-node-js-risingstack-4102.html

 

 本文由用戶 kfzs7850 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!