一個例子讀懂 JS 非同步程式設計: Callback / Promise / Generator / Async
JS非同步程式設計實踐理解
回顧JS非同步程式設計方法的發展,主要有以下幾種方式:
- Callback
- Promise
- Generator
- 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
,全部成功後執行get
,get
成功後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
})
所以理解Async
為new 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
物件一概不等待。
這也是await
和yield
的區別(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
所以說async
是promise
的語法糖,但是函式返回的promise
的狀態是不一樣的。說await
是yield
的語法糖,但是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