1. 程式人生 > >一個例子讀懂 JS 非同步程式設計: Callback / Promise / Generator / Async

一個例子讀懂 JS 非同步程式設計: Callback / Promise / Generator / Async

JS非同步程式設計實踐理解

回顧JS非同步程式設計方法的發展,主要有以下幾種方式:

  1. Callback
  2. Promise
  3. Generator
  4. Async

需求

顯示購物車商品列表的頁面,使用者可以勾選想要刪除商品(單選或多選),點選確認刪除按鈕後,將已勾選的商品清除購物車,頁面顯示剩餘商品。

為了便於本文內容闡述,假設後端沒有提供一個批量刪除商品的介面,所以對使用者選擇的商品列表,需要逐個呼叫刪除介面。

用一個定時器代表一次介面請求。那思路就是遍歷存放使用者已選擇商品的id陣列,逐個發起刪除請求del,待全部刪除完成後,呼叫獲取購物車商品列表的介面get

實現

let ids = [1, 2, 3] // 假設已選擇三個商品
let len = ids.length
let count = 0

let start // 便於後面計算執行時間

1. callback

傳統常規的寫法,如果是多個繼行任務就會陷入回撥地獄。比如此例中get作為del的回撥函式

let get = () => {
    setTimeout(() => {
        console.log(`get:${new Date() -start}ms`)
    }, 1000)
}

let del = (id, cb) => {
    setTimeout(() => {
        console.log(id)
        count++
        if (count === len) {
            cb()
        }
    }, 1000)
}

let confirmDel = () => {
    start = new Date()
    for (id of ids) {
        del(id, get)
    }
    console.log(`done:${new Date() -start}ms`)
}

confirmDel()

注意觀察和對比done的列印順序和get完成時間。
setTimeout是非同步執行的,沒有阻塞主流程的執行,所以done最先列印。
三個del任務是並行的,加上一個回撥執行時間,所以整個點選刪除按鈕事件耗時2秒左右

done:1ms
1
2
3
get:2007ms

2. Promise

let getP = () => {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            console.log(`get:${new Date() -start}ms`)
            resolve()
        }, 1000)
    })
}

let delP = (id, cb) => {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            console.log(id)
            count++
            if (count === len) {
                cb()
            }
            resolve()
        }, 1000)
    })
}

let confirmDelP = () => {
    start = new Date()
    for (id of ids) {
        delP(id, getP)
    }
    console.log(`done:${new Date() -start}ms`)
}

confirmDelP()

單純常用Promise寫法,看上去結構跟回撥寫法一樣,而且執行時間也一樣。

done:2ms
1
2
3
get:2007ms

但是,如果使用Promise.all方法,就能很好將併發任務(三個del)和繼發任務(get)區分開了,就是get不用嵌入回撥中了。

3. Promise.all

Promise物件then / catch / all / race / finally,以及resolve / reject更多內容請參閱MDN

let delP_1 = (id) => {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            console.log(id)
            resolve()
        }, 1000)
    })
}

let getP_1 = () => {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            console.log(`get:${new Date() -start}ms`)
            resolve()
        }, 1000)
    })
}

let confirmDelP_all = () => {
    start = new Date()
    let p_Arr = ids.map(id => delP_1(id))

    Promise.all(p_Arr)
        .then(() => {
            return getP_1()
        })
        .then(() => {
            console.log(`done:${new Date() -start}ms`)
        })
}
confirmDelP_all()

在這裡,程式碼的語義就很直觀了,先併發三個刪除del,全部成功後執行getget成功後done
注意看done的列印順序

1
2
3
get:2008ms
done:2010ms

4. Generator

Generator型別是一種特殊的函式,它擁有自己獨特的語法和方法屬性。比如函式名前加*,配合yield 返回非同步回撥結果, 通過next 傳入函式、next返回特殊的包含value和done屬性的物件等等,具體見MDN

Generator是一種惰性求值函式,執行一次next()才開啟一次執行,到yield又中斷,等待下一次next()。所以本人更喜歡叫它步進函式,非常適合執行繼發任務

