從源碼看 Promise 概念與實現


Promise 是 JS 異步編程中的重要概念,它較好地解決了異步任務中回調嵌套的問題。在沒有引入新的語言機制的前提下,這是如何實現的呢?上手 Promise 時常見若干晦澀的 API 與概念,它們又爲何存在呢?源碼裏隱藏着這些問題的答案。javascript

下文會在介紹 Promise 概念的基礎上,以一步步代碼實現 Promise 的方式,解析 Promise 的實現機制。相應代碼參考來自 PromiseJS 博客 及 You don’t know JS 的若干章節。php

Why Promise

(有使用 Promise 經驗的讀者可忽略本段)java

基於 JS 函數一等公民的優良特性,JS 中最基礎的異步邏輯通常是以向異步 API 傳入一個函數的方式實現的,這個函數裏包含了異步完成後的後續業務邏輯。與普通的函數參數不一樣的是,這類函數需在異步操做完成時才被調用,故而稱之爲回調函數。以異步 Ajax 查詢爲例,基於回調的代碼實現多是這樣的:ajax

ajax.get('xxx', data => { // 在回調函數裏獲取到數據,執行後續邏輯 console.log(data) // ... }) 

從而,在須要多個異步操做依次執行時,就須要以回調嵌套的方式來實現,例如這樣:編程

ajax.get('xxx', dataA => { // 第一個請求完成後,依賴其獲取到的數據發送第二個請求 // 產生回調嵌套 ajax.get('yyy' + dataA, dataB => { console.log(dataB) // ... }) }) 

這樣一來,在處理越多的異步邏輯時,就須要越深的回調嵌套,這種編碼模式的問題主要有如下幾個:promise

  • 代碼邏輯書寫順序與執行順序不一致,不利於閱讀與維護。
  • 異步操做的順序變動時,須要大規模的代碼重構。
  • 回調函數基本都是匿名函數,bug 追蹤困難。
  • 回調函數是被第三方庫代碼(如上例中的 ajax )而非本身的業務代碼所調用的,形成了 IoC 控制反轉。

其中看似最可有可無的控制反轉,其實是純回調編碼模式的最大問題。 因爲回調函數是被第三方庫調用的,所以回調中的代碼沒法預期本身被執行時的環境 ,這可能致使:異步

  • 回調被執行了屢次
  • 回調一次都沒有被執行
  • 回調不是異步執行而是被同步執行
  • 回調被過早或過晚執行
  • 回調中的報錯被第三方庫吞掉
  • ……

經過【防護性編程】的概念,上述問題其實均可以經過在回調函數內部進行各類檢查來逐一避免,但這毫無疑問地會嚴重影響代碼的可讀性與開發效率。這種異步編碼模式存在的諸多問題,也就是臭名昭著的【回調地獄】了。函數式編程

Promise 較好地解決了這個問題。以上例中的異步 ajax 邏輯爲例,基於 Promise 的模式是這樣的:異步編程

// 將 ajax 請求封裝爲一個返回 Promise 的函數
function getData (){ return new Promise((resolve, reject) => { ajax.get('xxx', data => { resolve(data) }) }) } // 調用該函數並在 Promise 的 then 接口中獲取數據 getData().then(data => { console.log(data) }) 

看起來變得囉嗦了?但在上例中須要嵌套回調的狀況,能夠改寫成下面的形式:函數

function getDataA (){ return new Promise((resolve, reject) => { ajax.get('xxx', dataA => { resolve(dataA) }) }) } function getDataB (dataA){ return new Promise((resolve, reject) => { ajax.get('yyy' + dataA, dataB => { resolve(dataB) }) }) } // 使用鏈式調用解開回調嵌套 getDataA() .then(dataA => getDataB(dataA)) .then(dataB => console.log(dataB)) 

這就解決了異步邏輯的回調嵌套問題。那麼問題來了,這樣優雅的 API 是如何實現的呢?

基礎概念

很是籠統地說,Promise 其實應驗了 CS 的名言【全部問題均可以經過加一層中間層來解決】。在上面回調嵌套的問題中,Promise 就充當了一箇中間層,用來【把回調形成的控制反轉再反轉回去】。在使用 Promise 的例子中,控制流分爲了兩個部分:觸發異步前的邏輯經過 new傳入 Promise,而異步操做完成後的邏輯則傳入 Promise 的 then 接口中。經過這種方式,第一方業務和第三方庫的相應邏輯都由 Promise 來調用,進而在 Promise 中解決異步編程中可能出現的各類問題。

這種模式其實和觀察者模式是接近的。下面的代碼將 resolve / then 換成了 publish / subscribe ,將經過 new Promise 生成的 Promise 換成了經過 observe 生成的 observable 實例。能夠發現,這種調用一樣作到了回調嵌套的解耦。這就是 Promise 魔法的關鍵之一。

// observe 至關於 new Promise
// publish 至關於 resolve let observable = observe(publish => { ajax.get('xxx', data => { // ... publish(data) }) }) // subscribe 至關於 then observable.subscribe(data => { console.log(data) // ... }) 

到這個例子爲止,都尚未涉及 Promise 的源碼實現。在進一步深刻前,有必要列出在 Promise 中常見的相關概念:

  • resolve / reject : 做爲 Promise 暴露給第三方庫的 API 接口,在異步操做完成時由第三方庫調用,從而改變 Promise 的狀態。
  • fulfilled / rejected / pending : 標識了一個 Promise 當前的狀態。
  • then / done : 做爲 Promise 暴露給第一方代碼的接口,在此傳入【本來直接傳給第三方庫】的回調函數。

這些概念中有趣的地方在於,標識狀態的變量(如 fulfilled / rejected / pending )都是形容詞,用於傳入數據的接口(如 resolve 與 reject )都是動詞,而用於傳入回調函數的接口(如 then 及 done )則在語義上用於修飾動詞的副詞。在閱讀源碼的時候,除了變量的類型外,其名稱所對應的詞性也能對理解代碼邏輯起到幫助,例如:

  • 標識數據的變量與 OO 對象經常使用名詞( result / data / Promise )
  • 標識狀態的變量經常使用形容詞( fulfilled / pending )
  • 被調用的函數接口經常使用動詞( resolve / reject )
  • 用於傳入函數的參數接口經常使用副詞(如 then / onFulfilled 等,畢竟函數經常使用動詞,而副詞原本就是用來修飾動詞的)

預熱了 Promise 相關的變量名後,就能夠開始實現 Promise 了。下文的行文方式既不是按行號逐行介紹,也不是按代碼執行順序來回跳躍,而是按照實際編碼時的步驟一步步地搭建出相應的功能。相信這種方式比直接在源碼裏堆註釋能更爲友好一些。

狀態機

一個 Promise 能夠理解爲一個狀態機,相應的 API 接口要麼用於改變狀態機的狀態,要麼在到達某個狀態時被觸發。所以首先須要實現的,是 Promise 的狀態信息:

const PENDING = 0 const FULFILLED = 1 const REJECTED = 2 function Promise (){ // 存儲該 Promise 的狀態信息 let state = PENDING // 存儲 FULFILLED 或 REJECTED 時帶來的數據 let value = null // 存儲 then 或 done 時調用的成功或失敗回調 var handlers = [] } 

狀態遷移

指定狀態機的狀態後,能夠實現基本的狀態遷移功能,即 fulfill 與 reject 這兩個用於改變狀態的函數,相應實現也十分簡單:

const PENDING = 0 const FULFILLED = 1 const REJECTED = 2 function Promise (){ // 存儲該 Promise 的狀態信息 let state = PENDING // 存儲 FULFILLED 或 REJECTED 時帶來的數據 let value = null // 存儲 then 或 done 時調用的成功或失敗回調 let handlers = [] function fulfill (result){ state = FULFILLED value = result } function reject (error){ state = REJECTED value = error } } 

在這兩種底層的狀態遷移基礎上,咱們須要實現一種更高級的狀態遷移方式,這就是 resolve了:

const PENDING = 0 const FULFILLED = 1 const REJECTED = 2 function Promise (){ // 存儲該 Promise 的狀態信息 let state = PENDING // 存儲 FULFILLED 或 REJECTED 時帶來的數據 let value = null // 存儲 then 或 done 時調用的成功或失敗回調 let handlers = [] function fulfill (result){ state = FULFILLED value = result } function reject (error){ state = REJECTED value = error } function resolve (result){ try { let then = getThen(result) if (then) { // 遞歸 resolve 待解析的 Promise doResolve(then.bind(result), resolve, reject) return } fulfill(result) } catch (e) { reject(e) } } } 

resolve 既能夠接受一個 Promise,也能夠接受一個基本類型。當 resolve 一個 Promise 時,就使用 doResolve 輔助函數來執行這個 Promise 並等待其完成。經過暴露 resolve 而隱藏底層的 fulfill 接口,從而保證了一個 Promise 必定不會被另外一個 Promise 所 fulfill 。在這個過程當中所用到的輔助函數以下:

/**
 * 檢查一個值是否爲 Promise
 * 若爲 Promise 則返回該 Promise 的 then 方法
 *
 * @param {Promise|Any} value * @return {Function|Null} */ function getThen (value){ let t = typeof value if (value && (t === 'object' || t === 'function')) { const then = value.then // 可能須要更復雜的 thenable 判斷 if (typeof then === 'function') return then } return null } /** * 傳入一個需被 resolve 的函數,該函數可能存在不肯定行爲 * 確保 onFulfilled 與 onRejected 只會被調用一次 * 在此不保證該函數必定會被異步執行 * * @param {Function} fn 不能信任的回調函數 * @param {Function} onFulfilled * @param {Function} onRejected */ function doResolve (fn, onFulfilled, onRejected){ let done = false try { fn(function (value){ if (done) return done = true // 執行由 resolve 傳入的 resolve 回調 onFulfilled(value) }, function (reason){ if (done) return done = true onRejected(reason) }) } catch (ex) { if (done) return done = true onRejected(ex) } } 

resolve 接口

在完整完成了內部狀態機的基礎上,還須要向用戶暴露用於傳入第一方代碼的 new Promise接口,及傳入異步操做回調的 done / then 接口。下面從 resolve 一個 Promise 開始:

const PENDING = 0 const FULFILLED = 1 const REJECTED = 2 function Promise (fn){ // 存儲該 Promise 的狀態信息 let state = PENDING // 存儲 FULFILLED 或 REJECTED 時帶來的數據 let value = null // 存儲 then 或 done 時調用的成功或失敗回調 let handlers = [] function fulfill (result){ state = FULFILLED value = result } function reject (error){ state = REJECTED value = error } function resolve (result){ try { let then = getThen(result) if (then) { // 遞歸 resolve 待解析的 Promise doResolve(then.bind(result), resolve, reject) return } fulfill(result) } catch (e) { reject(e) } } doResolve(fn, resolve, reject) } 

能夠發現這裏重用了 doResolve 以執行不被信任的 fn 函數。這個 fn 函數能夠屢次調用 resolve 和 reject 接口,甚至拋出異常,但 Promise 中對其進行了限制,保證每一個 Promise 只能被 resolve 一次,且在 resolve 後再也不發生狀態轉移。

觀察者 done 接口

到此爲止已經完成了一個完整的狀態機,但仍然沒有暴露出一個合適的方法來觀察其狀態的變動。咱們的最終目標是實現 then 接口,但因爲實現 done 接口的語義要容易得多,所以可首先實現 done 。

下面的例子中要實現的是 promise.done(onFulfilled, onRejected) 接口,使得:

  • onFulfilled 與 onRejected 兩者只有一個被調用。
  • 該接口只會被調用一次。
  • 該接口老是被異步執行。
  • 調用 done 的執行時機與調用時 Promise 是否已 resolved 無關。
const PENDING = 0 const FULFILLED = 1 const REJECTED = 2 function Promise (fn){ // 存儲該 Promise 的狀態信息 let state = PENDING // 存儲 FULFILLED 或 REJECTED 時帶來的數據 let value = null // 存儲 then 或 done 時調用的成功或失敗回調 let handlers = [] function fulfill (result){ state = FULFILLED handlers.forEach(handle) handlers = null } function reject (error){ state = REJECTED value = error handlers.forEach(handle) handlers = null } function resolve (result){ try { let then = getThen(result) if (then) { // 遞歸 resolve 待解析的 Promise doResolve(then.bind(result), resolve, reject) return } fulfill(result) } catch (e) { reject(e) } } // 保證 done 中回調的執行 function handle (handler){ if (state === PENDING) { handlers.push(handler) } else { if (state === FULFILLED && typeof handler.onFulfilled === 'function') { handler.onFulfilled(value) } if (state === REJECTED && typeof handler.onRejected === 'function') { handler.onRejected(value) } } } this.done = function (onFulfilled, onRejected){ // 保證 done 老是異步執行 setTimeout(function (){ handle({ onFulfilled: onFulfilled, onRejected: onRejected }) }, 0) } doResolve(fn, resolve, reject) } 

從而在 Promise 的狀態遷移至 resolved 或 rejected 時,全部經過 done 註冊的觀察者 handler 都能被執行。而且這個操做老是在下一個 tick 異步執行的。

觀察者 then 方法

在實現了 done 方法的基礎上,就能夠實現 then 方法了。它們沒有本質的區別,但 then 可以返回一個新的 Promise:

this.then = function (onFulfilled, onRejected){ const _this = this return new Promise(function (resolve, reject){ return _this.done(function (result){ if (typeof onFulfilled === 'function') { try { return resolve(onFulfilled(result)) } catch (ex) { return reject(ex) } } else return resolve(result) }, function (error){ if (typeof onRejected === 'function') { try { return resolve(onRejected(error)) } catch (ex) { return reject(ex) } } else return reject(error) }) }) } 

最後梳理一下典型場景下 Promise 的執行流程。以一個 ajax 請求的異步場景爲例,整個異步邏輯分爲兩部分:調用 ajax 庫的代碼及異步操做完成時的代碼。前者被放入 Promise 的構造函數中,由 doResolve 方法執行,在這部分業務邏輯經過調用 resolve 與 reject 接口,在異步操做完成時改變 Promise 的狀態,從而調用後者,即調用 Promise 中經過 then 接口傳入的 onFulfilled 與 onRejected 後續業務邏輯代碼。這個過程當中, doResolve 對第三方 ajax 庫的各類異常行爲(屢次調用回調或拋出異常)作了限制,而 then 下隱藏的 done 則封裝了 handle 接口,保證了多個經過 then 傳入的 handler 老是異步執行,並能獲得合適的返回結果。因爲then 中的代碼老是異步執行並返回了一個新的 Promise,所以能夠經過鏈式調用的方式來串聯多個 then 方法,從而實現異步操做的鏈式調用。

總結

閱讀了 Promise 的代碼實現後能夠發現,它的魔法來自於將【函數一等公民】和【遞歸】的結合。一個 resolve 若是得到的結果仍是一個 Promise,那麼就將遞歸地繼續 resolve 這個 Promise。同時,Promise 的輔助函數中解決了諸多異步編程時的常見問題,如回調的屢次調用及異常處理等。

介紹 Promise 時很多較爲晦澀的 API 其實也來自於對 Promise 編碼實現時的涉及的若干底層功能。例如, fulfilled 這個概念就被封裝在了 resolve 下,而 done 方法則是 then 方法的依賴等。這些概念在 Promise 的演化中被封裝在了通用的 API 下,只有在閱讀源碼時纔會用到。Promise 的 API 設計也是簡潔的,其接口命名和英語的詞性也有至關大的聯繫,這也有利於理解代碼實現的相應功能。

除了上文中從狀態機的角度理解 Promise 之外,其實還能夠從函數式編程的角度來理解這個模式。能夠將 Promise 看作一個封裝了異步數據的 Monad,其 then 接口就至關於這個 Monad 的map 方法。這樣一來,Promise 也能夠理解爲一個特殊的對象,這個對象【經過一個函數獲取數據,並經過另外一個函數來操做數據】,用戶並不須要關心其中潛在的異步風險,只須要提供相應的函數給 Promise API 便可(這展開又是一篇長文了)。

但願本文對 Promise 的分析對理解異步編程有所幫助。

相關文章
相關標籤/搜索