面試官問:來實現一個 Promise

Promise 作爲異步編程的一種解決方案,在 ES6 中被標準化,提供了 Promise 對象和一系列的 API。在事件循環、鏈式調用、調度器實現等面試場景中均有涉及。在本文中筆者將從零實現一個符合 Promise/A+ 標準的 Promise 主體代碼邏輯,並在後續系列文章中給出其他方法的實現以及常見的實際使用場景中的解法。

1、準備

本文按 Promise/A+[1] (中文版【翻譯】Promises/A + 規範 [2]) 的標準實現,不熟悉的讀者可以先看一遍,瞭解一些術語做些準備知識。

2、Promise 實現

本節,我們將進入正題,從零實現一個我們自己的 Promise。PS:在下文中,本文約定大寫 Promise 指代我們實現的 MyPromise 函數對象,小寫 promise 指代一個實例對象。

現在,我們實現的 MyPromise 函數第一版定義如下

function MyPromise(executor) {
  // TODO

}

2.1、狀態定義

Promise 只有三種狀態:pending、fulfilled 和 rejected, 其中後兩種是終態。

因此,我們可以先定義一個狀態集合:

const PRO_STATUS = {
  PENDING: 'pending',
  FULFILLED: 'fulfilled',
  REJECTED: 'rejected'
}

2.2、狀態轉換及方式

promise 對象內部就像一個狀態機,但是這個狀態機有一點自己的限制條件,即, 它的狀態變換路徑只有兩種:

pending-》fulfilled 
或者
pending -》rejected

並且,轉換之後是固定的。Ok,在談完狀態轉換的路徑後,我們來看一下狀態轉換的方式。

在初始化 promise 對象時需要向構造函數提供一個 executor 函數,該函數有兩個入參(函數類型):

•1、resolve,該函數接受一個參數,更改 promise 內部狀態 pending-》fullfilled•2、reject,該函數接受一個 Error 類型參數,更改 promise 內部狀態 pending -》rejected

至此,總結一下我們的 MyPromise 裏應該有幾個東西:

當前狀態
fulfilled 狀態下的 value 值
rejected 狀態下的 reason 值
resolve 函數
reject 函數

那麼,MyPromise 第二版現在是如下的樣子:

let count = 0
function MyPromise(executor) {
  const self = this
  self.status = PRO_STATUS.PENDING
  self.count = ++count
  self.fulfilledValue = undefined
  self.rejectedReason = undefined

  try {
    executor(resolve, reject)
  } catch (error) {
    reject(error)
  }

  function resolve(rs) {
    //TODO
  }

  function reject(err){
    // TODO
  }
}

這裏,我們爲每個實例追加了一個 count 計數,讀者可以忽略。

實例化函數後,我們直接執行了 executor 函數,並傳入了兩個函數類型的參數。在 executor 函數內部,用戶可以通過 resolve 或者 reject 修改 promise 對象進入終態,並且只能進入一次,舉個例子:

new MyPromise((resolve, reject)=>{
  //balabala...
  ....
  resolve(1) 
  reject(new Error('error'))
  resolve(2)
})

這裏寫了三行修改 promise 狀態的代碼,但是最後 promise 的狀態是 fulfilled,並且 fulfilledValue 是 1。這個我們在後面 resolve 和 reject 實現中說。

2.3、then 和 catch 方法

我們知道,Promise 對象實現了鏈式調用來解決回調地獄的問題。類似這樣:

new Promise(()=>{
  ....
})
.then(rs=>{
  ...
})
.then(rs=>{})
.catch(err=>{})

也就是說,我們可以在 then 或 catch 中拿到 promise 對象的終態數據並通過生成新的 promise 對象向下傳遞。

首先,我們來看看 then 方法。

then 方法接受兩個函數類型的參數:onfulfilled 和 onrejected。onfulfilled 接受 promise 的 fulfilledValue 作爲入參並在 promise 爲 fulfilled 狀態時被調用, onrejected 接受 promise 的 rejectedReason 作爲入參並在 promise 爲 rejected 狀態時被調用。

