/ JavaScript

JavaScript Promises ... In Wicked Detail

This post is Traditional Chinese version of JavaScript Promises ... In Wicked Detail
原文請參考:JavaScript Promises ... In Wicked Detail


我在我的JavaScript程式碼中使用promises有一陣子了。它們一開始會讓你的腦袋有些扭曲。我現在還蠻有效率的在使用它們,但是,一旦冷靜下來之後,我發覺我並不完全了解它是如何運作的。這篇文章是我要了解的決心。如果你持續的讀到最後,你應該也能夠跟我一樣更了解些promises。

我們將會持續漸進的建立一個promises實作,到最後將會幾乎符合Promises/A+的規格,並且在實作的過程中了解promises是如何合適於非同步程式設計的需求。本文假設你已經對promises有些熟悉。如果沒有,promisejs.org是個很好的網站,值得你看看。

目錄

  1. 為什麼?
  2. 最簡單的使用案例
  1. Promises具有狀態
  2. 串連Promises
  1. 拒絕Promises
  1. Promise提案需要非同步
  1. 在我們包裝then/promise之前
  2. 結論
  3. 延伸閱讀
  4. 翻譯

為什麼?

為什麼我們要操心去了解到這種程度的promises?真正的了解某些東西如何運作,可以提昇你的能力並從中獲得好處,也可以讓你遭遇錯誤的時候可能更成功的去除錯。因為有次跟同伴卡在一個狡猾的promise狀況,進而啟發我寫下這篇文章。如果當下的我了解promise,那我就不會卡在這個狀況了。

最簡單的使用案例

讓我們開始我們的promise實作,盡可能的簡單。我們希望從這開始

doSomething(function(value) {
  console.log('Got a value:' + value);
});

一直到

doSomething().then(function(value) {
  console.log('Got a value:' + value);
});

要達到這樣的結果,我們只要將doSomething()函式

function doSomething(callback) {
  var value = 42;
  callback(value);
}

改成這種“promise”基礎的方案

function doSomething() {
  return {
    then: function(callback) {
      var value = 42;
      callback(value);
    }
  };
}

fiddle

這是給回呼函式模式的一點小小甜頭。目前為止也是個無意義的甜頭。但這只是個開始,且我們也還未正中promises背後核心概念。

Promises捕捉最後結果值的概念到一個物件之中

這是為什麼promises這麼有趣的主要原因。一旦最後的概念有像這樣捕捉,我們就可以開始某些非常有力的事。我們在後面會有更多的探索。

定義Promise類型

這個簡單的物件字面不會就此打住。讓我們接著定義一個實際的Promise類型,我們將可以擴展成

function Promise(fn) {
  var callback = null;
  this.then = function(cb) {
    callback = cb;
  };

  function resolve(value) {
    callback(value);
  }

  fn(resolve);
}

並重新實作doSomething()

function doSomething() {
  return new Promise(function(resolve) {
    var value = 42;
    resolve(value);
  });
}

這裡有個問題。如果透過執行來追蹤它,你會發現resolve()會在then()之前被呼叫,表示callback將會是null。讓我們隱藏這個問題,用一點小小的技巧,呼叫setTimeout

function Promise(fn) {
  var callback = null;
  this.then = function(cb) {
    callback = cb;
  };

  function resolve(value) {
    // 強迫讓回呼函式在下一個事件迴圈中被呼叫,
    // 讓回呼函式有機會被then()設定
    setTimeout(function() {
      callback(value);
    }, 1);
  }

  fn(resolve);
}

fiddle

在適當位置做了點手腳,程式碼現在可以運作了…有幾分地.

這樣的程式碼是易碎且不好的

天真的我們,差勁的promis實作必須使用非同步來運作。這樣很容易讓它再次失效,只要非同步的呼叫then(),我們馬上再次讓回呼函式成為null。為什麼我讓你這麼快的失敗?因為上述的實作方式有個好處,就是很容易包圍你的大腦。then()resolve()不會消失。它們是promises的關鍵概念。

Promises具有狀態

我們上述易碎的程式碼暴露了某些非預期的事情。Promises具有狀態。我們在行動之前需要知道它現在的狀態,並且確認我們正確地在狀態中的移動。這樣一來就擺脫了易碎性。

  • Promise可以是擱置等待某個值,或者解決帶著某個值。
  • 一旦promise解決成了某個值,它將永遠保持在那個值,而且不會再次被解決。

(Promise也以被拒絕,我們將會在稍候的錯誤處理提到)

讓我們明確地在我們實作中追蹤狀態,讓我們可以擺脫先前的問題

