Promise、Generator、Async有什麼區別?

前端南玖發表於2022-02-08

前言

我們知道PromiseAsync/await函式都是用來解決JavaScript中的非同步問題的,從最開始的回撥函式處理非同步,到Promise處理非同步,到Generator處理非同步,再到Async/await處理非同步,每一次的技術更新都使得JavaScript處理非同步的方式更加優雅,從目前來看,Async/await被認為是非同步處理的終極解決方案,讓JS的非同步處理越來越像同步任務。非同步程式設計的最高境界,就是根本不用關心它是不是非同步

如果這篇文章有幫助到你,❤️關注+點贊❤️鼓勵一下作者,文章公眾號首發,關注 前端南玖 第一時間獲取最新的文章~

非同步解決方案的發展歷程

1.回撥函式

從早期的Javascript程式碼來看,在ES6誕生之前,基本上所有的非同步處理都是基於回撥函式函式實現的,你們可能會見過下面這種程式碼:

ajax('aaa', () => {
    // callback 函式體
    ajax('bbb', () => {
        // callback 函式體
        ajax('ccc', () => {
            // callback 函式體
        })
    })
})

沒錯,在ES6出現之前,這種程式碼可以說是隨處可見。它雖然解決了非同步執行的問題,可隨之而來的是我們常聽說的回撥地獄問題:

  • 沒有順序可言:巢狀函式執行帶來的是除錯困難,不利於維護與閱讀
  • 耦合性太強:一旦某一個巢狀層級有改動,就會影響整個回撥的執行

所以,為了解決這個問題,社群最早提出和實現了Promise,ES6將其寫進了語言標準,統一了用法。

2.Promise

Promise 是非同步程式設計的一種解決方案,比傳統的解決方案——回撥函式和事件——更合理和更強大。它就是為了解決回撥函式產生的問題而誕生的。

有了Promise物件,就可以將非同步操作以同步操作的流程表達出來,避免了層層巢狀的回撥函式。此外,Promise物件提供統一的介面,使得控制非同步操作更加容易。

所以上面那種回撥函式的方式我們可以改成這樣:(前提是ajax已用Promise包裝)

ajax('aaa').then(res=>{
  return ajax('bbb')
}).then(res=>{
  return ajax('ccc')
})

通過使用Promise來處理非同步,比以往的回撥函式看起來更加清晰了,解決了回撥地獄的問題,Promisethen的鏈式呼叫更能讓人接受,也符合我們同步的思想。

但Promise也有它的缺點:

  • Promise的內部錯誤使用try catch捕獲不到,只能只用then的第二個回撥或catch來捕獲
let pro
try{
    pro = new Promise((resolve,reject) => {
        throw Error('err....')
    })
}catch(err){
    console.log('catch',err) // 不會列印
}
pro.catch(err=>{
    console.log('promise',err) // 會列印
})
  • Promise一旦新建就會立即執行,無法取消

之前寫過一篇從如何使用到如何實現一個Promise,講解了Promise如何使用以及內部實現原理。對Promise還不太理解的同學可以看看~

3.Generator

Generator 函式是 ES6 提供的一種非同步程式設計解決方案,語法行為與傳統函式完全不同。Generator 函式將 JavaScript 非同步程式設計帶入了一個全新的階段。

宣告

與函式宣告類似,不同的是function關鍵字與函式名之間有一個星號,以及函式體內部使用yield表示式,定義不同的內部狀態(yield在英語裡的意思就是“產出”)。

function* gen(x){
 const y = yield x + 6;
 return y;
}
// yield 如果用在另外一個表示式中,要放在()裡面
// 像上面如果是在=右邊就不用加()
function* genOne(x){
  const y = `這是第一個 yield 執行:${yield x + 1}`;
 return y;
}

執行

const g = gen(1);
//執行 Generator 會返回一個Object,而不是像普通函式返回return 後面的值
g.next() // { value: 7, done: false }
//呼叫指標的 next 方法,會從函式的頭部或上一次停下來的地方開始執行,直到遇到下一個 yield 表示式或return語句暫停,也就是執行yield 這一行
// 執行完成會返回一個 Object,
// value 就是執行 yield 後面的值,done 表示函式是否執行完畢
g.next() // { value: undefined, done: true }
// 因為最後一行 return y 被執行完成,所以done 為 true

