寫一個符合 Promises/A+ 規範並可配合 ES7 async/await 使用的 Promise

FTAndy發表於2016-10-30

原文地址


從歷史的程式來看,Javascript 非同步操作的基本單位已經從 callback 轉換到 Promise。除了在特殊場景使用 streamRxJs 等更加抽象和高階的非同步資料獲取和操作流程方式外,現在幾乎任何成熟的非同步操作庫,都會實現或者引用 Promise 作為 API 的返回單位。主流的 Javascript 引擎也基本原生實現了 Promise。

在 Promise 遠未流行以前,Javascript 的非同步操作基本都在使用以 callback 為主的非同步介面。滑鼠鍵盤事件,頁面渲染,網路請求,檔案請求等等非同步操作的回撥函式都是用 callback 來處理。隨著非同步使用場景範圍的擴大,出現了大量工程化和富應用的的互動和操作,使得應用不足以用 callback 來面對愈加複雜的需求,慢慢出現了許多優雅和先進的非同步解決方案:EventEmitterPromiseWeb WorkerGeneratorAsync/Await

目前 Javascript 在客戶端和伺服器端的應用當中,只有 Promise 被廣泛接受並使用。追根溯源,Promise 概念的提出是在 1976 年,Javascript 最早的 Promise 實現是在 2007 年,到現在 2016 年,Promise/A+ 規範和 ECMAscript 規範提出的 API 也足夠穩定。then, reject, all, spread, race, finally 都是工程師開發中經常用到的 Promise API。很多人剛接觸 Promise 概念的時候看下 API,看幾篇部落格或者看幾篇最佳實踐就以為理解程度夠了,但是對 Promise 內部的非同步機制不明瞭,使得在開發過程中遇到不少坑或者懵逼。

本文旨在讓讀者能深入瞭解 Promise 內部執行機制,熟悉和掌握 Promise 的操作流。如有興趣,可以繼續往下讀。

Promise 只是一個 Event Loop 中的 microtask

深入瞭解過 Promise 的人都知道,Promise 所說的非同步執行,只是將 Promise 建構函式中 resolvereject 方法和註冊的 callback 轉化為 eventLoop的 microtask/Promise Job,並放到 Event Loop 佇列中等待執行,也就是 Javascript 單執行緒中的“非同步執行”。

Promise/A+ 規範中,並沒有明確是以 microtask 還是 macrotask 形式放入佇列,對沒有 microtask 概念的宿主環境採用 setTimeout 等 task/Job 類的任務。規範中另外明確的一點也非常重要:回撥函式的非同步呼叫必須在當前 context,也就是 JS stack 為空之後執行。

在最新的 ECMAScript 規範 中,明確了 Promise 必須以 Promise Job 的形式入 Job 佇列(也就是 microtask),並僅在沒有執行的 stack(stack 為空的情況下)才可以初始化執行。

HTML 規範 也提出,在 stack 清空後,執行 microtask 的檢查方法。也就是必須在 stack 為空的情況下才能執行。

Google Chrome 的開發者 Jake Archibald (ES6-promise 作者)的文章 Tasks, microtasks, queues and schedules中,將這個區分的問題描述得很清楚。假如要在 Javascript 平臺或者引擎中實現 Promise,優先以 microtask/Promise Job 方式實現。目前主流瀏覽器的 Javascript 引擎原生實現,主流的 Promise 庫(es6-promise,bluebrid)基本都是使用 microtask/Promise Job 的形式將 Promise 放入佇列。

其他以 microtask/Promise Job 形式實現的方法還有:process.nextTicksetImmediatepostMessageMessageChannel

根據規範,microtask 存在的意義是:在當前 task 執行完,準備進行 I/O,repaintredraw 等原生操作之前,需要執行一些低延遲的非同步操作,使得瀏覽器渲染和原生運算變得更加流暢。這裡的低延遲非同步操作就是 microtask。原生的 setTimeout 就算是將延遲設定為 0 也會有 4 ms 的延遲,會將一個完整的 task 放進佇列延遲執行,而且每個 task 之間會進行渲染等原生操作。假如每執行一個非同步操作都要重新生成一個 task,將提高宿主平臺的負擔和響應時間。所以,需要有一個概念,在進行下一個 task 之前,將當前 task 生成的低延遲的,與下一個 task 無關的非同步操作執行完,這就是 microtask。

這裡的 Quick Sort Demo 展示了 microtask 和 task 在延遲執行上的巨大區別。

對於在不通宿主環境中選擇合適的 microtask,可以選擇 asapsetImmediate 的程式碼作為參考。

Promise 的中的同步與非同步

new Promise((resolve) => {
  console.log('a')
  resolve('b')
  console.log('c')
}).then((data) => {
  console.log(data)
})

// a, c, b複製程式碼

