iT邦幫忙

2022 iThome 鐵人賽

DAY 17
1

前言

首先,在 Day4 的時候有提到 JS 是單執行緒的,也就是在 JS 引擎中,編譯並執行 JS 在同時間內只能執行一件任務。

為什麼要設計成單執行緒?這有些原因,在瀏覽器裡面其實並不只有執行 JS 這件事情要做而已,而且試想如果 JS 可以同時處理多個任務,那在操作修改 DOM、執行 JS 動畫、執行非同步任務等這些事情都混在一起處理,會增加複雜性,還要處理一些併發的問題。

但單執行緒如果碰到一個執行時間較久的任務,會導致後面的任務一直排隊等待,如果此任務和頁面渲染有關,就會導致頁面卡死,為了解決這個問題,JS 將任務的執行類型分成同步和非同步。

而處理非同步任務的方式有好幾種,包括 Callback、Promise、async/await、Generator,以 Callback function 來說,會有 callback hell 造成程式碼可讀性降低的問題,並且假設使用第三方 API 時用到了 callback 去執行下一步的任務,有可能會因為第三方 API 的設計導致你的 callback function 被呼叫多次、沒有被呼叫,又是有時同步/非同步執行,要避免這些問題都需要額外的判斷程式碼。

而 Promise 解決了上述的問題,所以這篇來了解一下 Prmoise。


Promise 語法

直接從範例了解最快,所以首先來看個範例:

const myPromise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('hi');
  }, 1000);
  //   執行一些非同步作業,最終呼叫:
  //   resolve(someValue); // 實現
  //   reject("failure reason"); // 拒絕
});

myPromise.then((value) => {
  // 在 myPromise 被 resolve 時執行
  console.log(value); // hi
});

myPromise.catch((error) => {
  // 在 myPromise 被 reject 時執行
  console.log(error);
});

在範例中,我們透過 new 關鍵字從 Promise 類別產生一個 Promise 物件,並且建立 Promise 物件時傳入一個函式當作參數,我們在這裡先叫它為 executor,executor 函式會帶入兩個函式 resolve & reject 作為參數。

在宣告 new Promise 時,executor 會自動執行,若是成功完成後會執行 resolve 函式,如果有錯誤則執行 reject 函式。

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

範例中,myPromise 為一個 Promise 物件,因為物件內的函式有成功完成執行,resolve 函式的參數值傳給 .then() 當作回呼函式的參數,並印出 hi。

而實際上 Promise 有三種狀態,上面的 Promise 範例最終就是進入到 resolved 的狀態:

  • fulfilled(resolved): 成功的狀態
  • rejected: 失敗的狀態
  • pending: 還在執行中的狀態

若 Promise 已經不處於 pending 狀態,已經是 fulfilled 或是 rejected 狀態,就統稱為 settled 狀態,此狀態不可再變更

順帶一提,在任何一個 .then() 的回呼函式中有錯誤發生時,就會直接跳到最後的 .catch() 而不會繼續執行。

取消 Promise 的執行

取消 Promise 的話,目前 JS 是沒有提供專門的函式或功能去取消 Promise。

不過根據 Promise - is it possible to force cancel a promise
這個在 Stackoverflow 問答的內容,知道我們可以使用一些第三方套件像是 bluebirdcancelable-promise 和 Web API AbortController 去做處理。

不過這些套件我都還沒使用過,等未來有實際使用後更了解再來跟讀者們分享,有興趣的讀者可以先自己研究一下XD。


Promise.prototype.then()

.then() 這個函式接收兩個參數,Promise 在成功及失敗情況時的回呼函式,如果接收的參數不是函式則直接返回該值。

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

onFulfilled 函式

Promise fulfilled 時被呼叫。

onRejected 函式

Promise rejected 時被呼叫。

.then() 回傳值

回傳的是 pending 狀態的 Promise 物件或是同步的某種資料型態的值,不過非 Promise 物件的值會被轉成 Promise 物件。


Promise.prototype.catch()

這個函式用來處理 Promise 的 rejected 狀態,等同於呼叫 Promise.prototype.then(undefined, onRejected)

catch() 回傳值也是一個 Promise。

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

onRejected 函式

catch() 唯一的參數,Promise rejected 時被呼叫。

Promise 其實內部隱藏著 try catch 去做錯誤處理

從 Promise 原始碼 可以知道 Promise 內部有透過 try catch 去做錯誤處理。

簡化後擷取片段程式碼的樣子大概長這樣:

class Promise {
  constructor(cb) {
    try {
      cb(this.onResolve, this.onReject);
    } catch (e) {
      this.#onFail(e);
    }
  }
  
  // ...略
}

所以這兩段程式碼的作用其實是一樣的:

new Promise((resolve, reject) => {
  throw new Error("Whoops!");
}).catch(alert); // Error: Whoops!

// 等同於:
new Promise((resolve, reject) => {
  reject(new Error("Whoops!"));
}).catch(alert); // Error: Whoops!

小問題

那麼看完前面的介紹,有個小問題,catch() 內的 console.log 會執行嗎?裡面有非同步的 setTimeout 喔~

new Promise(function(resolve, reject) {
  setTimeout(() => {
    throw new Error("Whoops!");
  }, 2000);
}).catch(error => console.log(error)); // 會執行嗎?

當然是不會執行,等 new Promise() 內部的函式執行完後,非同步的 setTimeout 才開始執行。

至於怎麼處理 setTimeout 拋出的錯誤呢 ? 這個 Stackoverflow 問題-How to handle errors from setTimeout in JavaScript? 的討論我覺得還不錯,有興趣的讀者可以進去閱讀看看,像改寫成這樣就能處理到了。

