2026年11月1日 星期日

JavaScript 入門教學(三):非同步程式設計(Promise、async/await 與 fetch)

前兩篇我們學會了 JavaScript 的基礎語法,以及如何用 DOM 與事件讓網頁互動。但在實際開發中,網頁常常需要「等待」某些事情完成,例如向伺服器要資料、讀取檔案、等待計時器。這一篇要介紹的就是處理等待的方法:非同步程式設計,包括 Promise、async/await 與 fetch。

一、同步與非同步

JavaScript 一次只能做一件事(單執行緒)。如果每個動作都要等前一個做完,遇到需要等待的工作,整個網頁就會卡住。因此,耗時的工作會被丟到一旁等待,其他程式碼先繼續執行,這就是非同步。

先看一個經典的例子,請猜猜輸出順序:

console.log("1. 開始");

setTimeout(function () {
  console.log("2. 兩秒後執行");
}, 2000);

console.log("3. 結束");

實際輸出順序是:1 → 3 → 2。setTimeout 把工作交給瀏覽器計時,JavaScript 不會停下來等它,而是先執行後面的程式碼,時間到了再回頭執行回呼函式(callback)。

二、回呼地獄(Callback Hell)

早期處理非同步只能靠回呼函式。當多個非同步動作需要依序執行時,程式碼會一層包一層,難以閱讀與維護:

setTimeout(function () {
  console.log("步驟 1 完成");
  setTimeout(function () {
    console.log("步驟 2 完成");
    setTimeout(function () {
      console.log("步驟 3 完成");
    }, 1000);
  }, 1000);
}, 1000);

為了解決這個問題,ES6 引進了 Promise。

三、Promise

Promise 代表「一個未來才會完成的結果」,就像餐廳給你的取餐號碼牌:現在還沒有餐點,但保證之後會通知你成功或失敗。它有三種狀態:

狀態 說明
pending 等待中,尚未有結果
fulfilled 成功完成
rejected 失敗

建立 Promise

function wait(seconds) {
  return new Promise(function (resolve, reject) {
    if (seconds < 0) {
      reject(new Error("秒數不可為負數"));
      return;
    }
    setTimeout(function () {
      resolve(`等了 ${seconds} 秒`);
    }, seconds * 1000);
  });
}

resolve() 代表成功,reject() 代表失敗。

使用 then 與 catch

wait(1)
  .then(function (result) {
    console.log(result);          // 等了 1 秒
    return wait(2);               // 回傳另一個 Promise,繼續串接
  })
  .then(function (result) {
    console.log(result);          // 等了 2 秒
  })
  .catch(function (error) {
    console.log("發生錯誤:" + error.message);
  })
  .finally(function () {
    console.log("不論成功失敗都會執行");
  });

用 .then() 串接後,原本層層巢狀的程式碼就變成由上到下的流程了。

四、async / await

async/await 是 Promise 的語法糖,讓非同步程式碼讀起來像一般的同步程式碼,是目前最推薦的寫法:

async function run() {
  try {
    console.log("開始");
    const r1 = await wait(1);   // 等待 Promise 完成
    console.log(r1);
    const r2 = await wait(2);
    console.log(r2);
    console.log("全部完成");
  } catch (error) {
    console.log("發生錯誤:" + error.message);
  }
}

run();

兩個重點:

  • await 只能在 async 函式內使用(模組的最上層除外)。
  • async 函式一定會回傳一個 Promise。錯誤處理請用 try...catch。

五、fetch:向伺服器取得資料

fetch() 是瀏覽器內建的網路請求功能,回傳的就是 Promise。以下使用免費的測試 API(JSONPlaceholder)示範:

async function getUser() {
  try {
    const response = await fetch("https://jsonplaceholder.typicode.com/users/1");

    if (!response.ok) {
      throw new Error("HTTP 錯誤,狀態碼:" + response.status);
    }

    const user = await response.json();   // 把回應轉成 JavaScript 物件
    console.log(user.name);
    console.log(user.email);
  } catch (error) {
    console.log("取得資料失敗:" + error.message);
  }
}

