本文首發於本人部落格
ES6非同步方式全面解析
眾所周知JS是單執行緒的,這種設計讓JS避免了多執行緒的各種問題,但同時也讓JS同一時刻只能執行一個任務,若這個任務執行時間很長的話(如死迴圈),會導致JS直接卡死,在瀏覽器中的表現就是頁面無響應,使用者體驗非常之差。
因此,在JS中有兩種任務執行模式:同步(Synchronous)和非同步(Asynchronous)。類似函式呼叫、流程控制語句、表示式計算等就是以同步方式執行的,而非同步主要由setTimeout/setInterval
、事件實現。
傳統的非同步實現
作為一個前端開發者,無論是瀏覽器端還是Node,相信大家都使用過事件吧,通過事件肯定就能想到回撥函式,它就是實現非同步最常用、最傳統的方式。
不過要注意,不要以為回撥函式就都是非同步的,如ES5的陣列方法Array.prototype.forEach((ele) => {})
等等,它們也是同步執行的。回撥函式只是一種處理非同步的方式,屬於函數語言程式設計中高階函式的一種,並不只在處理非同步問題中使用。
舉個例子?:
// 最常見的ajax回撥
this.ajax(`/path/to/api`, {
params: params
}, (res) => {
// do something...
})
複製程式碼
你可能覺得這樣並沒有什麼不妥,但是若有多個ajax或者非同步操作需要依次完成呢?
this.ajax(`/path/to/api`, {
params: params
}, (res) => {
// do something...
this.ajax(`/path/to/api`, {
params: params
}, (res) => {
// do something...
this.ajax(`/path/to/api`, {
params: params
}, (res) => {
// do something...
})
...
})
})
複製程式碼
回撥地獄就出現了。。。?
為了解決這個問題,社群中提出了Promise方案,並且該方案在ES6中被標準化,如今已廣泛使用。
Promise
使用Promise的好處就是讓開發者遠離了回撥地獄的困擾,它具有如下特點:
-
物件的狀態不受外界影響:
- Promise物件代表一個非同步操作,有三種狀態:Pending(進行中)、Resolved(已完成,又稱 Fulfilled)和Rejected(已失敗)。
- 只有非同步操作的結果,可以決定當前是哪一種狀態,任何其他操作都無法改變這個狀態。
-
一旦狀態改變,就不會再變,任何時候都可以得到這個結果。
- Promise物件的狀態改變,只有兩種可能:從Pending變為Resolved和從Pending變為Rejected。
- 只要這兩種情況發生,狀態就凝固了,不會再變了,會一直保持這個結果。
- 如果改變已經發生了,你再對Promise物件新增回撥函式,也會立即得到這個結果。
- 這與事件(Event)完全不同,事件的特點是,如果你錯過了它,再去監聽,是得不到結果的。
-
一旦宣告Promise物件(new Promise或Promise.resolve等),就會立即執行它的函式引數,若不是函式引數則不會執行
上面的程式碼可以改寫成如下:
this.ajax(`/path/to/api`, {
params: params
}).then((res) => {
// do something...
return this.ajax(`/path/to/api`, {
params: params
})
}).then((res) => {
// do something...
return this.ajax(`/path/to/api`, {
params: params
})
})
...
複製程式碼
看起來就直觀多了,就像一個鏈條一樣將多個操作依次串了起來,再也不用擔心回撥了~?
同時Promise還有許多其他API,如Promise.all
、Promise.race
、Promise.resolve/reject
等等(可以參考阮老師的文章),在需要的時候配合使用都是極好的。
API無需多說,不過這裡我總結了一下自己之前使用Promise踩到的坑以及我對Promise理解不夠透徹的地方,希望也能幫助大家更好地使用Promise:
-
then的返回結果:
- 如果
then
方法中返回了一個值,那麼返回一個“新的”resolved的Promise,並且resolve回撥函式的引數值是這個值 - 如果
then
方法中丟擲了一個異常,那麼返回一個“新的”rejected狀態的Promise - 如果
then
方法返回了一個未知狀態(pending)的Promise新例項,那麼返回的新Promise就是未知狀態 - 如果
then
方法沒有返回值時,那麼會返回一個“新的”resolved的Promise,但resolve回撥函式沒有引數
我之前天真的以為
then
要想鏈式呼叫,必須要手動返回一個新的Promise才行Promise.resolve(`first promise`) .then((data) => { // return Promise.resolve(`next promise`) // 實際上兩種返回是一樣的 return `next promise` }) .then((data) => { console.log(data) }) 複製程式碼
- 如果
-
一個Promise可設定多個then回撥,會按定義順序執行,如下
const p = new Promise((res) => { res(`hahaha`) }) p.then(console.log) p.then(console.warn) 複製程式碼
這種方式與鏈式呼叫不要搞混,鏈式呼叫實際上是then方法返回了新的Promise,而不是原有的,可以驗證一下:
const p1 = Promise.resolve(123) const p2 = p1.then(() => { console.log(p1 === p2) // false }) 複製程式碼
-
then
或catch
返回的值不能是當前promise本身,否則會造成死迴圈:const promise = Promise.resolve() .then(() => { return promise }) 複製程式碼
-
then
或者catch
的引數期望是函式,傳入非函式則會發生值穿透:Promise.resolve(1) .then(2) .then(Promise.resolve(3)) .then(console.log) // 1 複製程式碼
-
process.nextTick
和promise.then
都屬於microtask,而setImmediate
、setTimeout
屬於macrotaskprocess.nextTick(() => { console.log(`nextTick`) }) Promise.resolve() .then(() => { console.log(`then`) }) setImmediate(() => { console.log(`setImmediate`) }) console.log(`end`) // end nextTick then setImmediate 複製程式碼
有關microtask及macrotask可以看這篇文章,講得很細緻。
但Promise也存在弊端,那就是若步驟很多的話,需要寫一大串.then()
,儘管步驟清晰,但是對於我們這些追求極致優雅的前端開發者來說,程式碼全都是Promise的API(then
、catch
),操作的語義太抽象,還是讓人不夠滿意呀~
Generator
Generator是ES6規範中對協程的實現,但目前大多被用於非同步模擬同步上了。
執行它會返回一個遍歷器物件,而每次呼叫next
方法則將函式執行到下一個yield
的位置,若沒有則執行到return或末尾。
依舊是不再贅述API,對它還不瞭解的可以查閱阮老師的文章。
通過Generator實現非同步:
function* main() {
const res = yield getData()
console.log(res)
}
// 非同步方法
function getData() {
setTimeout(() => {
it.next({
name: `yuanye`,
age: 22
})
}, 2000)
}
const it = main()
it.next()
複製程式碼
先不管下面的next
方法,單看main
方法中,getData
模擬的非同步操作已經看起來很像同步了。但是追求完美的我們肯定是無法忍受每次還要手動呼叫next
方法來繼續執行流程的,為此TJ大神為社群貢獻了co模組來自動化執行Generator,它的實現原理非常巧妙,原始碼只有短短的200多行,感興趣可以去研究下。
const co = require(`co`)
co(function* () {
const res1 = yield [`step-1`]
console.log(res1)
// 若yield後面返回的是promise,則會等待它resolved後繼續執行之後的流程
const res2 = yield new Promise((res) => {
setTimeout(() => {
res(`step-2`)
}, 2500)
})
console.log(res2)
return `end`
}).then((data) => {
console.log(`end: ` + data)
})
複製程式碼
這樣就讓非同步的流程完全以同步的方式展示出來啦?~
Async/Await
ES7標準中引入的async函式,是對js非同步解決方案的進一步完善,它有如下特點:
- 內建執行器:不用像generator那樣反覆呼叫next方法,或者使用co模組,呼叫即會自動執行,並返回結果
- 返回Promise:generator返回的是iterator物件,因此還不能直接用
then
來指定回撥 - await更友好:相比co模組約定的generator的yield後面只能跟promise或thunk函式或者物件及陣列,await後面既可以是promise也可以是任意型別的值(Object、Number、Array,甚至Error等等,不過此時等同於同步操作)
進一步說,async函式完全可以看作多個非同步操作,包裝成的一個Promise物件,而await命令就是內部then命令的語法糖。
改寫後程式碼如下:
async function testAsync() {
const res1 = await new Promise((res) => {
setTimeout(() => {
res(`step-1`)
}, 2000)
})
console.log(res1)
const res2 = await Promise.resolve(`step-2`)
console.log(res2)
const res3 = await new Promise((res) => {
setTimeout(() => {
res(`step-3`)
}, 2000)
})
console.log(res3)
return [res1, res2, res3, `end`]
}
testAsync().then((data) => {
console.log(data)
})
複製程式碼
這樣不僅語義還是流程都非常清晰,即便是不熟悉業務的開發者也能一眼看出哪裡是非同步操作。
總結
本文彙總了當前主流的JS非同步解決方案,其實沒有哪一種方法最好或不好,都是在不同的場景下能發揮出不同的優勢。而且目前都是Promise與其他兩個方案配合使用的,所以不存在你只學會async/await或者generator就可以玩轉非同步。沒準以後又會出現一個新的方案,將已有的這幾種方案顛覆呢 ~
在這不斷變化、發展的時代,我們前端要放開自己的眼界,擁抱變化,持續學習,才能成長,寫出優質的程式碼?~