前兩篇我們學會了 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 的網站沒有開放跨網域存取,瀏覽器會擋下請求,這是伺服器端的設定問題。
九、練習題
- 寫一個
delay(ms)函式回傳 Promise,並用async/await依序印出 1、2、3,每個數字間隔一秒。 - 使用 fetch 取得
https://jsonplaceholder.typicode.com/posts,只顯示前 10 筆文章的標題。 - 修改綜合範例,加入一個搜尋輸入框,依姓名過濾顯示的使用者。
- 使用
Promise.all同時取得 3 位使用者的資料,並比較與逐一 await 的耗時差異(提示:console.time)。
結語
這篇從回呼函式一路介紹到 Promise、async/await 與 fetch。非同步是 JavaScript 最重要、也最容易讓初學者困惑的主題,只要記住「Promise 代表未來的結果,await 負責等它」,就掌握了核心概念。下一篇將介紹 ES6+ 現代語法,包括解構、展開運算子與模組,讓你的程式碼更簡潔。有任何問題,歡迎在下方留言討論!
沒有留言:
張貼留言