function Promise(fn) {
  var state = 'pending';
  var value;
  var deferred;

  function resolve(newValue) {
    value = newValue;
    state = 'resolved';

    if(deferred) {
      handle(deferred);
    }
  }

  function handle(onResolved) {
    if(state === 'pending') {
      deferred = onResolved;
      return;
    }

    onResolved(value);
  }

  this.then = function(onResolved) {
    handle(onResolved);
  };

  fn(resolve);
}

fiddle

這樣一來就更複雜了一些,但我們的呼叫端就可以隨意的調用then(),被叫端也可以隨意的調用resolve()。完全適用於同步或非同步的程式碼中。

這是因為state旗標的關係。then()resolve()兩者不干涉新的handle()方法,而新方法將會根據情況不同進行以下其中一種動作:

  • 呼叫端在被叫端執行resolve()之前呼叫了then(),表示當下並沒有準備好將結果值交出。在這個情況下,狀態仍然處於擱置中,所以我們在呼叫端回呼函式內持有著,準備給之後使用。之後當resolve()被呼叫時,我們可以調用回呼函式,並且將結果值帶入。
  • 被叫端在呼叫端執行then()之前呼叫了resolve():這種情況下,我們在結果值中持有著。一旦then()被呼叫,我們就準備將結果值交出。

注意到setTimeout不見了嗎?這只是暫時的,它會再回來。
同時有件事。

在promises中,我們使用的前後順序並不重要。我們可以任意的呼叫thenresolve(),無論何時適用於我們的意圖。這是補捉最終值結果到物件中的概念其中一個有力的好處

我們仍有幾件規格內事情需要實作,雖然我們的promises已經是相當有力的。這個系統允許我們呼叫then()幾次都沒關係,永遠會得到相同的結果

var promise = doSomething();

promise.then(function(value) {
  console.log('獲得一個值:', value);
});

promise.then(function(value) {
  console.log('再次獲得相同的值:', value);
});
對於這篇文章內的promise實作,這並不是完全正確的。如果相反的事情方生,例如在resolve()呼叫之前,呼叫端多次呼叫then(),只有最後一次呼叫then()才會兌現。這個修正需要持有一份延遲清單在pormise之中,而不是一個。我決定不要這樣做,原因是為了讓這篇文章簡單一些,它已經夠長了 :)

串連Promises

因為promises捕捉非同步的概念在物件中,我們可以串連它們、對應它們、將它們以平行或循序方式執行,以任何有用處的方式。下列程式碼是個非常常見的promises用法

getSomeData()
.then(filterTheData)
.then(processTheData)
.then(displayTheData);

getSomeData會回傳promise,如同根據呼叫then(),但第一次呼叫的也必須是promise,接著再次呼叫then()(再接著下去!)這就是實際的發生,如果我們可以確信then()回傳promise,事情會變得更有趣。

then() 永遠回傳promise

這是我們的promise類型帶著串連

function Promise(fn) {
  var state = 'pending';
  var value;
  var deferred = null;

  function resolve(newValue) {
    value = newValue;
    state = 'resolved';

    if(deferred) {
      handle(deferred);
    }
  }

  function handle(handler) {
    if(state === 'pending') {
      deferred = handler;
      return;
    }

    if(!handler.onResolved) {
      handler.resolve(value);
      return;
    }

    var ret = handler.onResolved(value);
    handler.resolve(ret);
  }

  this.then = function(onResolved) {
    return new Promise(function(resolve) {
      handle({
        onResolved: onResolved,
        resolve: resolve
      });
    });
  };

  fn(resolve);
}

fiddle

齁,這變得有些古怪。你不覺得很慶幸我們的緩慢步伐嗎?真正關鍵的是這裡then()回傳一個新的promise。

因為then()永遠傳回一個新的promise物件,所以至少會有一個promise被建立。建立之後被解決,解決後接著被忽略掉。看起來有些浪費。回呼函式的方法不會有這樣的問題。另一個被人說道promise的缺點。你可以開始感激為什麼某些在JavaScript社群中避免這些。

第二個promise會解決什麼值?它收到第一個promise的值。這個發生在handle()的最底下,handler物件攜帶了onResolved回呼函式以及一個參考到resolve()值。這裡有超過一份的resolve()到處漂流,每個promise都會取得它們自己的那一份,以及一個閉包函式用來執行它。這個是從第一個promise到第二個promise之間的橋樑。我們在這行結束了第一個promise:

var ret = handler.onResolved(value);

這個範例中我用在這裡的handler.onResolved

function(value) {
  console.log("獲得一個值:", value);
}