假設現在每一個介面請求都是繼發任務,就是說只有當上一個請求成功後,才開始下一個請求。在實際的場景中,通常是當前請求需要使用上一個請求返回的結果資料。此時使用Generator函式是最好的方式。

let generator

let getG = () => {
    setTimeout(() => {
        console.log(`get:${new Date() -start}ms`)
        generator.next()
    }, 1000)
}

let delG = (id) => {
    setTimeout(() => {
        console.log(id)
        generator.next()
    }, 1000)
}

function *confimrDelG () {
    start = new Date()
    for (id of ids) {
        yield delG(id)
    }
    yield getG()
    console.log(`done:${new Date() -start}ms`)
}

generator = confimrDelG()
generator.next()
console.log('會被阻塞嗎?')

觀察列印的時間,四個非同步任務4秒左右。
注意"阻塞“文字最先列印

會被阻塞嗎?
1
2
3
get:4009ms
done:4011ms

我理解Generator就是一個用來裝載非同步繼發任務的容器,不阻塞容器外部流程,但是容器內部任務用yield設定斷點,用next步進執行,可以通過next向下一步任務傳值,或者直接使用yield返回的上一任務結果。

5. async / await

async 函式

我們先看MDN上關於async function怎麼說的:

When an async function is called, it returns a Promise. When the async function returns a value, the Promise will be resolved with the returned value. When the async function throws an exception or some value, the Promise will be rejected with the thrown value.

也就是說async函式會返回一個Promise物件。

  • 如果async函式中是return一個值,這個值就是Promise物件中resolve的值;
  • 如果async函式中是throw一個值,這個值就是Promise物件中reject的值。

例子顯示下,我們先用Promise寫法

function imPromise(num) {

  return new Promise(function (resolve, reject) {
    if (num > 0) {
      resolve(num);
    } else {
      reject(num);
    }
  })
}

imPromise(1).then(function (v) {
  console.log(v); // 1
})

imPromise(0).catch(function (v) {
  console.log(v); // 0
})

再用Async寫法

async function imAsync(num) {
  if (num > 0) {
    return num // 這裡相當於resolve(num)
  } else {
    throw num // 這裡相當於reject(num)
  }
}

imAsync(1).then(function (v) {
  console.log(v); // 1
});

// 注意這裡是catch
imAsync(0).catch(function (v) {
  console.log(v); // 0
})

所以理解Asyncnew Promise的語法糖也是這個原因。但要注意一點的是上面imPromise函式和imAsync函式呼叫返回的結果區別。

 `new Promise`生成的是一個`pending`狀態的`Promise`物件,而`async`返回的是一個`resolved`或`rejected`狀態的`Promise`物件,就是一個已經終結狀態的`promise`物件。理解這點,對下面的`await`理解很重要。
let p = imPromise(1)
console.log(p) // Promise { pending }
let a = imAsync(1)
console.log(a) // Promise { resolved }

await

再來看看MDN對於await是怎麼說的:

 An async function can contain an await expression, that pauses the execution of the async function and watis for the passed Promise's resolution, and then resumes the async function's execution and returns the resolved value.

await會暫停當前async函式的執行,等待後面的Promise的計算結果返回以後再繼續執行當前的async函式

  • await 等待什麼??

await等待一個Promise物件從pending狀態到resoled或rejected狀態的這段時間。

所以如果要實現中斷步進執行的效果,await後面接的必須是一個pedding狀態的promise物件,其它狀態的promise物件或非promise物件一概不等待。
這也是awaityield的區別(yield不管後面是什麼,執行完緊接著的表示式就中斷)。

async / await 解決了什麼問題

Promise解決callback巢狀導致回撥地獄的問題,但實際上並不徹底,還是在then中使用了回撥函式。而async / await使得非同步回撥在寫法上完成沒有,就像同步寫法一樣。
看個例子:

// callback
get((a) => {
    (a,b) => {
        (b,c) => {
            (c,d) => {
                (d,e) => {
                    console.log(e)
                }
            }
        }
    }
})
// promise
get()
    .then(a => p1(a))
    .then(b => p1(b))
    .then(c => p1(c))
    .then(d => p1(d))
    .then(e => {console.log(e)})
