你懂 JavaScript 嗎?#24 Promise

你所不知道的 JS

本文主要會談到 promise 是什麼?promise 的錯誤處理、模式與限制。

promise 就是承諾(真的)。

promise

callback 不能解決的信任與 callback hell 問題都即將在此得到解答 d(`・∀・)b

Promise 是什麼?

我們都有這個經驗…在午餐時間、人滿為患的餐廳裡排隊等候點餐,點餐完畢後,服務生給我們一個小圓盤,告知當小圓盤開始發光震動的時候,就可以來取餐了。

Promise 可說是這個小圓盤,當先前承諾的工作完成時,就來通知我們「工作完成了!來進行下一步的任務吧」。

取餐器

圖片來源

那跟我們的程式碼有什麼關係呢?先來看個兩數相加的例子,函式 add 傳入兩個參數 x 與 y,回傳得到其相加的結果,如範例所示,1 + 2 得到 3。

function add(x, y) {
  return x + y;
}

console.log(add(1, 2)); // 3

但若輸入的兩數,可能要經過冗長計算過程或從伺服器取得,而無法立即做運算呢?

var a = fetchA(); // a 此時尚未取得,值是 undefined
var b = fetchB(); // 2

console.log(add(a, b)); // NaN

這樣就會得到 NaN 倒地

備註:做加法運算時,非數值的部份會被強制轉型,意即 Number(undefined) 得到 NaN,點此複習強制轉型。

那…我們可以改為,等兩數都取到值了,再做相加運算嗎?當然可以。

function fetchA(cb) {
  setTimeout(function () {
    // 模擬冗長運算
    return cb(1);
  }, 3000);
}

function fetchB(cb) {
  return cb(2);
}

function add(getX, getY, cb) {
  var x, y;

  getX(function (xVal) {
    x = xVal; // 得到 x
    y && cb(x, y); // 若 y 也取到了,就執行加法運算
  });

  getY(function (yVal) {
    y = yVal; // 得到 y
    x && cb(x, y); // 若 x 也取到了,就執行加法運算
  });
}

add(fetchA, fetchB, function (a, b) {
  console.log(a + b); // 加法運算,印出相加結果
});

函式 fetchA 模擬了必須經過冗長運算才能得到結果的狀況,函式 add 等待兩數皆取得結果時,就執行加法運算。

程式碼是不是有點複雜?要檢查 x 又要檢查 y 的!的確,在沒有 promise 的 恐龍 年代 ,我們是這樣解決等候的問題。

恐龍

有 promise 之後,改寫上面的例子…

function fetchA() {
  return new Promise(function (resolve, reject) {
    setTimeout(function () {
      // 模擬冗長運算
      resolve(1);
    }, 3000);
  });
}

function fetchB(cb) {
  return new Promise(function (resolve, reject) {
    resolve(2);
  });
}

function add(xPromise, yPromise) {
  // x 與 y 都取到了
  return Promise.all([xPromise, yPromise]).then(function (values) {
    return values[0] + values[1]; // 執行加法運算
  });
}

add(fetchA(), fetchB()).then(function (sum) {
  console.log(sum); // 印出相加結果
});

依舊使用函式 fetchA 模擬必須經過冗長運算才能得到結果的狀況,函式 add 彷彿拿了兩個小圓盤(xPromise 與 yPromise)等著取餐,等兩個小圓盤都發光震動了才去領餐點,也就是執行加法運算。

有 promise 的世界是不是文明多了!?比之前來得方便許多。

讚讚

錯誤處理

承上,then 可接受兩個函式作為參數,第一個函式用於成功(resolve)時要執行的任務,第二個函式用於失敗(reject)時要執行的任務,失敗的原因可能是因為取得值的過程出錯或加法運算失敗等,當然還有一種遲遲未得到結果的延遲狀態(pending),稍後再討論。

add(fetchA(), fetchB()).then(
  function (sum) {
    // fulfillment handler
    console.log(sum);
  },
  function (err) {
    // rejection handler
    console.error(err); // 印出錯誤原因
  },
);

如同在前面 callback 的分離回呼提過的,用於錯誤通知的 callback 通常是 optional,不設定即默認忽略。

而這樣的捕捉錯誤的方式真的可靠嗎?來看另一個例子。

var p = Promise.reject('Oops');

p.then(
  function fulfilled() {
    // ...
  },
  function rejected(err) {
    console.log(err);
  },
);

// Oops

好像還可以?有正確補捉到錯誤喔!但如果是在 callback 內發生錯誤,要怎麼辦?

var p = Promise.resolve(42);

p.then(
  function fulfilled() {
    console.log(x);
  },
  function rejected(err) {
    console.log(err);
  },
);

// Uncaught (in promise) ReferenceError: x is not defined

直接報錯,並沒有進入 rejected 這個 callback!也就是說,若在 callback 內發生錯誤,是不會被捕捉到的。

加上一個 catch 如何?

var p = Promise.resolve(42);

p.then(
  function fulfilled() {
    console.log(x);
  },
  function rejected(err) {
    console.log(err);
  },
).catch(handleErrors); // ReferenceError: x is not defined

function handleErrors(err) {
  console.log(err);
}

的確捕捉到錯誤「ReferenceError: x is not defined」了,可是…若錯誤是發生在 catch 的 handleErrors 裡面呢?要無限制地加上 catch 嗎?其實永遠都會有無法被捕捉的錯誤存在,而目前尚未有更全面、可靠又普遍的解法。

另,從上面的程式碼可以觀察到,promise 還可以做流程控管,由成功或失敗來決定要執行哪一個 callback-看是要顯示結果呢,還是要執行 error handler,都是可以的。

模式

這個部份要來看一些非同步模式的變體,它們可簡化非同步流程控制的表達方式,並讓程式碼更易於維護。

Promise.all([ .. ])

想像有位土豪到美食街任意點餐,拿個好幾個小圓盤,小圓盤分別發光震動,但他等到每個小圓盤都呼叫了,才起身取餐-這就是 Promise.all([ .. ]) 在做的事情,所有的 promise 都回傳成功了才進入下一個任務,在此之前都是等待,但若其一回傳為失敗就進入失敗的處理狀況。

範例如下,當 p1 和 p2 都成功時才會進入 fulfilled 的 callback,任一失敗就進入 rejected。

var p1 = request('http://some.url.1/');
var p2 = request('http://some.url.2/');

Promise.all([p1, p2])
  .then(
    function fulfilled(msgs) {
      return request('http://some.url.3/?v=' + msgs.join(','));
    },
    function rejected(err) {
      console.log(err);
    },
  )
  .then(function (msg) {
    console.log(msg);
  });

all([ .. ]) 常用於需要迭代的狀況。

Promise.race([ .. ])

再次想像有位土豪,雖然他只想吃一份餐點,但也是到處點餐,拿了好幾個小圓盤,而他只對第一個發光震動的小圓盤取餐,其他都捨棄-這就是 Promise.race([ .. ]) 在做的事情,只要有任一 promise 回傳成功就進入下一個任務,其餘的都忽略。

範例如下,當 p1 或 p2 任一成功時就會進入 fulfilled 的 callback,全都失敗就進入 rejected。注意,若 Promise.race(iterable) 中的 iterable 是空陣列,則會造成永久擱置。

var p1 = request('http://some.url.1/');
var p2 = request('http://some.url.2/');

Promise.race([p1, p2])
  .then(
    function fulfilled(msgs) {
      return request('http://some.url.3/?v=' + msgs.join(','));
    },
    function rejected(err) {
      console.log(err);
    },
  )
  .then(function (msg) {
    console.log(msg);
  });

race([ .. ]) 常用於解決逾時等候的問題,如下範例所示,若超過 3 秒未回覆就當成錯誤處理,也可看作是假裝取消承諾的解法,後面會再探討。

function timeoutPromise(delay) {
  return new Promise(function (resolve, reject) {
    setTimeout(function () {
      reject('Timeout!');
    }, delay);
  });
}

Promise.race([foo(), timeoutPromise(3000)]).then(
  function () {
    // `foo(..)` 在 3 秒內成功回覆!
  },
  function (err) {
    // 可能是被拒絕或擱置超過 3 秒
  },
);

其他變體

更多關於 all([ .. ])race([ .. ]) 的變體。

限制

promise 儘管為我們帶來種種美好,但其實也是有些限制的。

忽略意外

如前面錯誤處理所提到的,若在 promise 的 callback 內發生錯誤,是不會被捕捉到的,而會直接報錯。

var p = Promise.resolve(42);

p.then(
  function fulfilled() {
    console.log(x); // 有錯!
  },
  function rejected(err) {
    console.log(err);
  },
);

// Uncaught (in promise) ReferenceError: x is not defined

單一值

promise 只能回傳一個履行值或拒絕理由,因此若有多個值想要回傳的時候,就必須包裹成一個物件或陣列,接著在稍後的 callback 再一一解鎖,這顯得繁瑣又彆扭,因此提出兩個解法-拆成多個 promise 或解構(參數)。

如下,foo 會回傳兩個數字 x 與 y,必須包裹成物件或陣列後回傳,並在之後的 callback 所傳入的 msg 解開為 msgs[0]msgs[1]

function getY(x) {
  return new Promise(function (resolve, reject) {
    setTimeout(function () {
      resolve(3 * x - 1);
    }, 100);
  });
}

function foo(bar, baz) {
  var x = bar * baz;

  return getY(x).then(function (y) {
    // 包裹成物件或陣列再回傳
    return [x, y];
  });
}

foo(10, 20).then(function (msgs) {
  // 解鎖
  var x = msgs[0];
  var y = msgs[1];
  console.log(x, y); // 200 599
});

解法一:拆成多個 promise

這可能代表兩個值是都必須要經過運算(等待)才能取得的,那麼就拆成兩個 promise,也比較符合實際狀況、簡化程式碼。

function getY(x) {
  return new Promise(function (resolve, reject) {
    setTimeout(function () {
      resolve(3 * x - 1);
    }, 100);
  });
}

function foo(bar, baz) {
  var x = bar * baz;

  // 回傳兩個 promise
  return [Promise.resolve(x), getY(x)];
}

Promise.all(foo(10, 20)).then(function (msgs) {
  var x = msgs[0];
  var y = msgs[1];

  console.log(x, y);
});

雖然稍微順暢了語意和簡化了程式碼的邏輯,但最後還是必須在之後的 callback 所傳入的 msg 解開為 msgs[0]msgs[1]

解法二:解構(參數)

若仍是包裹成一個物件或陣列回傳,ES6 提供解構的功能,讓我們能輕鬆指定要取得的參數值。

function getY(x) {
  return new Promise(function (resolve, reject) {
    setTimeout(function () {
      resolve(3 * x - 1);
    }, 100);
  });
}

function foo(bar, baz) {
  var x = bar * baz;

  // 回傳兩個 promise
  return [Promise.resolve(x), getY(x)];
}

Promise.all(foo(10, 20)).then(function ([x, y]) {
  // 解構
  console.log(x, y); // 200 599
});

單一解析

由於 promise 是不可變的(immutable),一但 promise 被解析完成,指定值為旅行或拒絕之後就是固定了,因此當發出重複的 promise 時,除了第一個之外,其餘的都會被捨棄,因此若希望之後的仍能被接受,就必須為之後建立新的 promise 串鏈。

如下範例所示,點擊按鈕「#mybtn」後會發出一個 request 以取得資料,但由於 promise 的不可變的特性,p promise 已被解析,因此除了第一個得到的履行值之外,其餘的都會被捨棄。

var p = new Promise(function (resolve, reject) {
  click('#mybtn', resolve);
});

p.then(function (e) {
  var btnID = e.currentTarget.id;
  return request(`http://some.url.1/?id=${btnID}`);
}).then(function (text) {
  console.log(text);
});

