ES6非同步方式全面解析

歪程式喵發表於2018-11-12

本文首發於本人部落格

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的好處就是讓開發者遠離了回撥地獄的困擾,它具有如下特點:

  1. 物件的狀態不受外界影響:

    • Promise物件代表一個非同步操作,有三種狀態:Pending(進行中)、Resolved(已完成,又稱 Fulfilled)和Rejected(已失敗)。
    • 只有非同步操作的結果,可以決定當前是哪一種狀態,任何其他操作都無法改變這個狀態。
  2. 一旦狀態改變,就不會再變,任何時候都可以得到這個結果。

    • Promise物件的狀態改變,只有兩種可能:從Pending變為Resolved和從Pending變為Rejected。
    • 只要這兩種情況發生,狀態就凝固了,不會再變了,會一直保持這個結果。
    • 如果改變已經發生了,你再對Promise物件新增回撥函式,也會立即得到這個結果。
    • 這與事件(Event)完全不同,事件的特點是,如果你錯過了它,再去監聽,是得不到結果的。
  3. 一旦宣告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.allPromise.racePromise.resolve/reject等等(可以參考阮老師的文章),在需要的時候配合使用都是極好的。

API無需多說,不過這裡我總結了一下自己之前使用Promise踩到的坑以及我對Promise理解不夠透徹的地方,希望也能幫助大家更好地使用Promise:

  1. 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)
    })
    複製程式碼
  2. 一個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
    })
    複製程式碼
  3. thencatch返回的值不能是當前promise本身,否則會造成死迴圈

    const promise = Promise.resolve()
    .then(() => {
        return promise
    })
    複製程式碼
  4. then或者catch的引數期望是函式,傳入非函式則會發生值穿透

    Promise.resolve(1)
      .then(2)
      .then(Promise.resolve(3))
      .then(console.log)
    // 1
    複製程式碼
  5. process.nextTickpromise.then都屬於microtask,而setImmediatesetTimeout屬於macrotask

    process.nextTick(() => {
      console.log(`nextTick`)
    })
    Promise.resolve()
      .then(() => {
        console.log(`then`)
      })
    setImmediate(() => {
      console.log(`setImmediate`)
    })
    console.log(`end`)
    // end nextTick then setImmediate
    複製程式碼

    有關microtaskmacrotask可以看這篇文章,講得很細緻。

但Promise也存在弊端,那就是若步驟很多的話,需要寫一大串.then(),儘管步驟清晰,但是對於我們這些追求極致優雅的前端開發者來說,程式碼全都是Promise的API(thencatch),操作的語義太抽象,還是讓人不夠滿意呀~


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非同步解決方案的進一步完善,它有如下特點:

  1. 內建執行器:不用像generator那樣反覆呼叫next方法,或者使用co模組,呼叫即會自動執行,並返回結果
  2. 返回Promise:generator返回的是iterator物件,因此還不能直接用then來指定回撥
  3. 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就可以玩轉非同步。沒準以後又會出現一個新的方案,將已有的這幾種方案顛覆呢 ~

在這不斷變化、發展的時代,我們前端要放開自己的眼界,擁抱變化,持續學習,才能成長,寫出優質的程式碼?~

相關文章