【譯】async 的非同步操作模式

是方旭啊發表於2019-10-29

原文連結:careersjs.com/magazine/as…

原文作者:Joe Zimmerman

譯文地址:【譯】async 的非同步操作模式

基礎筆記的github地址:github.com/qiqihaobenb… ,可以watch,也可以star。


我還記得以前執行非同步操作需要在越來越深的回撥地獄中使用回撥的那些“好日子”。雖然回撥地獄並沒有完全成為過去,但是使用 Promise 來代替回撥的巢狀已經顯得簡單多了。

我依稀記得 Promise 成為主流時的那些美好時光,我之前使用 jQuery 的 Deferred,後來 Promise 普及之後,我才在工作中把 Promise 庫加到我們的專案中。那時我們已經有 Babel 了,所以我們甚至不需要再加一個 Promise 庫。

無論如何,Promise 在很大程度上實現了它的承諾,使得非同步程式設計更加易於管理。如果關於 Promise 的用法你還不是很熟,可以在這裡看到更多關於 Promise 的知識。當然,Promise 也有自己的弱點。很多時候,您要麼需要巢狀 Promise ,要麼需要將變數傳遞到外部,因為你需要的一些資料只在 Promise 的 handler 中可用。例如:

function getVals () {
    return doSomethingAsync().then(function (val) {
        return doAnotherAsync(val).then(function (anotherVal) {
            // 這裡我們需要val和anotherVal,所以我們巢狀了
            return val + anotherVal
        })
    })
}

// 或者...

function getVals () {
    let value

    return doSomethingAsync().then(function (val) {
        // 把 val 賦給最外面的 value,這樣其他地方都能拿到了
        value = val
        return doAnotherAsync(val)
    }).then(function (anotherVal) {
        // 這裡我們獲取最外層的 value
        return value + anotherVal
    })
}
複製程式碼

這兩個例子本身都很混亂。當然,可以使用箭頭函式使他們更有條理

function getVals () {
    return doSomethingAsync().then(val => doAnotherAsync(val).then(anotherVal => val + anotherVal))
}

// 或者...

function getVals () {
    let value

    return doSomethingAsync()
    .then(val => (value = val, doAnotherAsync(val)))
    .then(anotherVal => value + anotherVal)
}
複製程式碼

上面的程式碼可能會清除一些語法上的問題,但是並不能帶來更好的可讀性。好在我們已經度過了那些“好日子”,現在我們有了 asyncawait,我們可以避免所有的那些廢話。

async function getVals () {
    let val = await doSomethingAsync()
    let anotherVal = await doAnotherAsync(val)

    return val + anotherVal
}
複製程式碼

這看起來既簡單,又容易理解。我假設你已經對 asyncawait很熟悉了,所以,我不打算介紹太多關於他們的細節。不過,你可以去 MDN 上覆習更多的 async/await。在這裡,我們將重點介紹過去在 Promise 中使用的模式,以及這些模式如何轉換為 async/await

“隨機”序列非同步操作

在前面的程式碼片段中,我們在技術上已經討論了一個模式——隨機序列操作——這是我們首先要討論的。我說的隨機,並不是真的隨機。我指的是多個函式,它們可能彼此相關,也可能不相關,但它們是分別呼叫的。換句話說,隨機操作與在整個輸入列表/輸入陣列上執行的操作不同。如果你仍然感到困惑,你會在後面的章節中明白我說的意思,當我轉到非隨機操作時,你會看到區別。

總之,就像我說的,你已經用我們所說的第一個模式來實現了例子。這些操作是按順序執行的,這意味著第二個操作要等到第一個操作完成後才能啟動。這個模式可能與上面的示例不同,在使用 Promise 時,假設我們不會遇到前面的情況,即需要將多個值傳遞給後續操作:

function getVals () {
    return doSomethingAsync()
    .then(val => doAnotherAsync(val))
    .then(anotherVal => /* 在這裡,我們不需要val */ 2 * anotherVal)
}
複製程式碼

跟最開始的 Promise 的例子相比,不需要在最終的 handler 中訪問 val ,因此我們只需鏈式呼叫 then 即可,而不必費心將值傳遞給外部作用域。但是酷炫的是,在 async/await 程式碼版本中,我們除了把上面第一個 async/await 例子中,最後表示式的 val + 換成 2 *外,其他的什麼都用改:

async function getVals () {
    let val = await doSomethingAsync()
    let anotherVal = await doAnotherAsync(val)

    return 2 * anotherVal
}
複製程式碼

這就是 async/await 擅長做的:把一個非同步呼叫行為模擬成同步的,沒有什麼小把戲,只是簡單的用“先做這個然後做那個”實現程式碼。

“隨機”並行非同步操作

好了,這次我們看一下並行執行的操作,這些操作都不關心其他操作是否已經完成,也不依賴於其他操作產生的 value。當用 Promise 時,可以這樣寫(忽略這樣一個事實:我重用了之前的非同步函式名 getVals ,但是它們的使用方式完全不同;它們的函式名已經很明顯表示它們是非同步的;它們不一定是前面示例中使用的函式):

function getVals () {
    return Promise.all([doSomethingAsync(), doAnotherAsync()])
    .then(function ([val, anotherVal]) {
        return val + anotherVal
    })
}
複製程式碼

我們使用 Promise.all 是因為它允許我們傳遞任意數量的 Promise,並且會一起執行完,通過一個 then 函式把所有結果返回給我們。Promise 還有其他方法,例如 Promise.anyPromise.some 等,究竟選哪個,這取決於你是否使用 Promise 庫或某些 Babel 外掛,當然還取決於你的用例以及你要如何處理輸出或被拒絕的可能(reject)。在任何情況下,模式都非常相似,你只需選擇一個不同的 Promise 方法,就會得到不同的結果。

async/await 不允許我們脫離 Promise.all 或其組成部分來使用是把雙刃劍。不好的是,async/await 在後臺隱藏了對 Promise 的使用,但是我們需要顯式地使用 Promise 才能並行執行操作。好的一面是,這意味著我們不用學習任何新東西,我們只要在以前使用的基礎上刪掉傳遞給 then 回撥的額外引數就行。然後,我們使用 await 假裝我們的並行操作都是瞬間完成。

async function getVals () {
    let [val, anotherVal] = await Promise.all([doSomethingAsync(), doAnotherAsync()])
    return val + anotherVal
}
複製程式碼

因此,async/await 不僅僅是刪除回撥和不必要的巢狀,更重要的是,它使非同步程式設計模式看起來更像同步程式設計模式,這樣程式碼對開發人員就會更友好。

迭代並行非同步操作

這裡的操作不是“隨機”的了。在這裡,我們對一組值進行迭代,並對每個值執行相同的非同步操作。在這個並行版本中,每個元素都是同時處理的。

Promise 實現如下:

function doAsyncToAll (values /* array */) {
    return Promise.all(values.map(doSomethingAsync))
}
複製程式碼

就這麼簡單, 用 async/await 應該怎麼做呢?其實你什麼都不用做。非要做點什麼的話,只會讓程式碼更冗長。

async function doAsyncToAll (values /* array */) {
    return await Promise.all(values.map(doSomethingAsync))
}
複製程式碼

看上去除了新增幾個假裝使你看起來很聰明並使用現代 JavaScript 的關鍵字外,其他的毛用沒有。但實際上,你新增這幾個關鍵字沒有任何價值,反而還會導致 JavaScript 引擎可能會執行得更慢。但是,如果你的程式碼更復雜一些, async/await 肯定可以提供一些好處:

function doAsyncToAll (values /* array */) {
    return Promise.all(values.map(val => {
        return doSomethingAsync(val)
        .then(anotherVal => doAnotherAsync(anotherValue * 2))
    }))
}
複製程式碼

雖然上面的程式碼看著也還行,但是 async/await 更簡潔條理。

function doAsyncToAll (values /* array */) {
    return Promise.all(values.map(async val => {
        let anotherVal = await doSomethingAsync(val)
        return doAnotherAsync(anotherValue * 2)
    }))
}
複製程式碼

就我個人而言,我認為這很清楚,至少從回撥內部可以進行對映,但是這裡有些人可能會感到困惑。當我第一次開始使用 async/await 時,我在回撥中看到await,這讓我認為這些回撥沒有並行觸發。這是人們在巢狀函式中使用 async/await 時經常會犯的一個錯誤,並且是與直接使用 Promise 相比, async/await 顯得可能不那麼容易理解的例項。但是,當你使用巢狀非同步函式時,稍微暴露一下可以幫助你更容易地發現問題,因此它們的內部函式與外部函式是分離的,並且 await 不會暫停外部函式。