若希望之後的仍能被接受,就必須為之後建立新的 promise 串鏈。也就是改成,每點擊按鈕一次,就觸發一個新的 promise 串鏈。

click('#mybtn', function (e) {
  var btnID = e.currentTarget.id;

  request(`http://some.url.1/?id=${btnID}`).then(function (text) {
    console.log(text);
  });
});

承諾無法取消

promise 是無法被取消的,但可用使用逾時控制來假裝取消承諾。

如下,若三秒內未得到回覆,就判定為逾時並做錯誤處理。

var p = new Promise(function (resolve, reject) {
  setTimeout(function () {
    resolve(42);
  }, 5000);
});

function timeoutPromise(delay) {
  return new Promise(function (resolve, reject) {
    setTimeout(function () {
      reject('Timeout!');
    }, delay);
  });
}

function doSomething() {
  console.log('resolve!');
}

function handleError(err) {
  console.log(err);
}

Promise.race([p, timeoutPromise(3000)]).then(doSomething, handleError);

p.then(function () {
  // 但在 timeout 的狀況下,仍會執行到這裡...
  console.log('但在 timeout 的狀況下,仍會執行到這裡...');
});

得到

Timeout!
但在 timeout 的狀況下,仍會執行到這裡...

由上例可知,雖然 timeoutPromise 比較快速的回覆了,我們「假裝」已取消 promise 而忽略未來可能會回傳的結果,但其實上是沒有取消的,因此當後續 promise 抵達時還是可以捕捉到的,於是進入了後來的 then 的區塊,印出「但在 timeout 的狀況下,仍會執行到這裡…」。