呼叫 Generator 函式後,該函式並不執行,返回的也不是函式執行結果,而是一個指向內部狀態的指標物件,也就是遍歷器物件(Iterator Object)。下一步,必須呼叫遍歷器物件的next方法,使得指標移向下一個狀態。

所以上面的回撥函式又可以寫成這樣:

function *fetch() {
    yield ajax('aaa')
    yield ajax('bbb')
    yield ajax('ccc')
}
let gen = fetch()
let res1 = gen.next() // { value: 'aaa', done: false }
let res2 = gen.next() // { value: 'bbb', done: false }
let res3 = gen.next() // { value: 'ccc', done: false }
let res4 = gen.next() // { value: undefined, done: true } done為true表示執行結束

由於 Generator 函式返回的遍歷器物件,只有呼叫next方法才會遍歷下一個內部狀態,所以其實提供了一種可以暫停執行的函式。yield表示式就是暫停標誌。

遍歷器物件的next方法的執行邏輯如下。

(1)遇到yield表示式,就暫停執行後面的操作,並將緊跟在yield後面的那個表示式的值,作為返回的物件的value屬性值。

(2)下一次呼叫next方法時,再繼續往下執行,直到遇到下一個yield表示式。

(3)如果沒有再遇到新的yield表示式,就一直執行到函式結束,直到return語句為止,並將return語句後面的表示式的值,作為返回的物件的value屬性值。

(4)如果該函式沒有return語句,則返回的物件的value屬性值為undefined

yield表示式本身沒有返回值,或者說總是返回undefinednext方法可以帶一個引數,該引數就會被當作上一個yield表示式的返回值。

怎麼理解這句話?我們來看下面這個例子:

function* foo(x) {
  var y = 2 * (yield (x + 1));
  var z = yield (y / 3);
  return (x + y + z);
}

var a = foo(5);
a.next() // Object{value:6, done:false}
a.next() // Object{value:NaN, done:false}
a.next() // Object{value:NaN, done:true}

var b = foo(5);
b.next() // { value:6, done:false }
b.next(12) // { value:8, done:false }
b.next(13) // { value:42, done:true }

由於yield沒有返回值,所以(yield(x+1))執行後的值是undefined,所以在第二次執行a.next()是其實是執行的2*undefined,所以值是NaN,所以下面b的例子中,第二次執行b.next()時傳入了12,它會當成第一次b.next()的執行返回值,所以b的例子中能夠正確計算。這裡不能把next執行結果中的value值與yield返回值搞混了,它兩不是一個東西

yield與return的區別

相同點:

  • 都能返回語句後面的那個表示式的值
  • 都可以暫停函式執行

區別:

  • 一個函式可以有多個 yield,但是隻能有一個 return
  • yield 有位置記憶功能,return 沒有

4.Async/await

Async/await其實就是上面Generator的語法糖,async函式其實就相當於funciton *的作用,而await就相當與yield的作用。而在async/await機制中,自動包含了我們上述封裝出來的spawn自動執行函式。

所以上面的回撥函式又可以寫的更加簡潔了:

async function fetch() {
  	await ajax('aaa')
    await ajax('bbb')
    await ajax('ccc')
}
// 但這是在這三個請求有相互依賴的前提下可以這麼寫,不然會產生效能問題,因為你每一個請求都需要等待上一次請求完成後再發起請求,如果沒有相互依賴的情況下,建議讓它們同時發起請求,這裡可以使用Promise.all()來處理

async函式對Generator函式的改進,體現在以下四點:

  • 內建執行器:async函式執行與普通函式一樣,不像Generator函式,需要呼叫next方法,或使用co模組才能真正執行
  • 語意化更清晰:asyncawait,比起星號和yield,語義更清楚了。async表示函式裡有非同步操作,await表示緊跟在後面的表示式需要等待結果。
  • 適用性更廣:co模組約定,yield命令後面只能是 Thunk 函式或 Promise 物件,而async函式的await命令後面,可以是 Promise 物件和原始型別的值(數值、字串和布林值,但這時會自動轉成立即 resolved 的 Promise 物件)。
  • 返回值是Promise:async函式的返回值是 Promise 物件,這比 Generator 函式的返回值是 Iterator 物件方便多了。你可以用then方法指定下一步的操作。

