這篇手寫 Promise 你一定要康康

deepfunc 發表於 2022-04-12

最近重溫了一下 Q/Promise 的設計講解,結合自己的理解和一些小優化,決定也來寫一篇手寫 Promise 的文章。本文的內容適合對 Promise 的使用有一定了解的童鞋,因為過程中不會過多解釋 Promise 的基礎操作。我們從一個基礎版本開始,漸進式地完成這個 Promise,在過程中分享我的理解和觀點。內容可能有點長,廢話不多說,我們開始吧。

基礎版本

我們先以觀察者模式作為基石來搭建一個基礎版本,實現的功能如下:

  1. 建構函式接受一個函式 exector 作為引數,該函式的第一個引數是 resolve,作用是把 Promise 物件的狀態變為“成功”。
  2. 原型方法 then 是用來註冊一個當狀態變為成功的回撥函式,當回撥觸發時,引數是 resolve 時的決議值。
function Promise(exector) {
  this.pending = [];
  this.value = undefined;

  const resolve = value => {
    if (this.pending) {
      this.value = value;
      for (const onFulfilled of this.pending) {
        // 通知觀察者。
        onFulfilled(this.value);
      }
      this.pending = undefined;
    }
  };

  exector(resolve);
}

Promise.prototype.then = function (onFulfilled) {
  if (this.pending) {
    // 還沒決議,先註冊觀察者。
    this.pending.push(onFulfilled);
  } else {
    // 已決議,直接通知。
    onFulfilled(this.value);
  }
};

// 測試一下。
const p = new Promise(resolve => {
  setTimeout(() => resolve(666), 100);
})

p.then(res => console.log('res: %s', res));

// 輸出:
// res: 666

程式碼很簡單,應該不用過多解釋,上面的完整程式碼在這裡:p0.js

這個基礎版本有個明顯的問題: then 不能進行鏈式呼叫,接著就來優化一下。

then 鏈式呼叫

then 的鏈式呼叫會返回一個新的 Promise,並且 then 中回撥的返回值會使這個新的 Promise 決議為“成功”狀態。

Promise.prototype.then = function (onFulfilled) {
  // “當前”Promise,對於返回的新 Promise 而言,也是“前一個”Promise。
  const prev = this;

  const promise = new Promise(resolve => {
    // 包裝 onFulfilled,使其可以“傳播”決議;
    // “前一個” Promise 決議後,決議返回的這個新 Promise。
    const onSpreadFulfilled = function (value) {
      resolve(onFulfilled(value));
    };

    if (prev.pending) {
      prev.pending.push(onSpreadFulfilled);
    } else {
      onSpreadFulfilled(prev.value);
    }
  });

  return promise;
};

// 測試一下。
const p = new Promise(resolve => {
  setTimeout(() => resolve(666), 100);
});

