Promise拆解計劃:手寫Promise並透過官方全部測試用例

發表於2024-02-27

大家好,歡迎來到前端研習圈的今日分享。

前言

本系列上期帶著大家一起拆解了 Promises/A+ 規範。從概念,術語,約束條例等方面瞭解了規範

那麼本期我們要做的就是從規範到實現,並且透過官方的所有測試用例。為了和原生的 Promise 有所區別,我們就把這一版實現命名為 _Promise。完全形態已經上傳到 github,需要的同學自取

提示:_Promise 僅關注具體實現,不關注成員方法具體應該是私有還是公有等設計細節

原始碼地址 ->
https://github.com/Mumujianguang/_promise

接下來我們就進入主題,首先我們大概整理一下 todo list

  • 定義 Promise 的狀態列舉
  • 定義 Promise 的類結構
  • 實現建構函式
  • 實現 then 方法

結構設計

首先,我們先定義一個列舉物件,將 Promise 的三種狀態定義出來

const PromiseState = {
    pending: 'pending',
    fulfilled: 'fulfilled',
    rejected: 'rejected'
}

然後簡單設計一下類結構,同時將 state 初始化為 pending 狀態

class _Promise {
    state = PromiseState.pending;

    value;   
    reason;

    fulfilledQueue = [];
    rejectedQueue = [];

    constructor(executor) {}

    resolve(value) {}

    reject(reason) {}

    then(onFulfilled, onRejected) {}
}

因為 then 可以呼叫多次,所以我們設計 fulfilledQueuerejectedQueue 兩個陣列來分別儲存 then 所註冊的 成功的回撥失敗的回撥

為了後續方便內部呼叫,這裡將 resolvereject 兩個方法也直接定義在類上

實現

有了基礎結構,那麼我們就可以開始著手實現各個方法了。先從 constructor 開始

constructor

回顧一下 Promise 的用法,為了方便講解,我們把在構造 Promise 例項時傳入的函式單獨提出來,它的學名叫 executor,接收 resolvereject 兩個引數

const executor = (resolve, reject) => {}
const p = new Promise(executor)

看到這兒,相信大家應該都有 constructor 的實現思路了。

但需要注意一點,為了防止 executor 執行時內部報錯,需要 try catch 處理一下,並且在 catch 的場景直接將 Promise reject

constructor(executor) {
    try {
        executor(
            (value) => this.resolve(value),
            (reason) => this.reject(reason)
        )
    } catch(e) {
        this.reject(e);
    }
}

以上就是 constructor 的實現邏輯,接下來我們順著這個思路繼續

resolve & reject

在執行 executor 時,我們將 resolvereject 作為引數傳了進去,我們一起回顧一下它們的作用是什麼

  • 接收一個 value/reason
  • Promise 的狀態修改為成功/失敗
  • value/reason 作為 成功/失敗 回撥的引數並按照註冊的順序批次執行
  • 狀態一旦改變就不能再被呼叫

功能點很清晰,那麼我們就可以一條一條去實現它們,直接看程式碼吧

resolve(value) {
    if (this.state !== PromiseState.pending) {
        return;
    }

    this.state = PromiseState.fulfilled;
    this.value = value;

    this.fulfilledQueue.forEach((onFulfilled) => onFulfilled(value))
}
reject(reason) {
    if (this.state !== PromiseState.pending) {
        return;
    }

    this.state = PromiseState.rejected;
    this.reason = reason;

    this.rejectedQueue.forEach((onRejected) => onRejected(reason))
}

到這裡,我們就完成了 resolve & reject 的實現了,還是比較簡單對不對,那麼接下來要上強度咯

then

首先我們思考一下 then 的作用是什麼,再同步回顧一下用法

const p = new Promise(resolve => resolve('done'))
p.then(
  value => console.log(value),
  reason => console.log(reason),
)

then 的功能其實很簡單,就是單純註冊 成功/失敗 的回撥,但就是看似如此簡單的方法,它的實現難度卻是整個 Promise 中最高的。

但不要慌,我們今天的目標就是要搞懂它,翻過那座山(背後還是山)!

結合上期規範中所講,我們先簡單概括兩點

  • then 返回的是一個新的Promise
  • 接收 onFulfilledonRejected 作為引數

先寫出如下程式碼

