Promise 物件建立與基本使用

ES6 Promise 的實作中,會確保 Promise 物件一經實體化後就會固定住狀態,要不就是"已實現",要不就是"已拒絕"。

一個簡單的 Promise 語法結構如下:

const promise = new Promise(function(resolve, reject) {
  // 成功時
  resolve(value)
  // 失敗時
  reject(reason)
})

promise.then(
  function(value) {
    // on fulfillment(已實現時)
  },
  function(reason) {
    // on rejection(已拒絕時)
  }
)

首先先看 Promise 的建構函式,它的語法如下(來自MDN):

new Promise( function(resolve, reject) { ... } )

用箭頭函式可以簡化一下:

new Promise( (resolve, reject) => { ... } )

建構函式的傳入參數值需要一個函式。

這個函式中又有兩個傳入參數值,resolve(解決)與reject(拒絕)都是要求一定是函式類型。成功的話,也就是有合法值的情況下執行resolve(value),promise 物件的狀態會跑到 fulfilled(已實現)固定住。失敗或是發生錯誤時用執行reject(reason),reason(理由)通常是用 Error 物件,然後 promise 物件的狀態會跑到 rejected(已拒絕)狀態固定住。

這個建構函式的傳入函式稱為 executor(執行者, 執行函式),這有強制執行的意味。這是一種名稱為暴露的建構式樣式 Revealing Constructor Pattern的樣式。executor 會在建構式回傳物件實體前立即執行,也就是說當傳入這個函式時,Promise 物件會立即決定裡面的狀態,看是要執行resolve來回傳值,還是要用reject來作錯誤處理。也因為它與一般的物件實體化的過程不太一樣,所以常會先包到一個函式中,使用時再呼叫這個函式來產生 promise 物件,例如像下面這樣的程式碼:

function asyncFunction(value) {
  return new Promise(function(resolve, reject) {
    if (value) resolve(value)
    // 已實現,成功
    else reject(reason) // 有錯誤,已拒絕,失敗
  })
}

Promise 建構函式與 Promise.prototype 物件的設計目的,主要是要讓開發者作 Promisify 使用的,也就是要把原本異步或同步的程式碼或函式,打包成為 Promise 物件後在程式碼裡使用。經過包裝之後才能使用 Promise 的語法結構,也就是我們所說的異步函式執行流程的語法結構。

你可能會認為在new Promise(function(resolve, reject){...})中的resolvereject兩個傳入參數值的名稱,就一定要是這樣。而且學到最後面,最會讓人搞混的是 Promise 物件中也有兩個靜態方法(說明在下面章節),名稱也恰好是Promise.resolvePromise.reject

正確的解答是,"並不是一定要這樣命名"。這兩個名稱只是對照之後執行時,為了閱讀性或習慣上這樣命名而已。你可以執行下面的範例,看能不能執行:

const promise = new Promise(function(resolveParam, rejectParam) {
  //resolveParam(1)
  rejectParam(new Error('error!'))
})

promise
  .then(value => {
    console.log(value) // 1
    return value + 1
  })
  .then(value => {
    console.log(value) // 2
    return value + 2
  })
  .catch(err => console.log(err.message))

為什麼可以換傳入參數值的名稱?要回答這個問題,要先來解說一下進入 Promise 建構函式的大概執行流程,當然下面都是簡化過的說明:

  1. 先用一個內部的物件,我把它稱為雛形物件,然後狀態(state)設定為 pending(等待中),值(value)為 undefined,理由(reason)為 undefined

  2. 再來初始化這個物件的工作,用init(promise, resolver)函式,傳入建構式中的傳入參數,也就是function(resolve, reject){...}當作resolver(解決者,解決用函式)傳入參數。

  3. 把真正實作的 Promise 中的兩個內部_resolve函式,與_reject函式,對映到init(promise, resolver)中執行。

下面是把步驟說明實作出來的範例,不過這個只是為了方便解說用的,並不是真正可用的 Promise 建構函式,參考自es6-promise:

//內部用的雛形物件,實作上包含在建構式中用this
const pInternal = {
  state: 'pending',
  value: undefined,
  reason: undefined,
}

//這個就是稱為executor的傳入參數
function resolver(resolve, reject) {
  resolve(10)
  //reject(new Error('error occured !'))
}

//初始化內部雛形物件用的函式
function init(promise, resolver) {
  try {
    resolver(
      function resolvePromise(value) {
        _resolve(promise, value)
      },
      function rejectPromise(reason) {
        _reject(promise, reason)
      }
    )
  } catch (e) {
    _reject(promise, e)
  }

  return promise
}