p.then(res => {
  console.log('res1: %s', res);
  return res + 1;
).then(res => {
  console.log('res2: %s', res);
);
  
// 輸出:
// res1: 666
// res2: 667

實現鏈式呼叫的關鍵是如何決議返回的新 Promise?這裡我對變數做了一些有含義的命名,方便理解:

  1. prev 是呼叫 then 時“當前”的 Promise,對於返回的新 Promise 而言,可以看做是“前一個”Promise。
  2. 包裝 onFulfilled——執行完當前註冊的 onFulfilled 後,用其返回值來決議返回的那個新的 Promise。這是個關鍵步驟,為體現傳播的動作,將其命名為 onSpreadFulfilled
  3. onSpreadFulfilled 作為成功的回撥註冊到 prev 上。

上面的完整程式碼在這裡:p1.js

現在又有個新問題,如果 resolvevalue 是個 Promise,或者 onfulfilled 函式返回的結果是個 Promise,那麼鏈式傳播的決議值不應該是這個 Promise 本身,而是這個 Promise 的決議值才對,也就是要支援 Promise 的狀態傳遞

狀態傳遞

在實現狀態傳遞之前,我們先來康康如何確定一個值是不是 Promise。我們可以用原型繼承來判斷:

return value instanceof Promise;

這樣的缺點是相容性較差,你無法強制使用者的執行環境上下文中只會用一種 Promise 的庫,或者在不同的執行上下文中傳遞 Promise 例項。所以這裡我們使用 鴨子型別 來判斷 Promise,重點關注物件的行為,將 Promise 看作是一個 thenable 物件。

function isPromise(value) {
  // 如果這個物件上可以呼叫 then 方法,就認為它是一個“Promise”了。
  return value && typeof value.then === 'function';
}

接下來就來實現狀態傳遞了,實現的思路就是基於鴨子型別和“通知轉移”。我們先定義一個函式:

function wrapToThenable(value) {
  if (isPromise(value)) {
    return value;
  } else {
    return {
      then: function (onFulfilled) {
        return wrapToThenable(onFulfilled(value));
      }
    };
  }
}

顧名思義,這個函式的作用是用來把一個值包裝為 thenable 物件:如果 value 是 Promise 則直接返回;如果不是就包裝並返回一個有 then 方法的物件,也就是 thenable 物件。這個 thenable 物件的作用是啥呢?接著看這裡:

function Promise(exector) {
  this.pending = [];
  this.value = undefined;

  const resolve = value => {
    if (this.pending) {
      // 包裝為 thenable。
      this.value = wrapToThenable(value);
      for (const onFulfilled of this.pending) {
        // 通知時改為呼叫 thenable 上的 then。
        this.value.then(onFulfilled);
      }
      this.pending = undefined;
    }
  };

  exector(resolve);
}

resolve 決議時,根據 value 的型別不同,有兩種處理情況:

  1. 如果 value 是普通值,經過 wrapToThenable 會包裝為 thenable 物件,通知時呼叫 then 方法相當於直接呼叫 onFulfilled
  2. 如果 value 是 Promise,則把 onFulfilled 註冊到 value 上;等到 value 決議時,就會呼叫 onFulfilled。還記得鏈式呼叫時的 onSpreadFulfilled 嗎? 這裡就是“通知轉移”了,把通知下一個 Promise 的責任轉移到了 value 身上。

當然 then 也要做一點修改:

Promise.prototype.then = function (onFulfilled) {
  const prev = this;

  const promise = new Promise(resolve => {
    const onSpreadFulfilled = function (value) {
      resolve(onFulfilled(value));
    };

    if (prev.pending) {
      prev.pending.push(onSpreadFulfilled);
    } else {
      // 這裡也要改為呼叫 then。
      prev.value.then(onSpreadFulfilled);
    }
  });

  return promise;
};

// 測試一下。
const p = new Promise(resolve => {
  setTimeout(() => resolve(666), 100);
});

p.then(res => {
  console.log('res1: %s', res);
  return new Promise(resolve => {
    setTimeout(() => resolve(777), 100);
  });
}).then(res => {
  console.log('res2: %s', res);
});

// 輸出:
// res1: 666
// res2: 777

這裡來總結一下狀態傳遞的設計思路。包裝為 thenable 物件非常關鍵,作用是保持了與 Promise 一致的行為,也就是介面一致。這樣在 resolve 時我們不用特定去判斷這個值是不是 Promise,而可以用統一的處理方式來通知觀察者;並且也順便完成了“通知轉移”,如果 value 還沒有決議,則 then 會註冊為回撥,如果已決議則 then 會立即執行。

上面的完整程式碼在這裡:p2.js。接下來,我們來完善一下 reject

失敗狀態

當 Promise 決議失敗時,then 方法裡面將只執行第二個引數 onRejected 對應的回撥。首先我們需要另一個包裝函式:

function wrapToRejected(value) {
  return {
    then: function (_, onRejected) {
      return wrapToThenable(onRejected(value));
    }
  };
}

這個函式的作用是一旦發生 reject(value) 時,我們把 value 變為另一種 thenable 物件,這個物件在執行 then 時只會呼叫 onRejected

然後改變一下建構函式:

function Promise(exector) {
  // pending 變為一個二維陣列,裡面存放的元素是 [onFulfilled, onRejected]。
  this.pending = [];
  this.value = undefined;

  const resolve = value => {
    if (this.pending) {
      this.value = wrapToThenable(value);
      for (const handlers of this.pending) {
        this.value.then.apply(this.value, handlers);
      }
      this.pending = undefined;
    }
  };

  const reject = value => {
    resolve(wrapToRejected(value));
  };

  exector(resolve, reject);
}

現在有一個比較大的變化:this.pending 變為了二維陣列。這樣 this.value.then.apply 在執行時會有三種情況:

  1. this.value 是成功決議轉換來的 thenable 物件,還記得 wrapToThenable 嗎?then 被執行時只會呼叫 onFulfilled
  2. this.value 是失敗決議轉換來的 thenable 物件,then 被執行時只會呼叫 onRejected
  3. this.value 是一個 Promise,決議會轉移到這個 Promise 上。

同樣 then 方法也要做一些修改:

Promise.prototype.then = function (onFulfilled, onRejected) {
  const prev = this;
  
  // 注意這裡給了 onFulfilled、onRejected 預設值。
  onFulfilled =
    onFulfilled ||
    function (value) {
      return value;
    };
  onRejected =
    onRejected ||
    function (value) {
      return wrapToRejected(value);
    };

  const promise = new Promise(resolve => {
    const onSpreadFulfilled = function (value) {
      resolve(onFulfilled(value));
    };
    const onSpreadRejected = function (value) {
      resolve(onRejected(value));
    };

    if (prev.pending) {
      prev.pending.push([onSpreadFulfilled, onSpreadRejected]);
    } else {
      prev.value.then(onSpreadFulfilled, onSpreadRejected);
    }
  });

  return promise;
};

// 測試一下。
const p = new Promise((resolve, reject) => {
  setTimeout(() => reject(666), 100);
});

p.then(undefined, err => {
  console.log('err1: %s', err);
  return 1;
}).then(res => {
  console.log('res1: %s', res);
});

// 輸出:
// err1: 666
// res1: 1

我們要特別注意一下增加了 onFulfilledonRejected 的預設值。在實際使用 then 時,可能只會專注處理成功或者失敗的回撥,但是我們又需要另外一種狀態要繼續傳播下去。這裡可能有點不好理解,可以代入資料模擬一下。上面的完整程式碼在這裡:p3.js

又到了思考總結時間,thenable 這個介面是關鍵所在。通過兩個包裝物件,分別處理成功和失敗的狀態,在通知觀察者時可以保持統一的邏輯,這個設計是不是感覺很妙呢?

接下來我們要處理一下呼叫時會產生異常的問題。

異常處理

我們先思考一下會有哪些地方會產生異常?第一個是建構函式裡面 exector 執行的時候:

function Promise(exector) {
  this.pending = [];
  this.value = undefined;

  const resolve = value => {
    // ...
  };

  const reject = value => {
    resolve(wrapToRejected(value));
  };

  try {
    exector(resolve, reject);
  } catch (e) {
    // 如果有異常產生,狀態變為“失敗”。
    reject(e);
  }
}

然後是onFulfilledonRejected 執行的時候。當在以上兩個方法裡產生異常時,狀態要變為失敗,並且需要把異常傳播下去。then 的改動如下:

Promise.prototype.then = function (onFulfilled, onRejected) {
  // ...
  // 產生異常的時候包裝一下。
  const errHandler = returnWhenError(err => wrapToRejected(err));
  onFulfilled = errHandler(onFulfilled);
  onRejected = errHandler(onRejected);

  const promise = new Promise(resolve => {
    const onSpreadFulfilled = function (value) {
      resolve(onFulfilled(value));
    };
    const onSpreadRejected = function (value) {
      resolve(onRejected(value));
    };

    if (prev.pending) {
      prev.pending.push([onSpreadFulfilled, onSpreadRejected]);
    } else {
      prev.value.then(onSpreadFulfilled, onSpreadRejected);
    }
  });

  return promise;
};

// 封裝為一個可重用的高階函式。
// 如果 fun 執行失敗了,則返回 onError 的結果。
function returnWhenError(onError) {
  return fun =>
    (...args) => {
      let result;

      try {
        result = fun(...args);
      } catch (e) {
        result = onError(e);
      }

      return result;
    };
}

然後我們可以加入 catch 方法:

Promise.prototype.catch = function (onRejected) {
  // 在 then 中忽略掉“成功”狀態的回撥。
  return Promise.prototype.then.call(this, undefined, onRejected);
};

// 測試一下。
const p = new Promise(resolve => {
  setTimeout(() => resolve(666), 100);
});

p.then(res => {
  console.log('res1: %s', res);
  throw new Error('test error1');
}).then(undefined, err => {
  console.log('err1: %s', err.message);
  throw new Error('test error2');
}).catch(err => {
  console.log('err2: %s', err.message);
});

// 輸出:
// res1: 666
// err1: test error1
// err2: test error2

上面的完整程式碼在這裡:p4.js

到了這裡,基本上 Promise 的基本功能就差不多完成了。不過還有一些不太完善的地方,我們來繼續做一些優化。

一些優化

封裝私有變數

this.pendingthis.value 從外部是可以讀寫的,不夠安全和健壯。而我又還是想用建構函式和原型方法,不想用閉包來封裝。我這裡採用的是 WeakMap 來達到目的,關鍵的修改如下:

const refMap = new WeakMap();

// ...

function Promise(exector) {
  // 用當前的例項引用作為 key,把想隱藏的資料放進一個物件裡。
  refMap.set(this, {
    pending: [],
    value: undefined
  });

  const resolve = value => {
    // 取出封裝的資料。
    const data = refMap.get(this);

    if (data.pending) {
      data.value = wrapToThenable(value);
      for (const handlers of data.pending) {
        data.value.then.apply(data.value, handlers);
      }
      data.pending = undefined;
    }
  };

  // ...
}

同樣 then 也修改一下:

Promise.prototype.then = function (onFulfilled, onRejected) {
  // ...

  const promise = new Promise(resolve => {
    const onSpreadFulfilled = function (value) {
      resolve(onFulfilled(value));
    };
    const onSpreadRejected = function (value) {
      resolve(onRejected(value));
    };
    // 取出封裝的資料。
    const data = refMap.get(prev);

    if (data.pending) {
      data.pending.push([onSpreadFulfilled, onSpreadRejected]);
    } else {
      data.value.then(onSpreadFulfilled, onSpreadRejected);
    }
  });

  return promise;
};

上面的完整程式碼在這裡:p5.js

當 Promise 例項被垃圾回收時,對應在 WeakMap 中的私有資料物件引用也會被消除,沒有記憶體洩漏問題,這種方案非常適合用來封裝私有變數。

呼叫順序

目前的 Promise 在執行時有呼叫順序問題,比如:

const p = new Promise(resolve => resolve(1));

p.then(res => {
  console.log('res1:', res);
  return res + 1;
}).then(res => {
  console.log('res2:', res);
});

p.then(res => {
  console.log('res3:', res);
});

console.log('Hi!');

// 目前的輸出是:
// res1: 1
// res2: 2
// res3: 1
// Hi!

// 正確的輸出應該是:
// Hi!
// res1: 1
// res3: 1
// res2: 2

一個簡單的做法是利用 setTimeout 來改進:

function Promise(exector) {
  // ...
  
  const resolve = value => {
    const data = refMap.get(this);

    if (data.pending) {
      data.value = wrapToThenable(value);
      for (const handlers of data.pending) {
        // 延遲執行。
        enqueue(() => {
          data.value.then.apply(data.value, handlers);
        });
      }
      data.pending = undefined;
    }
  };
  
  // ...
}

Promise.prototype.then = function (onFulfilled, onRejected) {
  // ...

  const promise = new Promise(resolve => {
    // ...

    if (data.pending) {
      data.pending.push([onSpreadFulfilled, onSpreadRejected]);
    } else {
      // 延遲執行。
      enqueue(() => {
        data.value.then(onSpreadFulfilled, onSpreadRejected);
      });
    }
  });

  return promise;
};

function enqueue(callback) {
  setTimeout(callback, 1);
}

enqueue 的作用是模擬按入隊順序來延遲執行函式。通過對所有 then 呼叫的延遲執行,可以保證按正確的註冊順序和決議順序來執行了,上面的完整程式碼在這裡:p6.js

接下來呢?

咳咳,到了這裡我覺得就先差不多了,畢竟此文的目的是分享和交流一種 Promise 的設計思路和心得,而不是去造一個完美的 Promise。手寫一個 Promise 這個結果不應該是我們的目的,觀察演進過程中的思路和方案才是我們需要吸收的東西。後面有時間我會把缺少的一些介面也補上,比如 Promise.resolvePromise.prototype.finally 等等。

最後,希望你也能從這篇文章中收穫一些東西吧,歡迎 star 和關注我的 JavaScript 部落格:小聲比比 JavaScript