緊接上文,一旦在你的函式中增加更多的步驟,閱讀 Promise 的複雜度也會上升,使用 async/await 就會顯得更有效果。

function doAsyncToAll (values /* array */) {
    return Promise.all(values.map(val => {
        return doSomethingAsync(val)
        .then(anotherVal => doAnotherAsync(anotherValue * 2))
    }))
    .then(newValues => newValues.join(','))
}
複製程式碼

這些不同層級的 then 呼叫真的會讓一個人的頭腦混亂,所以讓我們用更現代的方式來實現它:

async function doAsyncToAll (values /* array */) {
    const newValues = await Promise.all(values.map(async val => {
        let anotherVal = await doSomethingAsync(val)
        return doAnotherAsync(anotherValue * 2)
    }))
    return newValues.join(',')
}
複製程式碼

以往來看,還有其他方法可以解決這種情況,但解決並不是最終目標:可讀性和可維護性才是最重要的,通常這是 async/await 最方便的地方。編寫通常也更簡單,因為我們就是按照以前那種同步來寫的。

迭代序列非同步操作

我們回到最後的模式。我們再次遍歷一個列表,並對列表中的每項進行非同步操作,但是這次,我們同一時間只執行一個操作。換句話說,在我們完成對第一項的操作之前,不能對第二項進行任何操作。

function doAsyncToAllSequentially (values) {
    return values.reduce((previousOperation, val) => {
        return previousOperation.then(() => doSomethingAsync(val))
    }, Promise.resolve())
}
複製程式碼

為了按照順序執行,我們需要將 then 的呼叫鏈起來,前一個操作生成後一個操作。這可以通過reduce實現,也是最合理的方式。請注意,你需要傳遞一個 resolved 的 Promise 作為最後一個 reduce 的引數,這樣第一次迭代就可以觸發後面一連串的呼叫了。

在這裡,我們將再次看到 async/await 耀眼的光芒,我們不需要像是 reduce 這樣的任何陣列方法,只需要一個普通的迴圈,然後在迴圈中使用 await

async function doAsyncToAllSequentially (values) {
    for (let val of values) {
        await doSomethingAsync(val)
    }
}
複製程式碼

如果你使用 reduce 的原因不僅僅是為了序列操作,那麼你仍然可以繼續使用。例如,如果你打算把所有操作的結果相加

function doAsyncToAllSequentially (values) {
    return values.reduce((previousOperation, val) => {
        return previousOperation.then(
            total => doSomethingAsync(val).then(
                newVal => total + newVal
            )
        )
    }, Promise.resolve(0))
}
複製程式碼

上面的程式碼只會讓我頭腦混亂。令人驚訝的是,即使使用了 Promise ,我們也沒有防止回撥地獄的重現。即使我們使用了箭頭函式,我們可以總是在一行內寫完程式碼,但是這並沒有讓程式碼變得好理解。但是,使用 async/await 就可以讓程式碼更簡潔條理:

async function doAsyncToAllSequentially (values) {
    let total = 0
    for (let val of values) {
        let newVal = await doSomethingAsync(val)
        total += newVal
    }
    return total
}
複製程式碼

如果使用 async/await 時,你仍然喜歡在將陣列單個值合併時使用 reduce ,那也可以參照下面的程式碼 :

async function doAsyncToAllSequentially (values) {
    return values.reduce(async (previous, val) => {
        let total = await previous
        let newVal = await doSomethingAsync(val)

        return total + newVal
    }, Promise.resolve(0))
}
複製程式碼

總結

在編寫非同步程式碼時,相對較新的 async/await 關鍵字確實改變了編碼體驗。它們幫助我們消除或減弱了長期困擾 JavaScript 開發人員的非同步程式碼編寫和閱讀方面的問題:回撥地獄。讓我們能夠以一種更容易理解的方式讀寫非同步程式碼。因此,瞭解如何有效地使用這項新技術是很重要的,我希望以上這些模式對你有所幫助。

相關文章