//隱藏在內部的私有函式
function _resolve(promise, value) {
  console.log(value)
  promise.state = 'onFulfilled'
  promise.value = value
}

//隱藏在內部的私有函式
function _reject(promise, reason) {
  console.log(reason)
  promise.state = 'onRejected'
  promise.reason = reason
}

//最後生成回傳的promise物件
const promise = init(pInternal, resolver)
console.log(promise)

以上面的範例來說,resolver函式在init中被呼叫時,resolver的第 1 個傳入參數,它的執行程式碼內容會被init中的resolver呼叫時的第一個傳入參數resolvePromise所取代,然後再加上init函式傳入參數promise物件,最後呼叫執行_resolve(promise, value)這個內部私有方法。也就是說這只是一個函式傳入參數的代換過程。所以如果你改成這樣也是一樣的結果:

//這個就是稱為executor的傳入參數
function resolver(rs, rj) {
  rs(10)
  //rj(new Error('error occured !'))
}

//初始化內部雛形物件用的函式
function init(promise, resolver) {
  //改用匿名函式
  resolver(
    function(value) {
      _resolve(promise, value)
    },
    function(reason) {
      _reject(promise, reason)
    }
  )

  return promise
}

或用箭頭函式來更簡化程式碼:

//這個就是稱為executor的傳入參數
function resolver(rs, rj) {
  rs(10)
  //rj(new Error('error occured !'))
}

//初始化內部雛形物件用的函式
function init(promise, resolver) {
  //改用匿名函式與箭頭函式
  resolver(
    value => _resolve(promise, value),
    reason => _reject(promise, reason)
  )
  return promise
}

那麼 executor(執行者,執行函式)是必要的傳入參數值嗎?是的,如果你沒傳入任何的參數,會產生一個類型錯誤,錯誤訊息中有一個 resolver(解決者,解決函式)字詞,它應該算是 executor 的別名(或者是第一個函式型傳入參數的名稱?),如果你傳入一個沒有任何內容的空白函式,雖然不會有錯誤發生,但會產生一個完全無用的 Promise 物件:

const promise = new Promise()
//Uncaught TypeError: Promise resolver undefined is not a function

const promise = new Promise(function() {})
//不會有錯誤,但會產生一個完全無用的promise,無法改變狀態
//Promise {[[PromiseStatus]]: "pending", [[PromiseValue]]: undefined}

另外,Promise 物件中的兩個靜態方法,Promise.resolvePromise.reject,它們的實作是雖然接近上面範例中的_resolve_reject內部方法,但定義中都說它是與then回傳出來的 Promise 物件相同,請不要搞混了。下面的章節會專門來討論它們的用法。

你可能會覺得很奇怪,為何要一定要用這種方式來建構一個 promise 的物件實體?而且實際上這是一種相當高消費的語法(三個閉包)。有幾個原因:

  1. 暴露的建構式樣式: Promise 只是個用來包裹現有函式或程式語句的物件,所以把建構式外露出來給開發者自行定義其中的程式碼,稱之為"暴露的建構式樣式(Revealing Constructor Pattern)"。

  2. 封裝: Promise 物件不外露狀態,也無法從外部程式碼中直接修改其狀態,狀態由 executor 的執行結果決定。此外,Promise 物件一旦被固定住兩種其一的狀態,就無法再改變狀態,這也是這種樣式在致力保護的原則。

  3. Throw safety(安全性): 確保在建構函式在執行過程時,如果有 throw 例外的情況也是安全的,能作異步的錯誤處理。在混用同步/異步程式之後,可能會產生的問題,稱之為 Zalgo

註: 有一些術語或形容詞的意思大概都是相同的,fulfilled(已實現)、resolve(解決)、successful(成功的)、completed(完成的)差不多意思,尤其有些外部函式庫,會 使用fulfill代替這裡的resolve。而 rejected(已拒絕)、fail(失敗)、error(錯誤)是相反的一個意思。

then 與 catch

then 方法是 Promise 的最核心方法,標準規則有極大一部份都是在定義 then 方法

在 Promise 的標準中,一直不斷的提到一個方法 - then,中文是"然後、接著、接下來"的意思,這個是一個 Promise 的重要方法。有定義 then 方法的物件被稱之為thenable物件,標準中花了一個章節在講then方法的規格,它的語法如下(出自MDN):

p.then(onFulfilled, onRejected)

p.then(
  function(value) {
    // fulfillment
  },
  function(reason) {
    // rejection
  }
)