const onfulfilled = value =>{...}
const onrejected = reason =>{...}
promiseA.then(onfulfilled, onrejected)

此外,then 方法將返回一個新的 Promise 類型對象。

相比 then 方法,catch 方法僅接受一個 onrejected 函數類型的參數。和 then 方法一樣將返回一個新的 Promise 類型對象。

const onrejected = reason =>{...}
promiseA.catch(onrejected)

實際上,then 和 catch 方法有幾個作用:

• 爲 promise 對象收集 onfulfilled 和 onrejected 回調函數,在終態後(resolve 和 reject 函數觸發)進行回調的調用 • 觸發 onfulfilled 和 onrejected 回調函數

其實第一個比較好理解,第二個可以用下面一個代碼去解釋。

let promise = new Promise((resolve)=>{
  setTimeout(()=>{
    resolve()
    promise.then(rs=>{console.log(2)}) // then2
  })
}).then(rs=>{console.log(1)}) // then1

rs=>{console.log(1)} 回調通過 then1 收集,在 resolve 調用後被觸發。此時 promise 對象進入終態, rs=>{console.log(2)} 回調通過 then2 收集並觸發執行。

並且,這些回調函數只會被調用一次。

綜上,我們可以總結如下:

•MyPromise 內部 resolve、reject 函數以及 then、catch 都可能會觸發回調函數執行,那麼他們可能在代碼鏈路上交匯在某個執行點,也就是說他們調用了同一個處理函數,我們定義爲 _handle 函數。• 此外,Promise 函數內部有一個數據結構維護當前的回調函數,這裏我們需要一個隊列。• 最後,如果我們有 promise A 對象,promise A 對象的 then 和 catch 方法都會返回一個新的 promise B 實例,A 內部狀態是 fullfilled,它只調用 onfulfilled 方法。此外,promise A 進入終態纔會使得 promise B 進入終態,關鍵點在於 A 持有 B 的 resolve、reject,A 進入終態後調用 B 的 resolve/reject,具體調用 resolve 還是 reject 以及入參要分情況區別。

Ok,有了上面的結論,我們繼續修改已有代碼:

const TYPES = {
  THEN: 'then',
  CATCH: 'catch',
  FINALLY: 'finally'
}
...
function MyPromise(executor) {
  const self = this
  ...
  self.cbQueue = [] // 保存回調等數據
  ...

  function resolve(rs) {
    self._handle()
  }

  function reject(err){
    self._handle()
  }
}

MyPromise.prototype._handle = function(cb){
  // TODO
}

/**
 * 
 * @param {*} onfulfilled 
 * @param {*} onrejected 
 * @returns 
 */
MyPromise.prototype.then = function(onfulfilled, onrejected) {
  return new Promise((resolve, reject) => {
    this._handle({
      type: TYPES.THEN,
      resolve,
      reject,
      onfulfilled,
      onrejected
    })
  })
}

/**
 * 
 * @param {*} onrejected 
 * @returns 
 */
MyPromise.prototype.catch = function(onrejected) {
  return new Promise((resolve, reject) => {
    this._handle({
      type: TYPES.CATCH,
      resolve,
      reject,
      onrejected
    })
  })
}

上面的代碼裏,我們還定義了一個 TYPES 來指定回調函數是通過 then、catch 還是 finally 方法收集的,以此來輔助我們在 _handle 函數中的處理。

現在關鍵來到了 _handle 函數。我們根據 Promise/A+ 的標準和實際代碼使用中,對於細節進行了歸結。

•1、A.resolve() 情況下,B 不管是通過 then 還是 catch 產生, 都要調用 B.resolve(),入參要看是否提供了 onfulfilled,具體如下:

    1)如果 B 是 A.then 生成,則 B.resolve(onfulfilled(A.fulfilledValue))

    2)如果 B 是 A.catch 生成,則 B.resolve(A.fulfilledValue),不會調用 A.catch 提供的 onrejected 方法 如果不提供 onfulfilled 則 B.resolve(A.fulfilledValue)