then(onFulfilled, onRejected) {
    const p2 = new _Promise((resolve, reject) => {
      // TODO
    })
    return p2;
}

現在 then 的整體框架有了,我們繼續思考,由於 onFulfilledonRejected 的返回值會決定 p2 的狀態,那麼在註冊之前,我們肯定需要對這兩個方法做一層包裝,將新的Promise的 resolve 和 reject 的執行權onFulfilled/onRejected 的返回值關聯起來,由返回值的具體情況決定

也就是說 then 的其餘邏輯我們需要寫在 新Promiseexecutor 中。同時還需要注意的是,在 executor 中我們就需要訪問 p2,但 p2 是在 executor 執行完畢之後才被賦的值,直接訪問肯定會報錯

then(onFulfilled, onRejected) {
    const p2 = new _Promise((resolve, reject) => {
        console.log(p2) // Uncaught ReferenceError: Cannot access 'p2' before initialization
    })
    return p2;
}
Uncaught ReferenceError: Cannot access 'p2' before initialization

如上程式碼所示,我們會得到一個與預期一致的報錯,怎麼規避呢?其實很容易解決,放到非同步任務裡面去執行不就好了嗎,這裡我們用 queueMicrotask 來實現

then(onFulfilled, onRejected) {
  const p2 = new _Promise((resolve, reject) => {
    queueMicrotask(() => {
      console.log(p2) // _Promise {}
    })
  })
  return p2;
}

搞定,現在就能正常訪問 p2

那麼接下我們繼續按照規範的約束條例給 then 的實現添磚加瓦,先梳理一下大致要做的事情

  • onFulfilledonRejected 新增相容邏輯(參考條例 2.2.1 & 2.2.7.3 & 2.2.7.4
  • 包裝 onFulfilledonRejected,它們的返回值將決定 p2 的 resolve 和 reject 如何執行。因此這裡我們再抽象一層 包裝器 函式(wrapCallback)出來,由這個函式來返回包裝後的 onFulfilledresolveCallback) 和 onRejectedrejectCallback
  • 判斷當前 Promise 的狀態是否已經確定,是的話則直接呼叫resolveCallbackrejectCallback,否則將它們註冊到各自的回撥佇列中

梳理完畢,就敲程式碼實現吧

then(onFulfilled, onRejected) {
  const p2 = new _Promise((resolve, reject) => {
    queueMicrotask(() => {
      onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : () => resolve(this.value);
      onRejected = typeof onRejected === 'function' ? onRejected : () => reject(this.reason);

      const resolveCallback = this.wrapCallback(
        p2,
        onFulfilled,
        resolve,
        reject
      );
      const rejectCallback = this.wrapCallback(
        p2,
        onRejected,
        resolve,
        reject
      );

      if (this.state === PromiseState.fulfilled) {
          resolveCallback(this.value);
          return;
      }

      if (this.state === PromiseState.rejected) {
          rejectCallback(this.reason);
          return;
      }

      this.fulfilledQueue.push(
          resolveCallback
      )

      this.rejectedQueue.push(
          rejectCallback
      )
    })
  })

  return p2;
}

至此 then 方法我們就已經實現啦,完結撒花!最難的部分也不過如此...

等等,不太對勁,then 是實現完了,但我們剛剛又引入了一層包裝器 還沒實現呢,還得繼續呀各位~

順便提醒一下大家,還記得上期我們留的一個大坑嗎,沒錯就是規範中的約束條例2.3 所描述的 Promise Resolution Procedure 流程,但我們現在不是隻剩下 wrapCallback 了嗎,所以這個處理流程只能在 wrapCallback 中實現了,為了和規範保持一致,我們把 Promise Resolution Procedure 流程單獨用一個方法來實現,就取名叫 resolutionProcedure

這樣一來 wrapCallback 的實現就異常簡單了,但注意 then 的回撥需要放在 micro task 中去執行,這裡我們還是用 queueMicrotask 來實現;同時還是需要考慮回撥內部執行報錯的場景,所以加上 try catch 來捕獲異常,並在異常的case,觸發 reject

基於上面的分析,我們就能敲出以下程式碼了

wrapCallback(promise2, callback, resolve, reject) {
  return (arg) => queueMicrotask(() => {
    let x;

    try {
        x = callback(arg);
    } catch (error) {
        reject(error);
        return;
    }

    this.resolutionProcedure(promise2, x, resolve, reject)
  })
}