使用過 Promise 的人都知道輸出 a, c, b,但有多少人可以清楚地說出從建立 Promise 物件到執行完回撥的過程?下面是一個完整的解釋:

建構函式中的輸出執行是同步的,輸出 a, 執行 resolve 函式,將 Promise 物件狀態置為 resolved,輸出 c。同時註冊這個 Promise 物件的回撥 then 函式。整個指令碼執行完,stack 清空。event loop 檢查到 stack 為空,再檢查 microtask 佇列中是否有任務,發現了 Promise 物件的 then 回撥函式產生的 microtask,推入 stack,執行。輸出 b,event loop的列隊為空,stack 為空,指令碼執行完畢。

以基礎的 Promises/A+ 規範為範本

規範地址:

值得注意的是:

Finally, the core Promises/A+ specification does not deal with how to create, fulfill, or reject promises, choosing instead to focus on providing an interoperable then method. Future work in companion specifications may touch on these subjects.

Promises/A+ 規範主要是制定一個通用的回撥方法 then,使得各個實現的版本可以形成鏈式結構進行回撥。這使得不同的 Promise 庫內部細節實現可能不一樣,但是隻有具有想通的 then 方法,返回的 Promise API 之間就可以相互呼叫。

下面會實現一個簡單的 Promise,不想看實現的可以跳過。專案地址在這裡,歡迎更多討論。

Promise 建構函式,選擇平臺的 microtask 實現

// Simply choose a microtask
const asyncFn = function() {
  if (typeof process === 'object' && process !== null && typeof(process.nextTick) === 'function')
    return process.nextTick
  if (typeof(setImmediate === 'function'))
    return setImmediate
  return setTimeout
}()

// States
const PENDING = 'PENDING'

const RESOLVED = 'RESOLVED'

const REJECTED = 'REJECTED'

// Constructor
function MimiPromise(executor) {
  this.state = PENDING
  this.executedData = undefined
  this.multiPromise2 = []

  resolve = (value) => {
    settlePromise(this, RESOLVED, value)
  }

  reject = (reason) => {
    settlePromise(this, REJECTED, reason)
  }

  executor(resolve, reject)
}複製程式碼

stateexecutedData 都容易理解,但是必須要理解一下為什麼要維護一個 multiPromise2 陣列。由於規範中說明,每個呼叫過 then 方法的 promise 物件必須返回一個新的 promise2 物件,所以最好的方法是當呼叫 then 方法的時候將一個屬於這個 then 方法的 promise2 加入佇列,在 promise 物件中維護這些新的 promise2 的狀態。

  • executorpromise 建構函式的執行函式引數
  • statepromise 的狀態
  • multiPromise2:維護的每個註冊 then 方法需要返回的新 promise2
  • resolve:函式定義了將物件設定為 RESOLVED 的過程
  • reject:函式定義了將物件設定為 REJECTED 的過程

最後執行建構函式 executor,並呼叫 promise 內部的私有方法 resolvereject

settlePromise 如何將一個新建的 Promise settled

function settlePromise(promise, executedState, executedData) {
  if (promise.state !== PENDING)
    return

  promise.state = executedState
  promise.executedData = executedData

  if (promise.multiPromise2.length > 0) {
    const callbackType = executedState === RESOLVED ? "resolvedCallback" : "rejectedCallback"

    for (promise2 of promise.multiPromise2) {
      asyncProcessCallback(promise, promise2, promise2[callbackType])
    }
  }
}複製程式碼

第一個判斷條件很重要,因為 Promise 的狀態是不可逆的。在 settlePromise 的過程中假如狀態不是 PENDING,則不需要繼續執行下去。

當前 settlePromise 的環境,可以有三種情況:

  • 非同步延遲執行 settlePromise 方法,執行緒已經同步註冊好 then 方法,需要執行所有註冊的 then 回撥函式
  • 同步執行 settlePromise 方法,then 方法未執行,後面需要執行的 then 方法會在註冊的過程中直接執行
  • 無論執行非同步 settlePromise 還是同步 settlePromise 方法,並沒有註冊的 then 方法需要執行,只需要將本 Promise 物件的狀態設定好即可

then 方法的註冊和立即執行

MimiPromise.prototype.then = function(resolvedCallback, rejectedCallback) {
  let promise2 = new MimiPromise(() => {})

  if (typeof resolvedCallback === "function") {
      promise2.resolvedCallback = resolvedCallback;
  }
  if (typeof rejectedCallback === "function") {
      promise2.rejectedCallback = rejectedCallback;
  }

  if (this.state === PENDING) {
    this.multiPromise2.push(promise2)
  } else if (this.state === RESOLVED) {
    asyncProcessCallback(this, promise2, promise2.resolvedCallback)
  } else if (this.state === REJECTED) {
    asyncProcessCallback(this, promise2, promise2.rejectedCallback)
  }

  return promise2
}複製程式碼