•2、注意,A.reject() 的情況下,如果有 onrejected 函數處理則狀態發生轉換,並且入參要看是否提供了 onrejected 函數進行包裝,具體如下:

    1)A 調用 reject,B 是 A.then 生成,則 B.reject(A.rejectedReason) 或者 B.resolve(onrejected(A.rejectedReason))

    2)A 調用 reject,B 是 A.catch 生成,則 B.resolve(onrejected(A.rejectedReason)) 如果不提供 onrejected 則 B.reject(A.rejectedReason)

    •    3、如果 A.fulfilledValue 是一個 Promise 類型,則要把 A.then() 這些收集到的回調給 A.fulfilledValue

針對 3,可以看如下示例代碼:

function delay(){
  new Promise((resolve, reject)=>{// promise 0
    // console.log('0')
    // resolve('resolve')
    reject(new Error('reject'))
  })
  .then(rs=>{
    return new Promise(resolve=>{
      setTimeout(()=>{
        console.log('1')
        resolve('inner rs')
      }, 2000)
    })
  })
  .catch(err=>{ // promise 1
    return new Promise(resolve=>{ // promise 2
        setTimeout(()=>{
          console.log('1')
          resolve('inner err')
        }, 2000)
      })
  })
  .then(rs=>{
    console.log('2')
    return 'then2'
  })
}
最後打印:
(注:先延遲2s)
1 
2

最後的 then 會被 promise1 收集到,因爲 promise1 的 fulfilledValue 是一個 Promise 類型對象,即 promise2。要實現延遲 2s 打印 1 後再打印 2,需要把 promise1 收集到的回調賦給 promise2。

Ok,瞭解了處理邏輯,我們就可以直接上代碼了。

MyPromise.prototype._handle = function(cb){
  if(cb) {
    // then、catch、finally 方法處理
    this.cbQueue.push(cb)
  }else{
    // resolve、reject 處理
  }
  if(this.status === PRO_STATUS.PENDING){
    // nothing to do
  } else {
    for (let i = 0; i < this.cbQueue.length; i++) {
      const cb = this.cbQueue[i];
      const { type, resolve, reject, onfulfilled, onrejected, onfinally } = cb
      // finally
      if(type === TYPES.FINALLY){
        onfinally()
        resolve()
        continue
      }
      if(this.status === PRO_STATUS.FULFILLED){
        //
        if(typeof resolve === 'function'){
          let fulfilledValue = this.fulfilledValue
          let ans = fulfilledValue
          if(fulfilledValue instanceof MyPromise){
            // 收集的回調賦給 fulfilledValue
            fulfilledValue.cbQueue = this.cbQueue
            this.cbQueue = []
            continue
          }

          if(typeof onfulfilled === 'function'){
            // 這裏要處理一下數據

            ans = onfulfilled(fulfilledValue)

            if(ans instanceof MyPromise && ans.status !== PRO_STATUS.PENDING){
              if(ans.status === PRO_STATUS.FULFILLED){
                resolve(ans.fulfilledValue)
              }else{
                reject(ans.rejectedReason)
              }
            }else{
              resolve(ans)
            }
          }else{
            resolve(ans)
          }
        }
      }else{
        if(typeof resolve === 'function'){
          let ans = this.fulfilledValue
          if(typeof onrejected === 'function'){
            /**
             * 這個地方要注意下,上面 setTimeout模擬異步的地方,修改狀態的部分要放在 setTimeout 外面。
             * 否則到這裏, status 還是 pending
             */
            ans = onrejected(this.rejectedReason)
            if(ans instanceof MyPromise && ans.status !== PRO_STATUS.PENDING){
              if(ans.status === PRO_STATUS.FULFILLED){
                resolve(ans.fulfilledValue)
              }else{
                reject(ans.rejectedReason)
              }
            }else{
              resolve(ans)
            }
          }else{
            reject(this.rejectedReason)
          }
        }
      }
    // })
    }
    this.cbQueue = []
  }
}