then方法一樣用兩個函式當作傳入參數,onFulfilled是當 promise 物件的狀態轉變為 fulfilled(已實現) 呼叫的函式,有一個傳入參數值可用,就是 value(值)。而onRejected是當 promise 物件的狀態轉變為 rejected(已拒絕) 呼叫的函式,也有一個傳入參數值可以用,就是 reason(理由)。

為什麼說它是"一樣"?因為比對到 promise 物件的建構式,與那個傳入的函式參數值的樣子非常像,也是兩個函式當作傳入參數,只是名稱的定義上有點不同,但意義接近。

那麼then方法最後的回傳值是什麼?是另一個"新的"promise 物件。

Promises/A+標準 2.2.7

then必須回傳一個 promise。promise2 = promise1.then(onFulfilled, onRejected);

這樣設計的主要目的,是要能作連鎖(chained)的語法結構,也就是稱之為合成(composition)的一種運算方式,在 JavaScript 中如果回傳值相同的函式,可以使用連鎖的語法。像下面這樣的程式碼:

const promise = new Promise(function(resolve, reject) {
  resolve(1)
})

promise
  .then(function(value) {
    console.log(value) // 1
    return value + 1
  })
  .then(function(value) {
    console.log(value) // 2
    return value + 2
  })
  .then(function(value) {
    console.log(value) // 4
  })

then方法中的 onFulfilled 函式,也就是第一個函式傳入參數,它是有值時使用的函式,經過連鎖的結構,如果要把值往下傳遞,可以用回傳值的方式,上面的例子可以看到用return語句來回傳值,這個值可以繼續的往下面的then方法傳送。

onRejected 函式,也就是then方法中第二個函式的傳入參數,也有用回傳值往下傳遞的特性,不過因為它是使用於錯誤的處理,除非你是有要用來修正錯誤之類的理由,不然說實在這樣作會有點混亂,因為不論是 onFulfilled 函式或 onRejected 函式的傳遞值,都只會記錄到新產生的那個 Promise 物件之中,對"值"來說並沒有區分是 onFulfilled 回傳的來的,還是 onRejected 回傳來的。當一直有回傳值時就可以一直傳遞回傳值,當出現錯誤時,就會因為獲取不到之前的值,會導致之前的值消失不見。

then方法中的兩個函式傳入參數,第 1 個 onFulfilled 函式是在 promise 物件有值的情況下才會執行,也就是進入到 fulfilled(已實現)狀態。第 2 個 onRejected 函式則是在 promise 物件發生錯誤、失敗,才會執行。這兩個函式都可以寫出來,但為了方便進行多個不同程式碼的連鎖,通常在只使用then方法時,都只寫第 1 個函式傳入參數,也就是只用 onFulfilled 函式這個傳入參數值。

而對於錯誤處理,通常交給另一個catch方法來作,catch只需要一個函式傳入參數,(出自MDN):

p.catch(onRejected)

p.catch(function(reason) {
  // rejection
})

catch方法相當於then(undefined, onRejected),也就是then方法的第一個函式傳入參數沒有給定值的情況,它算是個then方法的語法糖。catch方法正如其名,它就是要取代同步try...catch語句用的異步例外處理方式。

註: 也因為catch方法與try...catch中的catch同名,這會造成 IE8 以下的瀏覽器產生名稱上的衝突與錯誤。有些函式庫會用caught這個名稱來取代它,或是乾脆用then方法就好。

對於值的傳遞情況,需要注意是在中途發生有 promise 物件有 rejected(已拒絕)的情況。在下面的範例中,第二個then方法假設在中途發生錯誤,這樣會導致下一個執行被強迫只能使用catch方法,catch方法中的回調(相當於then中的 onRejected 函式)是只能得到理由(錯誤)而得不到值的,所以導致最後一個then方法中的 onFulfilled 函式獲取不到之前的值。這一段內容如果你並不是很理解,你可以先看其他章節的內容,再回來這裡重新執行一次這個範例,程式碼如下:

const p1 = new Promise((resolve, reject) => {
  resolve(4)
})

p1.then(val => {
  console.log(val) //4
  return val + 2
})
  .then(val => {
    console.log(val) //6
    throw new Error('error!')
  })
  .catch(err => {
    //catch無法抓到上個promise的回傳值
    console.log(err.message)
    //這裡如果有回傳值,下一個then可以抓得到
    //return 100
  })
  .then(val => console.log(val, 'done')) //val是undefined,回傳值消息

以上是有關於 then 與 catch 兩個重要方法的基本使用介紹。初次學習時,要仔細看清楚範例中的程式碼是怎麼用這兩個方法的,以及各相關的說明,這些細節和概念先務必掌握,對於未來深入的學習會非常有幫助。

Last updated