OK,現在 wrapCallback 實現完畢!

終於,我們來到了最後一個方法 resolutionProcedure,還記得這個流程是做什麼的嗎,先拋開其他細節,此流程主要是為了處理 x 是一個 thenable 的場景,以支援第三方實現的 類promise,滿足互操作性的要求。

好吧,糾正一下我之前的措辭,在整個 Promise 實現中 resolutionProcedure 才是最難的(手動狗頭)

那麼接下來我們還是根據規範 條例2.3 來梳理出需要實現的邏輯點

  1. 因為這裡面存在自呼叫,所以當 p2x 是同一個物件時,為了防止死迴圈,需要直接退出後續處理並觸發 reject
  2. x 是一個 Promise 時,則直接將 resolve & reject 包裝後註冊到 x 上,由 x 的最終狀態來決定 p2 的狀態
  3. x 是一個普通物件或者方法時,如果 x 存在 then 方法,則將其視為 thenable。注意,這裡需要考慮 then 是一個 getter 的情況,也就是意味著在訪問 x.then 時也可能會報錯,因此獲取 then 的過程也需要 try catch 包裹一下,報錯的情況直接觸發 reject。後續處理的目標和第2點一致,還是遵循 resolvereject 只能觸發一次的原則,需考慮 then 執行報錯的場景,這裡就不做贅述了
  4. 以上條件均不滿足時,則將 x 作為 value 觸發 resolve

接下來就是編碼時間~

resolutionProcedure(promise2, x, resolve, reject) {
    if (promise2 === x) {
        reject(new TypeError('promise2 === x'));
        return;
    }

    if (x instanceof _Promise) {
        x.then(
            (value) => this.resolutionProcedure(promise2, value, resolve, reject),
            (reason) => reject(reason)
        );
        return;
    }

    if (
        x !== null &&
        typeof x === 'object' ||
        typeof x === 'function'
    ) {
        let then;

        try {
            then = x.then;
        } catch (error) {
            reject(error);
            return;
        }

        if (typeof then === 'function') {
            let isCalledResolvePromise = false;
            let isCalledRejectPromise = false;
            const resolvePromise = (value) => {
                if (isCalledResolvePromise || isCalledResolvePromise) {
                    return;
                }

                isCalledResolvePromise = true;
                this.resolutionProcedure(promise2, value, resolve, reject)
            }
            const rejectPromise = (reason) => {
                if (isCalledRejectPromise || isCalledResolvePromise) {
                    return;
                }

                isCalledRejectPromise = true;
                reject(reason)
            }
            try {
                then.call(
                    x,
                    resolvePromise,
                    rejectPromise
                )
            } catch (error) {
                if (
                    !isCalledRejectPromise &&
                    !isCalledResolvePromise
                ) {
                    reject(error)
                }
            }
            return;
        }
    }

    resolve(x);
}

至此,我們的 _Promise 就全部編碼完畢,那它能否像原生的 Promise 正常工作呢,趕緊去測試一下吧!

當然也不用大家去手寫測試用例了,官網提供了一個 npmpromises-aplus-tests

github地址 -> https://github.com/promises-aplus/promises-tests

根據官方的文件描述,要執行測試用例我們還需要匯出一個標準結構

那麼我們就按照要求匯出如下物件

module.exports = {
    resolved(value) {
        return new _Promise((resolve) => resolve(value))
    },
    rejected(reason) {
        return new _Promise((_, reject) => reject(reason))
    },
    deferred() {
        const ret = {};

        ret.promise = new _Promise((resolve, reject) => {
            ret.resolve = resolve;
            ret.reject = reject
        })

        return ret;
    }
}

值得一提的是,deferred 是不是與 Promise.withResolvers 的功能如出一轍

回到正題,安裝promises-aplus-tests後,我們根據官網文件的測試指南配置一下測試指令

// package.json
...
"scripts": {
  "test": "promises-aplus-tests ./core/Promise.cjs"
},
...

接下來就可以測試了,在控制檯輸入

pnpm run test

OK,872個用例全部透過

寫在最後

到這裡 Promise拆解計劃 終於完成,這個系列的階段性目標也達成了,希望這個系列能真正幫助到大家,從此不再受 Promise 的毒打~

那麼這期就到這裡,如果覺得有用的話記得點贊加關注哦!

我們下期見!

相關文章