改良如下,我們依然無法真正的取消 promise,但可以判定是否要「徹底的假裝取消」,也就是說,這裡設定一個 flag「OK」,為我們在判別是否為 timeoutPromise 先回覆了,如果是,就將 OK 設定為 false 並丟出錯誤原因,在後續 then 的區塊再徹底忽略 else 那個區塊就好了,我們只要處理 OK 是 true 的情況即可。

var OK = true;
var p = new Promise(function (resolve, reject) {
  setTimeout(function () {
    resolve(42);
  }, 5000);
});

function timeoutPromise(delay) {
  return new Promise(function (resolve, reject) {
    setTimeout(function () {
      reject('Timeout!');
    }, delay);
  });
}

function doSomething() {
  console.log('success');
}

function handleError(err) {
  console.log(err);
}

Promise.race([
  p,
  timeoutPromise(3000).catch(function (err) {
    OK = false;
    throw err;
  }),
]).then(doSomething, handleError);

p.then(function () {
  if (OK) {
    // 只會在沒有 timeout 的狀況下執行
    console.log('I am OK!');
  } else {
    console.log('I am not OK!');
  }
});

得到

Timeout!
I am not OK!

雖然解法有點醜但還可以接受!

我覺得OK

效能

由於 promise 要做的事情很多,promise 的效能是比 callback 來得差一點點,但權衡之下,如果不是慢得離譜,這種極具信任的解法不是很好嗎?待後續探討如何衡量程式效能。

回顧

看完這篇文章,我們到底有什麼收穫呢?藉由本文可以理解到…

推薦閱讀

References


同步發表於2019 鐵人賽


promise ES6 You-Dont-Know-JS javascript 2019鐵人賽 你所不知道的JS 你懂JavaScript嗎? 鐵人賽 You-Dont-Know-JS-Async-and-Performance ReferenceError NaN 你懂 JavaScript 嗎?2019 iT 邦幫忙鐵人賽 系列文