換句話說,這個就是我們第一次呼叫then()所傳入的函式。第一處理者所回傳的值將會用來傳遞給第二個promise。這樣一來串連完成

doSomething().then(function(result) {
  console.log('第一個結果', result);
  return 88;
}).then(function(secondResult) {
  console.log('第二個結果', secondResult);
});

// 輸出
//
// 第一個結果 42
// 第二個結 88


doSomething().then(function(result) {
  console.log('第一個結果', result);
  // 沒有明確地傳回任何東西
}).then(function(secondResult) {
  console.log('第二個結果', secondResult);
});

// 現在則輸出
//
// 第一個結果 42
// 第二個結果 undefined

因為then()永遠傳回一個新的promise,所以串連可以隨意的串下去

doSomething().then(function(result) {
  console.log('第一個結果', result);
  return 88;
}).then(function(secondResult) {
  console.log('第二個結果', secondResult);
  return 99;
}).then(function(thirdResult) {
  console.log('第三個結果', thirdResult);
  return 200;
}).then(function(fourthResult) {
  // 一直下去...
});

如果上述的範例中,我們最後想要所有的結果呢?在串連中,我們需要手動建構結果值

doSomething().then(function(result) {
  var results = [result];
  results.push(88);
  return results;
}).then(function(results) {
  results.push(99);
  return results;
}).then(function(results) {
  console.log(results.join(', ');
});

// 輸出
//
// 42, 88, 99
Promises只會解決一個值。如果你希望傳遞超過一個值,你需要用某些方法來建立這個值(如陣列、物件、串接字串等)

潛在地更好方式是使用promise函式庫的all()方法,或者其他實用工具方法,來提昇promise的有效性,這個我就留給你自己去發掘。

回呼函式是個選項

提供給then()函式的回呼函式不是嚴格要求的。如果你不提供,那promises將會用前一個promises的值做解決

doSomething().then().then(function(result) {
  console.log('得到結果', result);
});

// 輸出
//
// 得到結果 42

你可以看handle()內部,當沒有回呼函式存在時,它單純的解決promise並離開。value仍然是前一個promise的值。

if(!handler.onResolved) {
  handler.resolve(value);
  return;
}

串連內回傳Promises

我們的串連實作有些天真。它盲目地傳遞解決的值一直下去。如果有個解決的值是個promise怎麼辦?舉例來說

doSomething().then(result) {
  // doSomethingElse傳回promise
  return doSomethingElse(result)
}.then(function(finalResult) {
  console.log("最終結果值是", finalResult);
});

就目前來看,上面的結果不會是我們所想要的。finalResult不會真正的成為完全被解決的值,它會是個promise。為了要達到我們所想要的結果,我們必須

doSomething().then(result) {
  // doSomethingElse傳回promise
  return doSomethingElse(result)
}.then(function(anotherPromise) {
  anotherPromise.then(function(finalResult) {
    console.log("最終結果值是", finalResult);
  });
});

誰會希望有個髒傢伙在他的程式碼中?讓我們有個promise實作,能夠無縫地為我們處理。這個很容易達成,取而代之resolve()的是加入一個特別的案例,當解決的值是promise時

function resolve(newValue) {
  if(newValue && typeof newValue.then === 'function') {
    newValue.then(resolve);
    return;
  }
  state = 'resolved';
  value = newValue;

  if(deferred) {
    handle(deferred);
  }
}

fiddle

我們將持續遞迴地呼叫resolve(),一旦我們不斷的收到promise。一旦它不是個promise時,就會如之前動作一樣進行。

這樣很有可能變成無窮迴圈。Promises/A+規格建議實作判斷無窮迴圈,但並不是必要的。
值得一提的是,這個實作並沒有符合規格。而在這篇文章中我們也不會完全符合規格。若想要知道更多,我建議你閱讀promise解決程序

注意到檢查newValue是否為promise是如何的鬆散嗎?我們只有檢查then()方法。這個多型是有意的,它允許不同的promise實作之間能夠相互運作。這是實際上蠻常見在promise函式庫中混用的方式,不同的第三方函式庫中,你可以在不同的promise實作之間交互使用。

不同的promise實作可以相互混合使用,只要它們有恰當地遵循規格。

在適當地方串連,我們的實作幾乎完成。但我們完全忽略了錯誤處理。

拒絕Promises

當經過某個promise時發生錯誤,它需要拒絕並告知理由。呼叫者什麼時候會知道發生什麼事呢?它們可以透過傳遞第二個回呼函式到then()

doSomething().then(function(value) {
  console.log('成功!', value);
}, function(error) {
  console.log('歐喔', error);
});
如同先前所提到,promise狀態轉換從擱置解決或者拒絕,不會同時解決或拒絕。換句話說,then的兩個回呼函式中只有一個回呼函式會被呼叫

Promises可以透過reject()方法來拒絕,resolve()的邪惡雙胞胎兄弟。下列是doSomething()加入支援錯誤處理

function doSomething() {
  return new Promise(function(resolve, reject) {
    var result = somehowGetTheValue();
    if(result.error) {
      reject(result.error);
    } else {
      resolve(result.value);
    }
  });
}

在promise實作之中,我們必須考慮拒絕。一旦promise被拒絕了,接下來的promise也必須拒絕。

讓我們再看一次完整的promise實作,這次新增了拒絕的支援

function Promise(fn) {
  var state = 'pending';
  var value;
  var deferred = null;

  function resolve(newValue) {
    if(newValue && typeof newValue.then === 'function') {
      newValue.then(resolve, reject);
      return;
    }
    state = 'resolved';
    value = newValue;

    if(deferred) {
      handle(deferred);
    }
  }

  function reject(reason) {
    state = 'rejected';
    value = reason;

    if(deferred) {
      handle(deferred);
    }
  }

  function handle(handler) {
    if(state === 'pending') {
      deferred = handler;
      return;
    }

    var handlerCallback;

    if(state === 'resolved') {
      handlerCallback = handler.onResolved;
    } else {
      handlerCallback = handler.onRejected;
    }

    if(!handlerCallback) {
      if(state === 'resolved') {
        handler.resolve(value);
      } else {
        handler.reject(value);
      }

      return;
    }

    var ret = handlerCallback(value);
    handler.resolve(ret);
  }

  this.then = function(onResolved, onRejected) {
    return new Promise(function(resolve, reject) {
      handle({
        onResolved: onResolved,
        onRejected: onRejected,
        resolve: resolve,
        reject: reject
      });
    });
  };

  fn(resolve, reject);
}

fiddle

除了額外本身加入的reject()handle()也能夠知道解決。在handle()之中,根據state的值來決定是走拒絕的路還是解決的路。state的值接著推送到下一個promise,因為呼叫下個promises的resolve()reject()時,也會根據其值來設定自己的state

當你使用promises,很容易省略處理錯誤的回呼函式。如果你省略了,你將永遠不會得到任何指示,告訴你什麼東西錯了。至少,串連的最後一個promise一個處理錯誤的回呼函式。請見下一個章節,有關吞沒錯誤。

非預期錯誤也該導到拒絕

目前我們所處理的錯誤僅有已知的錯誤。也有可能發生無法處理的非預期錯誤,幾乎毀了所有事情。本質上promise實作捕獲這些例外並因此拒絕。

這表示resolve()應該要用 try/catch 來包著區塊

function resolve(newValue) {
  try {
    // ... 如同之前
  } catch(e) {
    reject(e);
  }
}

同時,確保呼叫端傳入的回呼函式,不會丟出無法處理的例外也很重要。這些回呼函式會在handle()函式中被呼叫,所以最終成為

function handle(deferred) {
  // ... 如同之前

  var ret;
  try {
    ret = handlerCallback(value);
  } catch(e) {
    handler.reject(e);
    return;
  }

  handler.resolve(ret);
}

Promises會吞沒錯誤!

很有可能對於promise的誤解,導致完全吞沒錯誤!這個絆倒不少人

考慮下列範例

function getSomeJson() {
  return new Promise(function(resolve, reject) {
    var badJson = "<div>歐喔,這並不是JSON!</div>";
    resolve(badJson);
  });
}

getSomeJson().then(function(json) {
  var obj = JSON.parse(json);
  console.log(obj);
}, function(error) {
  console.log('歐喔', error);
});

fiddle

這裡會發生什麼事?我們在then()的回呼函式預期著某個合法的JSON。所以天真地去嘗試剖析它,導致例外發生。但是我們有個處理錯誤的回呼函式,所以我們沒事,對吧?

處理錯誤的回呼函式不會被調用到! 如果你在上述的fiddle執行這個範例,你不會得到任何錯誤的輸出。沒有錯誤,什麼都妹有。單純冰冷的寂靜。

為什麼會這樣?因為未經處理的例外取代了我們在then()回呼函式的位置,它在handle()內被捕獲。導致handle()拒絕了promise的that()所傳回的,變成不是我們準備回應的promise,那個應該被適當地解決的promise。

永遠記著,在then()的回呼函式,你準備好要回應的promise是已經被解決的。你在回呼函式內的結果不會影響到這個promise

如果你希望捕獲上述錯誤,你需要個處理錯誤的回呼函式在串流之前

getSomeJson().then(function(json) {
  var obj = JSON.parse(json);
  console.log(obj);
}).then(null, function(error) {
  console.log("錯誤發生: ", error);
});

現在,我們是當地輸出錯誤。

在我的經驗中,這個是promise最大的陷阱。閱讀下一個章節,潛在更好地解決方案

利用done()拯救

大多(但不是全部)promise函式庫都有done()方法。它非常像then(),除了它避免了上述的then()陷阱。

done()可以像then()那樣呼叫。關鍵差異在於它不會傳回promise,並且在done()裡面所有未經處理的例外不會被promise實作給捕獲。換句話說,當整個promise串已經完全地被解決時才會表現done()。我們的getSomeJson()範例可以使用done()變得更強健。

getSomeJson().done(function(json) {
  // 當這裡丟出例外,它不會被吞沒
  var obj = JSON.parse(json);
  console.log(obj);
});

done()也接受一個處理錯誤的回呼函式,done(callback, errback),就如同then()動作,因為整個promise提案是,很好,完成,你可以確保任何噴出的錯誤都會收到通知。

done() 並不是Promises/A+規格(至少還不是),所以你的promise函式庫可以選擇要不要它。

Promise提案需要非同步

稍早文章中我們用了setTimeout做了點欺騙。一旦我們修正了,我們就不在需要setTimeout了。事實上Promises/A+規格要求promise提案必須非同步。符合這個要求很容易,我們只要簡單的將hanlde()包在setTimeout之中

function handle(handler) {
  if(state === 'pending') {
    deferred = handler;
    return;
  }
  setTimeout(function() {
    // ... 如同之前
  }, 1);
}

這就是我們所需要做的。事實上,真正的promise函式庫不需要動用到setTimeout。如果函式庫是NodeJS導向,那它將可能使用process.nextTick,瀏覽器則可能使用新的setImmediate或者setImmediate shim(目前只有IE支援setImmediate),或者可能是非同步函式庫,例如Kris Kowal的asap(Kris Kowal也開發了Q,知名的promise函式庫)

為什麼規格內是非同步需求?

它允許了一致性及可靠的執行流程。考慮下列人為的範例

var promise = doAnOperation();
invokeSomething();
promise.then(wrapItAllUp);
invokeSomethingElse();

這裡的執行流程是怎樣呢?基於命名你可能會猜是invokeSomething() -> invokeSomethingElse() -> wrapItAllUp()。但實際上會根據promise是同步或非同步的解決有差異。如果doAnOperation()是非同步的,那就會如我們所猜測的順序。但如果是同步的,那執行流程實際會是invokeSomething() -> wrapItAllUp() -> invokeSomethingElse(),可能是不好的。

為了處理這種狀況,promises永遠非同步解決,甚至它並不需要非同步。減少驚喜並讓人們在使用promises時不需要考慮自己的程式碼是否有非同步性。

Promises永遠要求至少多一次的事件迴圈來解決。這個對標準的回呼函式方法是不必要的。

在我們包裝then/promise之前

這裡有許多全能的promise函式庫,then組織的promise有個簡單方式。它主要是成為符合規格的簡單實作,除此之外沒了。如果你深入它們的實作,你會看到許多相似處。then/premise是這篇文章的程式碼基礎,我們幾乎建構出相同的promise實作。多謝Nathan Zadoks及Forbes Lindsay,提供他們很棒的函式庫,並且投入JavaScript promises。Forbes Lindsay也是一位在先前提過到promisejs.org背後的人物。

真實世界中的實作有些差異,這就是本文所在。這也是因為有許多在Promises/A+內的細節我並沒有指出。我建議你閱讀規格,內容不長且直覺。

結論

如果你走到這裡,謝謝你的閱讀!我們涵蓋了promise核心,也就是規格所要指出的。大多的實作提供更多功能,例如all()spread()race()denodeify()等等。我建議你瀏覽Bluebird函式庫的API文件,看看promises有什麼其他可能。

一旦了解promise的運作及告誡,我變成更加喜愛它。它為我們的專案帶領更加乾淨、優雅的程式碼。這裡有太多有關這樣的討論,這篇文章只是個開頭!

如果你喜歡這篇文章,你可以跟隨我的Twitter,當我有其他類似的文章時你也會知道。

延伸閱讀

更多有關promise的好文

發現錯誤? 如果我有造成錯誤,而你想讓我知道,請email給我,或者發出一個issue到我的Github中。謝謝!

翻譯