每個註冊 then 方法都需要返回一個新的 promise2 物件,根據當前 promise 物件的 state,會出現三種情況:

  • 當前 promise 物件處於 PENDING 狀態。建構函式非同步執行了 settlePromise 方法,需要將這個 then 方法對應返回的 promise2 放入當前 promisemultiPromise2 佇列當中,返回這個 promise2。以後當 settlePromise 方法非同步執行的時候,執行全部註冊的 then 回撥方法
  • 當前 promise 物件處於 RESOLVED 狀態。建構函式同步執行了 settlePromise 方法,直接執行 then 註冊的回撥方法,返回 promise2
  • 當前 promise 物件處於 REJECTED 狀態。建構函式同步執行了 settlePromise 方法,直接執行 then 註冊的回撥方法,返回 promise2

非同步執行回撥函式

function asyncProcessCallback(promise, promise2, callback) {
  asyncFn(() => {
    if (!callback) {
      settlePromise(promise2, promise.state, promise.executedData);
      return;
    }

    let x

    try {
      x = callback(promise.executedData)
    } catch (e) {
      settlePromise(promise2, REJECTED, e)
      return
    }

    settleWithX(promise2, x)
  })
}複製程式碼

這裡用到我們之前選取的平臺非同步執行函式,非同步執行 callback。假如 callback 沒有定義,則將返回 promise2 的狀態轉換為當前 promise 的狀態。然後將 callback 執行。最後再 settleWithX promise2 與 callback 返回的物件 x

最後的 settleWithX 和 settleXthen

function settleWithX (p, x) {
    if (x === p && x) {
        settlePromise(p, REJECTED, new TypeError("promise_circular_chain"));
        return;
    }

    var xthen, type = typeof x;
    if (x !== null && (type === "function" || type === "object")) {
        try {
            xthen = x.then;
        } catch (err) {
            settlePromise(p, REJECTED, err);
            return;
        }
        if (typeof xthen === "function") {
            settleXthen(p, x, xthen);
        } else {
            settlePromise(p, RESOLVED, x);
        }
    } else {
        settlePromise(p, RESOLVED, x);
    }
    return p;
}

function settleXthen (p, x, xthen) {
    try {
        xthen.call(x, function (y) {
            if (!x) return;
            x = null;

            settleWithX(p, y);
        }, function (r) {
            if (!x) return;
            x = null;

            settlePromise(p, REJECTED, r);
        });
    } catch (err) {
        if (x) {
            settlePromise(p, REJECTED, err);
            x = null;
        }
    }
}複製程式碼

這裡的兩個方法對應 Promise/A+ 規範裡的第三章,由於實在太囉嗦,這裡就不再過多解釋了。

配合 async/await 使用更加美味

V8 已經原生實現了 async/await,Node 和各瀏覽器引擎的實現也會慢慢跟進,而 babel 早就加入了 async/await。目前客戶端還是用 babel 預編譯使用比較好,而 Node 需要升級到 v7 版本,並且加入 --harmony-async-await 引數。

Promise 其中的一個侷限在於:所有操作過程都必須包含在建構函式或者 then 回撥中執行,假如有一些變數需要累積向下鏈式使用,還要加入外部全域性變數,或者引起回撥地獄,像這樣。

let result1
let result2
let result3

getSomething1()
  .then((data) => {
    result1 = data
    // do some shit with result1
    return getSomething2()
  })
  .then((data) => {
    result2 = data
    // do some other shit with result1 and result2
    return getSomething3()
  })
  .then((data) => {
    result3 = data
    // do some other shit with result1, result2 and result3
  })
  .catch((err) => {
    console.error(err);
  })

getSomething1()
  .then((data1) => {
    // do some shit with data1
    return getSomething2()
    .then((data2) => {
      // do some shit with data1 and data2
      return getSomething3()
      .then((data3) => {
        // do some shit with data1, data2 and data3
      })
    })
  })
  .catch((err) => {
    console.error(err);
  })複製程式碼

引入了全域性變數和寫出了回撥地獄都不是明智的做法,假如用了 async/await,可以這樣:

async function a() {
  try {
    const result1 = await getSomething1()
    // do some shit with result1
    const result2 = await getSomething2()
    // do some other shit with result1 and result2
    const result3 = await getSomething3()
    // do some other shit with result1, result2 and result3
  } catch (e) {
    console.error(e);
  }
}複製程式碼

async/await 配合 Promise,沒有了 then 方法和回撥地獄的寫法是不是清爽了很多?

結語

本文後續其實還有更多值得挖掘的地方:

  • 如何更加有效地選取平臺的 microtask?
  • 如何實現一個可用的符合 ECMAScript 規範的 Promise?
  • microtask 和 task 在 event loop 具體的執行過程?

可以期待後續的更多內容。最後再貼一下專案地址,歡迎繼續的討論。

參考資料

相關文章