getUser();

注意:fetch 只有在網路本身失敗時才會 reject,遇到 404 或 500 這類 HTTP 錯誤並不會丟出例外,所以一定要自己檢查 response.ok。

送出資料(POST)

async function createPost() {
  const response = await fetch("https://jsonplaceholder.typicode.com/posts", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      title: "我的第一篇文章",
      body: "Hello, fetch!",
      userId: 1
    })
  });

  const result = await response.json();
  console.log(result);
}

createPost();

六、同時執行多個非同步工作

如果幾個請求彼此沒有先後關係,逐一 await 會浪費時間。可以用 Promise.all 同時發出,全部完成後再一起取得結果:

async function loadAll() {
  const urls = [
    "https://jsonplaceholder.typicode.com/users/1",
    "https://jsonplaceholder.typicode.com/users/2",
    "https://jsonplaceholder.typicode.com/users/3"
  ];

  const responses = await Promise.all(urls.map(url => fetch(url)));
  const users = await Promise.all(responses.map(r => r.json()));

  users.forEach(u => console.log(u.name));
}

loadAll();

注意:Promise.all 只要其中一個失敗,整體就會失敗。若希望每個結果都拿到(不論成功失敗),可改用 Promise.allSettled。

七、綜合範例:使用者清單

結合第二篇學到的 DOM 操作,把 API 取得的資料顯示在網頁上。存成 users.html 並用瀏覽器開啟:

<!DOCTYPE html>
<html lang="zh-Hant">
<head>
  <meta charset="UTF-8">
  <title>使用者清單</title>
</head>
<body>
  <h1>使用者清單</h1>
  <button id="loadBtn">載入資料</button>
  <p id="status"></p>
  <ul id="userList"></ul>

  <script>
    const btn    = document.querySelector("#loadBtn");
    const status = document.querySelector("#status");
    const list   = document.querySelector("#userList");

    btn.addEventListener("click", async function () {
      status.textContent = "載入中...";
      list.innerHTML = "";

      try {
        const res = await fetch("https://jsonplaceholder.typicode.com/users");
        if (!res.ok) throw new Error("狀態碼 " + res.status);

        const users = await res.json();
        users.forEach(function (u) {
          const li = document.createElement("li");
          li.textContent = u.name + "(" + u.email + ")";
          list.appendChild(li);
        });
        status.textContent = "共載入 " + users.length + " 筆資料";
      } catch (err) {
        status.textContent = "載入失敗:" + err.message;
      }
    });
  </script>
</body>
</html>

八、常見錯誤與提醒

  • 忘記寫 await:拿到的會是 Promise 物件,而不是結果,印出來會看到 Promise {<pending>}。
  • 忘記處理錯誤:網路隨時可能失敗,請養成使用 try...catch 的習慣。
  • 在 forEach 中使用 await:forEach 不會等待 async 回呼函式,需要依序處理請改用 for...of,要平行處理請用 Promise.all。
  • 跨網域限制(CORS):如果 fetch 的網站沒有開放跨網域存取,瀏覽器會擋下請求,這是伺服器端的設定問題。

九、練習題

  1. 寫一個 delay(ms) 函式回傳 Promise,並用 async/await 依序印出 1、2、3,每個數字間隔一秒。
  2. 使用 fetch 取得 https://jsonplaceholder.typicode.com/posts,只顯示前 10 筆文章的標題。
  3. 修改綜合範例,加入一個搜尋輸入框,依姓名過濾顯示的使用者。
  4. 使用 Promise.all 同時取得 3 位使用者的資料,並比較與逐一 await 的耗時差異(提示:console.time)。

結語

這篇從回呼函式一路介紹到 Promise、async/await 與 fetch。非同步是 JavaScript 最重要、也最容易讓初學者困惑的主題,只要記住「Promise 代表未來的結果,await 負責等它」,就掌握了核心概念。下一篇將介紹 ES6+ 現代語法,包括解構、展開運算子與模組,讓你的程式碼更簡潔。有任何問題,歡迎在下方留言討論!

沒有留言:

張貼留言