// async / await
(async (a) => {
    const b = await A(a);
    const c = await A(b);
    const d = await A(c);
    const e = await A(d);
    console.log(e)

})()

async / await 實現繼發任務

我們用async / await改寫上面Generator的例子

let delP_1 = (id) => {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            console.log(id)
            resolve()
        }, 1000)
    })
}

let getP_1 = () => {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            console.log(`get:${new Date() -start}ms`)
            resolve()
        }, 1000)
    })
}

async function confimrDelAsync () {
    start = new Date()
    for (id of ids) {
        await delP_1(id)
    }
    await getP_1()
    console.log(`done:${new Date() -start}ms`)
}

confimrDelAsync()
console.log('被阻塞了嗎?')

列印結果基本跟generator一樣。但在語義上更明確。

被阻塞了嗎?
1
2
3
get:4014ms
done:4016ms

async / await 實現併發任務

let delP_1 = (id) => {
    setTimeout(() => {
        console.log(id)
    }, 1000)
}

let getP_1 = () => {
    setTimeout(() => {
        console.log(`get:${new Date() -start}ms`)
    }, 1000)
}

async function confimrDelAsync () {
    start = new Date()
    for (id of ids) {
        await delP_1(id)
    }
    await getP_1()
    console.log(`done:${new Date() -start}ms`)
}

confimrDelAsync()
console.log('被阻塞了嗎?')

不返回Promise物件,或者使promise物件處理resoled狀態,就可以不執行等待。但這樣的寫法跟直接用同步方式寫一樣,所以並不推薦,顯得多此一舉。

done:4ms
1
2
3
get:1009ms

async / await 實現併發和繼發的混合任務

如果事件函式中併發任務和繼發任務都有,此時使用async / await才是最好的解決方式。其中的併發任務用promise.all實現,因為它返回的正是await可用的pending狀態的Promise物件。

let delP_1 = (id) => {
    setTimeout(() => {
        console.log(id)
        resolve()
    }, 1000)
}

let getP_1 = () => {
    setTimeout(() => {
        console.log(`get:${new Date() -start}ms`)
        resolve()
    }, 1000)
}

async function confimrDelAsync_all () {
    start = new Date()

    let p_Arr = ids.map(id => delP_1(id))

    await Promise.all(p_Arr)
    await getP_1()
    console.log(`done:${new Date() -start}ms`)
}
confimrDelAsync_all()
console.log('被阻塞了嗎?')

觀察時間是繼發任務的一半。且不阻塞主流程。

被阻塞了嗎?
1
2
3
get:2009ms
done:2010ms

所以說asyncpromise的語法糖,但是函式返回的promise的狀態是不一樣的。說awaityield的語法糖,但是await只能接受pending狀態的promise物件
async可以單獨使用,await不能單獨使用,只能在async函式體內使用

所以針對開頭的需求:

顯示購物車商品列表的頁面,使用者可以勾選想要刪除商品(單選或多選),點選確認刪除按鈕後,將已勾選的商品清除購物車,頁面顯示剩餘商品。

最好的解決方案是:
 `promise.all` 與 `async / await`結合

其次是:
 `promise.all`

在實際專案中還應該加上捕獲錯誤的程式碼。
async / await中結合try...catch
promise中,因為錯誤具有冒泡以性質,所以在結尾加上.catch即可。

尾聲

文章只是自己的一個併發和繼發混合需求引發的知識總結。但JS程式設計還有很多內容,包括非同步事件、事件迴圈(瀏覽器和nodejs區別)、非同步任務錯誤的捕獲、promise/generator/async具體API細節等。還需要繼續學習。

參考連結

https://blog.csdn.net/ken_ding/article/details/81201248
https://segmentfault.com/a/1190000009070711?from=timeline&isappinstalled=0#articleHeader5
《Javascript ES6 函數語言程式設計入門指南》 第10章 使用Genera