_handle 函數先對進來的參數進行判斷,有的話就入隊列。然後看當前的狀態,是終態就處理上面的邏輯,最終清空隊列。否則就直接退出。PS:這裏缺少了 finally 方法的處理代碼,我們在後面補上。此外還有就是關於異常的拋出問題,當 promise A 對象進入 rejected 狀態,此時,如果 promise.then 未提供 onrejected,則會拋出 error; 如果提供 onrejected,則不會,也就是有的資料中提到的 error 被 “喫掉” 了,這部分功能並未實現。

2.4、resolve 和 reject 實現

好了,到了這裏基本上完成了大部分的工作了,但是還缺少了 resolve 和 reject 部分的代碼實現。無論是 resolve 還是 reject 函數,他們的功能都是兩個部分:

• 修改狀態 • 觸發 onfulfilled/onrejected 回調(如果有的話)

我們都知道,Promise 屬於異步任務裏的微任務,在構造函數里的代碼和 onfulfilled/onrejected 裏的代碼都運行在主線程中。因此,我們需要模擬一個異步的過程,並且在定義多個 Promise 對象實例時保證一個時序,這裏我們用 setTimeout,並在 setTimeout 中調用 _handle 函數。好的,我們來看 resolve 函數的具體實現。

function resolve(rs) {
  if(self.status === PRO_STATUS.PENDING) {
    /**
     * 在主線程修改狀態
     */
    self.status = PRO_STATUS.FULFILLED
    self.fulfilledValue = rs
    setTimeout(() => { 
      /**
       * 模擬異步,但是這裏有一個bug,setTimeout 並不準確,
       * 在 Promise.race 中有問題,主要是 setTimeout 執行間隔
       */
      self._handle()
    })
  }
}

可以看到,首先判斷了當前狀態,確保只能第一個 resolve/reject 方法裏面的代碼被執行去把 pending 狀態修改爲終態,並且是在主線程中修改了狀態。另外註釋裏標明瞭一個問題,我們使用了 setTimeout 去模擬異步,但是因爲它本身延遲執行的特性,會帶來一些問題,比如下面的測試代碼:

const Promise = MyPromise
function race(){
  Promise.race([
    new Promise(resolve=>{
      setTimeout(()=>{resolve(1)}, 200)// 這裏有一個bug?超時改成20看看
    }),
    new Promise((resolve, reject)=>{
      setTimeout(()=>{
        // resolve(2)
        reject(new Error('timeout'))
      }, 10)
    })
  ])
  .then(res=>{
    log2('race res', {res})
  },
  err=>{
    log2('race err', {err})
  }
  )
}

在註釋處修改爲 20 會產生意外的效果。比照 resolve 函數的實現,我們可以很容易給出 reject 函數的代碼實現:

function reject(err){
  if(self.status === PRO_STATUS.PENDING) {
    self.status = PRO_STATUS.REJECTED
    self.rejectedReason = err
    setTimeout(() => {
      self._handle()
    })
  }
}

3、結語

通過本文的介紹,我們得到了一個 Promise 實現。下一節中,我們介紹一些 API 的實現。

References

[1] Promise/A+: https://promisesaplus.com/
[2] 【翻譯】Promises/A + 規範: https://www.ituring.com.cn/article/66566

最近組建了一個江西人的前端交流羣,如果你是江西人可以加我微信 ruochuan12 私信 江西 拉你進羣。

················· 若川簡介 ·················

你好,我是若川,畢業於江西高校。現在是一名前端開發 “工程師”。寫有《學習源碼整體架構系列》10 餘篇,在知乎、掘金收穫超百萬閱讀。
從 2014 年起,每年都會寫一篇年度總結,已經寫了 7 篇,點擊查看年度總結
同時,最近組織了源碼共讀活動,幫助 1000 + 前端人學會看源碼。公衆號願景:幫助 5 年內前端人走向前列。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/427CZ3UG_f5rAEPqn8K5kQ