new Promise(function(resolve, reject) {
  setTimeout(() => reject(new Error("Whoops!")), 300);
})

Promise.prototype.finally()

這個方法會回傳一個 Promise 物件,並且在 Promise 無論是 resolve 或是 rejected 都會執行它的回呼函式,有兩個特性要注意:

  1. finally() 的回呼函式不會帶入參數。
  2. finally() return 東西的話將會被忽略。

它常放在最後做一些清理的任務,像是變更 loading 狀態、關閉連線,這裡借用 MDN 的範例,透過 finally() 將 loading 狀態做變更。

let isLoading = true;

fetch(myRequest).then(function(response) {
    const contentType = response.headers.get("content-type");
    if(contentType && contentType.includes("application/json")) {
      return response.json();
    }
    throw new TypeError("Oops, we haven't got JSON!");
  })
  .then(function(json) { /* process your JSON further */ })
  .catch(function(error) { console.log(error); })
  .finally(function() { isLoading = false; });

Promise 鏈式寫法

我們在前面的段落介紹了 .then() 會回傳 Promise 物件,而該物件可以加入到 .then() 的第一個參數函式 onFulfilled,達成鏈式寫法,例如以下範例就是透過鏈式寫法依序執行多個 Promise。

function returnPromise(time) {
  return new Promise((resolve, reject) => {
    setTimeout(function(){
      resolve(`${time} seconds done`);
    }, time * 1000);
  });
}

const randomTime = Math.floor(Math.random() * 5);

returnPromise(randomTime).then((promise1value) => {
  console.log('myPromise1 resolve:', promise1value);

  return returnPromise(2); // then 函式回傳 Promise 物件
}).then((promise2value) => {
  console.log('myPromise2 resolve:', promise2value);

  return returnPromise(3);
}).then((promise3value) => {
  console.log('myPromise3 resolve:', promise3value);
}).catch((error) => {
  console.log('error');
});

執行結果:

這樣的寫法能夠解決傳統 callback 的 callback hell 的問題,程式碼也更好閱讀,也能容易的做錯誤處理。


Promisification(Callback 轉成 Promise)

最後做個補充,我們可以把 Callback 轉成 Promise 的寫法,像 Node.js 就提供了 util.promisify() 這個 API,能將原本是 Callback 轉成 Promise 的寫法:

const fs = require('fs');
const util = require('util');

fs.readFile('./index.js', 'utf8', (err, text) => {
  if (err) {
    console.log('Error', err);
  } else {
    console.log(text);
  }
});

// 轉換
const readFile = util.promisify(fs.readFile);
readFile('./index.js', 'utf8')
  .then((text) => {
    console.log(text);
  })
  .catch((err) => {
    console.log('Error', err);
  });

自己實作一個 promisify 函式

當然也可以自己實作一個 promisify 函式,以下拆分成幾個步驟紀錄如下:

Step1.

promisify 會接受一個函式並把它轉為非同步,並回傳一個函式,所以初步寫的結構如下:

const promisify = (fn) => { 
  return function (...args) {
  
  }
}

Step2.

接著我們知道這個回傳的函式會是非同步的,所以我們讓它回傳一個 Promise,後面就可以接 .then() 去做後續處理。

const promisify = (fn) => { 
  return function (...args) {
  
  }
}

let loadScriptPromise = promisify(loadScript);
loadScriptPromise(...).then(...);

Step3.

接著要處理 Promise resolve & reject 的部分,早期還沒有 Promise、async/await 時使用的是 callback function 處理非同步,所以在 Promise 內定義一個 callback function。

const promisify = (fn) => {
  return function (...args) {
    return new Promise((resolve, reject) => {
      function callback(err, ...results) {
        if (err) {
          return reject(err);
        }

        return resolve(results.length === 1 ? results[0] : results);
      }

      args.push(callback);
      fn.call(this, ...args);
    });
  };
};

最後將 callback function 傳入被 promisify 的函式即可。

Promise 類別還實作了其他的靜態方法,像 Promise.all()Promise.race(),將於下篇文章介紹它們的用法以及如何實作這些函式。


參考資料 & 推薦閱讀

[JS] Promise 的使用

Master the JavaScript Interview: What is a Promise?

Promise - is it possible to force cancel a promise

JavaScript Promise Chain - The art of handling promises

Error handling with promises

Promisification

How to Write Your Own Promisify Function from Scratch


上一篇
Day16-instanceof 介紹
下一篇
Day18-JavaScript Promise 系列-Promise 的幾個靜態方法介紹
系列文
強化 JavaScript 之 - 程式語感是可以磨練成就的30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
harry xie
iT邦研究生 1 級 ‧ 2022-12-07 19:22:56

從 Promise chain 中拋出錯誤 範例

    fetch('/api/newsletter', {
      method: 'POST',
      body: JSON.stringify({ email: enteredEmail }),
      headers: {
        'Content-Type': 'application/json',
      },
    })
      .then((response) => {
        if (response.ok) {
          return response.json();
        }

        return response.json().then((data) => {
          throw new Error(data.message || 'Something went wrong!'); // 進入 catch block
        });
      })
      .then((data) => {
        notificationCtx.showNotification({
          title: 'Success!',
          message: 'Successfully registered for newsletter!',
          status: 'success',
        });
      })
      .catch((error) => {
        notificationCtx.showNotification({
          title: 'Error!',
          message: error.message || 'Something went wrong!',
          status: 'error',
        });
      });
  }

我要留言

立即登入留言