async函式

async函式的返回值為Promise物件,所以它可以呼叫then方法

async function fn() {
  return 'async'
}
fn().then(res => {
  console.log(res) // 'async'
})

await表示式

await 右側的表示式一般為 promise 物件, 但也可以是其它的值

  1. 如果表示式是 promise 物件, await 返回的是 promise 成功的值
  2. 如果表示式是其它值, 直接將此值作為 await 的返回值
  3. await後面是Promise物件會阻塞後面的程式碼,Promise 物件 resolve,然後得到 resolve 的值,作為 await 表示式的運算結果
  4. 所以這就是await必須用在async的原因,async剛好返回一個Promise物件,可以非同步執行阻塞
function fn() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(1000)
        }, 1000);
    })
}
function fn1() { return 'nanjiu' }
async function fn2() {
    // const value = await fn() // await 右側表示式為Promise,得到的結果就是Promise成功的value
    // const value = await '南玖'
    const value = await fn1()
    console.log('value', value)
}
fn2() // value 'nanjiu'

非同步方案比較

後三種方案都是為解決傳統的回撥函式而提出的,所以它們相對於回撥函式的優勢不言而喻。而async/await又是Generator函式的語法糖。

  • Promise的內部錯誤使用try catch捕獲不到,只能只用then的第二個回撥或catch來捕獲,而async/await的錯誤可以用try catch捕獲
  • Promise一旦新建就會立即執行,不會阻塞後面的程式碼,而async函式中await後面是Promise物件會阻塞後面的程式碼。
  • async函式會隱式地返回一個promise,該promisereosolve值就是函式return的值。
  • 使用async函式可以讓程式碼更加簡潔,不需要像Promise一樣需要呼叫then方法來獲取返回值,不需要寫匿名函式處理Promise的resolve值,也不需要定義多餘的data變數,還避免了巢狀程式碼。

說了這麼多,順便看個題吧~

console.log('script start')
async function async1() {
    await async2()
    console.log('async1 end')
}
async function async2() {
    console.log('async2 end')
}
async1()

setTimeout(function() {
    console.log('setTimeout')
}, 0)

new Promise(resolve => {
    console.log('Promise')
    resolve()
})
.then(function() {
    console.log('promise1')
})
.then(function() {
    console.log('promise2')
})
console.log('script end')

解析:

列印順序應該是: script start -> async2 end -> Promise -> script end -> async1 end -> promise1 -> promise2 -> setTimeout

老規矩,全域性程式碼自上而下執行,先列印出script start,然後執行async1(),裡面先遇到await async2(),執行async2,列印出async2 end,然後await後面的程式碼放入微任務佇列,接著往下執行new Promise,列印出Promise,遇見了resolve,將第一個then方法放入微任務佇列,接著往下執行列印出script end,全域性程式碼執行完了,然後從微任務佇列中取出第一個微任務執行,列印出async1 end,再取出第二個微任務執行,列印出promise1,然後這個then方法執行完了,當前Promise的狀態為fulfilled,它也可以出發then的回撥,所以第二個then這時候又被加進了微任務佇列,然後再出微任務佇列中取出這個微任務執行,列印出promise2,此時微任務佇列為空,接著執行巨集任務佇列,列印出setTimeout

解題技巧:

  • 無論是then還是catch裡的回撥內容只要程式碼正常執行或者正常返回,則當前新的Promise例項為fulfilled狀態。如果有報錯或返回Promise.reject()則新的Promise例項為rejected狀態。
  • fulfilled狀態能夠觸發then回撥
  • rejected狀態能夠觸發catch回撥
  • 執行async函式,返回的是Promise物件
  • await相當於Promise的then並且同一作用域下await下面的內容全部作為then中回撥的內容
  • 非同步中先執行微任務,再執行巨集任務

推薦閱讀

原文首發地址點這裡,歡迎大家關注公眾號 「前端南玖」

我是南玖,我們下一期見!!!

相關文章