iT邦幫忙

2023 iThome 鐵人賽

DAY 19
0

在說明 Promise 前,首先我們要知道為什麼需要有 Promise。我們知道 setTimeout() 是屬於非同步的一種,如果我們需要第一個執行完成後才執行第二個,以此類推,那我們可能會怎麼寫?

setTimeout(() => {
  console.log("我是第一個");
  setTimeout(() => {
    console.log("我是第二個");
    setTimeout(() => {
      console.log("我是第三個");
      setTimeout(() => {
        console.log("我是第四個");
        setTimeout(() => {
          console.log("我是第五個");
        }, 1000);
      }, 1000);
    }, 1000);
  }, 1000);
}, 1000);

由上面可知,這樣的寫法雖然執行上沒有問題,但非常難閱讀及容易形成傳說中的波動拳 code,也容易造成所謂的 回呼地獄 / 回調地獄 (Callback Hell),所以 Promise 就誕生了。

https://ithelp.ithome.com.tw/upload/images/20230919/20141250RxZCBWFU1w.jpg

圖片 ケン 取自 CAPCOM

// Callback Hell

asyncFunction1((result1) => {
  asyncFunction2(result1, (result2) => {
    asyncFunction3(result2, (result3) => {
      // 更多 callback function ...
    });
  });
});

Promise 是一種用於處理非同步操作的工具,它能夠幫助我們更有效地處理非同步的程式邏輯。它用於執行一些需要等待時間的操作,例如網路請求、檔案讀寫等,並在操作完成後返回結果或錯誤,可以有三種狀態:pending (進行中)fulfilled (已完成)rejected (已拒絕),然後會使用 resolve 回傳成功結果reject 回傳失敗的錯誤

我們假設娜美和索隆是情侶,娜美對索隆說:「如果這次成功懷孕,我們就結婚,如果失敗,那我要恢復單身讓香吉士來追求,不過不管最後結局如何,我們還是會一起去攻打黑鬍子海賊團。」

https://ithelp.ithome.com.tw/upload/images/20230919/20141250rCAnD7F8Ob.png

但確認有沒有懷孕總是需要一點時間吧!所以索隆跟娜美很適合當 Promise 的範例,展示了如何創建一個處理非同步操作的 Promise,並使用 Promise chain 的 .then().catch().finally() 來處理操作的結果和錯誤。

看以下範例:

const namiPregnant = (): Promise<string> => {
  return new Promise((resolve, reject) => {
    const success = Math.random() >= 0.5; // 50% 的機率

    if (success) resolve("我們結婚吧。"); // 成功
    reject(new Error("我們分手吧。")); // 失敗
  });
}

namiPregnant()
  .then((result) => {
    console.log(result); // 輸出: 我們結婚吧!
  })
  .catch((error) => {
    console.error(error.message); // 輸出: 我們分手吧!
  })
  .finally(() => {
    console.log("攻打黑鬍子海賊團。"); // 輸出: 攻打黑鬍子海賊團。
  });

在這個範例中,我們設定 namiPregnant 函數返回一個 Promise,該 Promise 隨機模擬懷孕成功或失敗,使用 .then() 來處理懷孕成功的情況,並使用 .catch() 來處理懷孕失敗的情況,無論娜美懷孕成功或失敗,使用 .finally() 來處理最後索隆和娜美還是會一起去攻打黑鬍子海賊團的情況。

不過呢,如果我們設定的 target 為 ES5 以下,在這邊我們會遇到 vscode 跳出錯誤警告,如下圖:

https://ithelp.ithome.com.tw/upload/images/20230919/20141250aANmPvmxr4.png

這是因為 Promise 為 ECMAScript 6 (ES2015) 新增的標準 API,而 finally() 為 ECMAScript 9 (ES2018) 新增,所以我們有兩種解決方式:

  • 修改 tsconfig.json 的 target 須為 ES6 之後的版本,如果會用到 finally(),就必須為 ES9 之後的版本。
// tsconfig.json
{
  "compilerOptions": {
    // ... ,
    "target": "ES2018"
    // ...,
  }
}

不過會將 target 修改為 ES5 就是為了要支援舊版本瀏覽器,所以我們還有第二種解法。

  • 增加或修改 tsconfig.json 的 lib 為 ES6 之後的版本,如果會使用到 finally(),就必須為 ES9 之後的版本。
// tsconfig.json
{
  "compilerOptions": {
    // ... ,
    "target": "ES5",
    "lib": ["ES2018", "DOM"]
    // ...,
  }
}

以上述兩種方式都可以成功消除 TypeScript 的紅波浪警告,就看哪一種方式適合專案當前的狀態。

我們再看另一個使用 Promise 的範例,這次使用箭頭函示並傳入參數,同樣模擬了非同步讀取文件的情境:

const promiseFile = (filename: string): Promise<string> => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (filename === "example.txt") resolve("檔案讀取成功"); // 成功
      reject(new Error("找不到檔案")); // 失敗
    }, 1000);
  });
};

promiseFile("example.txt")
  .then((data) => {
    console.log(data); // 輸出: 檔案讀取成功
  })
  .catch((error) => {
    console.error("Error:", error.message); // 輸出: 找不到檔案
  });

在這個範例中,我們設定 promiseFile 函式返回一個 Promise,該 Promise 在一秒後隨機模擬操作成功或失敗,使用 .then() 方法來處理操作成功的情況,並使用 .catch() 方法來處理操作失敗的情況。這樣,無論操作成功還是失敗,我們都能夠適當地處理。

Promise 鏈式操作

Promise 還支持鏈式操作,這使我們能夠在多個非同步操作之間建立清晰的流程。

我們可以用上面的波動拳 code 改成使用 Promise chain 來當範例:

const promiseUser = (id: number): Promise<string> => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (id) resolve(`我是第 ${id} 個`);
      reject(new Error("沒有 id"));
    }, 1000);
  });
};

promiseUser(1)
  .then((res) => {
    console.log(res); // 輸出: 我是第 1 個
    return promiseUser(2);
  })
  .then((res) => {
    console.log(res); // 輸出: 我是第 2 個
    return promiseUser(3);
  })
  .then((res) => {
    console.log(res); // 輸出: 我是第 3 個
    return promiseUser(4);
  })
  .then((res) => {
    console.log(res); // 輸出: 我是第 4 個
    return promiseUser(5);
  })
  .then((res) => {
    console.log(res); // 輸出: 我是第 5 個
  })
  .catch((error) => {
    console.error(error.message);
  });

在這個範例中,首先使用 promiseUser() 一秒後取得資料,然後使用 .then() 接收第一筆資料的回傳,再執行第二次操作 promiseUser(),接著再次使用 .then() 取得第二筆資料的回傳再執行第三次操作,以此類推。這種方式就能幫助我們建立一個更明確的流程,使程式碼更具可讀性。

我們再看一個使用 Promise chain 的範例:

interface IUserData {
  id: number;
  username: string;
}

const promiseUserData = (userId: number): Promise<IUserData> => {
  return new Promise((resolve, _) => {
    setTimeout(() => {
      const user = { id: userId, username: "威爾豬" };
      resolve(user);
    }, 1000);
  });
}

const promisePosts = (user: IUserData): Promise<string[]> => {
  return new Promise((resolve, _) => {
    setTimeout(() => {
      const posts = [
        `貼文 1 by ${user.username}`,
        `貼文 2 by ${user.username}`,
      ];
      resolve(posts);
    }, 1000);
  });
}

promiseUserData(1)
  .then((user) => {
    console.log(user); // 輸出: {id: 1, username: '威爾豬'}
    return promisePosts(user);
  })
  .then((posts) => {
    console.log(posts); // 輸出: ['貼文 1 by 1', '貼文 2 by 1']
  })
  .catch((error) => {
    console.error(error.message);
  });

在這個範例中,我們首先使用 promiseUserData 函式獲取使用者數據,然後使用 .then() 在使用者數據獲取後執行操作。接著,我們使用 promisePosts 函式獲取使用者的貼文,並再次使用 .then() 處理貼文數據,這樣非同步就會 依序出現,先輸出使用者,一秒後再輸出貼文。

Promise 方法

  • Promise.all: 多個 Promise 同時執行,等全部完成後再同時進行回傳

當然我們可以將上面範例改成使用 Promise.all 來處理多個非同步操作:

interface IUserData {
  id: number;
  username: string;
}

const promiseUserData = (user: IUserData): Promise<IUserData> => {
  return new Promise((resolve, _) => {
    setTimeout(() => {
      resolve(user);
    }, 1000);
  });
};

const promisePosts = (user: IUserData): Promise<string[]> => {
  return new Promise((resolve, _) => {
    setTimeout(() => {
      const posts = [
        `貼文 1 by ${user.username}`,
        `貼文 2 by ${user.username}`,
      ];
      resolve(posts);
    }, 1000);
  });
};

const promiseAllData = () => {
  const user = { id: 1, username: "威爾豬" };

  Promise.all([promiseUserData(user), promisePosts(user)])
    .then(([user, posts]) => {
      console.log(user); // 輸出: {id: 1, username: '威爾豬'}
      console.log(posts); // 輸出: ['貼文 1 by 1', '貼文 2 by 1']
    })
    .catch((error) => {
      console.error("Error:", error.message);
    });
};

promiseAllData();

我們直接使用 Promise.all 來同時操作 promiseUserData 和 promisePosts 這兩個 Promise 的函式,使用 .then() 和 .catch() 處理回傳的成功結果和錯誤,並運用解構來提取 Promise.all 返回陣列中的 user 和 posts,這樣 user 和 posts 就會 同時出現,當然陣列顯示的結果順序會與一開始傳入的順序一樣 ( 先 user 後 posts ),我們就能夠使用這些結果進行後續處理。

  • Promise.race: 多個 Promise 同時執行,但 只回傳第一個完成的

使用 Promise.race 可以解決一些需要快速回應的情況,例如超時處理,或者只關心最快完成的操作的情況。

以下範例:

interface IData {
  userId: number;
  id: number;
  title: string;
  completed: boolean;
}

const promiseTimeout = (url: string, timeout: number) => {
  return Promise.race([
    fetch(url), // 嘗試發出網絡請求
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error("請求已超過時間!")), timeout)
    ), // 超時拒絕的 Promise
  ]);
};

// 超時 3 秒沒回應就回傳錯誤
promiseTimeout("https://jsonplaceholder.typicode.com/todos/1", 3000)
  .then((res: any) => res.json())
  .then((data: IData) => console.log(data))
  .catch((error) => console.error(error.message));

在這個範例中,promiseTimeout 函式使用 Promise.race 同時監聽網絡請求和一個定時器。如果網絡請求在指定的超時時間內完成,則 Promise.race 返回該請求的 Promise。如果網絡請求未能在時間內完成,則定時器的 Promise 將回傳請求拒絕的錯誤,表示請求已超時。


Promise 提供了一種更結構化和可讀性的方式來處理非同步操作,並可以解決回呼地獄的問題,使程式碼更易於維護。在實際開發中,會常看到使用 Promise 的方式,或是另外一種 async / await 的方式來取得非同步的資料,這我們後面的章節再說明。


上一篇
null VS. undefined
下一篇
非同步處理 Ⅱ (Async / Await)
系列文
用不到 30 天學會基本 TypeScript30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言