非同步程式設計(Async / Await)#

JavaScript 是單執行緒語言,同一時間只能做一件事。遇到需要等待的操作(網路請求、讀取檔案)時,非同步機制會先把「等待完成後要做的事」登記起來,繼續執行後面的程式碼,等結果回來再執行:

1
2
3
4
5
fetchDataAsync().then(data => {
    console.log("資料回來了"); // 等結果回來才執行
});
console.log("不需要等待,立即執行"); // 這行先執行
// 輸出順序:不需要等待,立即執行 → 資料回來了

非同步的三種寫法演進#

JavaScript 處理非同步操作的方式歷經三個階段的演進,每一代都是為了解決上一代的問題:

寫法年代錯誤處理可讀性適用場景
CallbackES1手動檢查 err 參數巢狀多時差舊版程式碼
PromiseES6(2015).catch()鏈式尚可需要鏈式或平行執行
async/awaitES2017try/catch最佳,接近同步現代開發(推薦)

現代 JavaScript 推薦使用 async/await,搭配 Promise.all 處理平行操作。


Callback(回呼函式)#

Callback 是最早的非同步處理方式:把「完成後要執行的函式」當作參數傳進去,等非同步操作完成後再呼叫它。

setTimeout / setInterval 是最簡單的 callback 範例——傳入的箭頭函式就是 callback,由瀏覽器在指定時間後呼叫:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// setTimeout — 延遲一段時間後執行一次
setTimeout(() => {
    console.log("2 秒後執行"); // 這個函式就是 callback
}, 2000);

// setInterval — 每隔一段時間重複執行
let count = 0;
const timer = setInterval(() => {
    count++;
    console.log(`第 ${count} 次`);
    if (count === 3) clearInterval(timer); // 執行 3 次後停止
}, 1000);

// 取消尚未執行的 setTimeout
const t = setTimeout(() => console.log("不會執行"), 5000);
clearTimeout(t);

實際應用中,callback 用來處理非同步操作的結果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
function loadData(url, onSuccess, onError) {
    setTimeout(() => {
        if (url) {
            onSuccess({ data: "結果" });
        } else {
            onError(new Error("URL 不能為空"));
        }
    }, 1000);
}

loadData(
    "https://api.example.com",
    data => console.log("成功:", data),
    err => console.error("失敗:", err)
);

Callback 的問題是,當多個非同步操作有先後依賴關係時,程式碼會不斷往右縮排,形成難以維護的回呼地獄(Callback Hell)

1
2
3
4
5
6
7
8
// 先取得使用者 → 再取得訂單 → 再取得商品 → ...
getUser(userId, (user) => {
    getOrders(user.id, (orders) => {
        getProducts(orders[0].id, (products) => {
            // 越來越深,難以閱讀和維護
        });
    });
});

Promise 和 async/await 的出現就是為了解決這個問題。


Promise#

Promise(承諾)是一個代表「尚未完成的非同步操作」的物件。你可以把它理解成:「我現在給你一張憑證,結果出來之後你再來兌換。」

Promise 有三種狀態,且只會轉換一次,不可逆:

pending(等待中)
    ├─ resolve() → fulfilled(已成功)
    └─ reject()  → rejected(已失敗)

建立 Promise:傳入一個執行函式,內部呼叫 resolve(成功)或 reject(失敗):

1
2
3
4
5
6
7
8
9
const fetchUser = (id) => new Promise((resolve, reject) => {
    setTimeout(() => {
        if (id > 0) {
            resolve({ id, name: "Alice" }); // 成功,傳入結果
        } else {
            reject(new Error("Invalid ID")); // 失敗,傳入錯誤
        }
    }, 1000);
});

使用 Promise:用 .then() 接成功結果,.catch() 接錯誤,.finally() 無論如何都執行:

1
2
3
4
fetchUser(1)
    .then(user => console.log("使用者:", user))   // 成功時執行
    .catch(err => console.error("錯誤:", err))    // 失敗時執行
    .finally(() => console.log("無論如何都執行")); // 最後執行

Promise 鏈式呼叫#

.then() 本身也回傳一個新的 Promise,因此可以串接多個 .then(),取代巢狀 callback:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 依序:取得使用者 → 取得訂單 → 處理訂單
fetchUser(1)
    .then(user => {
        console.log("取得使用者:", user.name);
        return fetchOrders(user.id); // 回傳新的 Promise,下一個 .then 會等它
    })
    .then(orders => {
        console.log("訂單數量:", orders.length);
        return processOrders(orders);
    })
    .then(result => {
        console.log("處理完成:", result);
    })
    .catch(err => {
        // 任一步驟失敗,直接跳到這裡
        console.error("發生錯誤:", err);
    });

關鍵:在 .then()return 一個 Promise,下一個 .then() 就會等它完成再執行。


Promise 靜態方法#

當需要同時處理多個 Promise 時,可以使用這四個靜態方法:

方法完成條件失敗條件適用情境
Promise.all全部成功任一失敗即失敗全部都必須成功
Promise.allSettled全部結束(不論結果)不會失敗需要知道每個結果
Promise.race第一個完成(不論成敗)第一個失敗取最快的結果
Promise.any第一個成功全部失敗才失敗取第一個成功的
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// Promise.all — 同時發出多個請求,全部完成才繼續
const [users, products] = await Promise.all([
    fetchUsers(),
    fetchProducts()
]);

// Promise.allSettled — 等全部結束,逐一檢查結果
const results = await Promise.allSettled([fetchA(), fetchB(), fetchC()]);
results.forEach(r => {
    if (r.status === "fulfilled") console.log("成功:", r.value);
    else console.error("失敗:", r.reason);
});

// Promise.race — 取第一個完成的(常用於實作 timeout)
const fastest = await Promise.race([fetchFast(), fetchSlow()]);

// Promise.any — 取第一個成功的,全部失敗才算失敗
const first = await Promise.any([mayFail(), maySucceed()]);

async / await#

async/await 是 ES2017 引入的語法,讓非同步程式碼讀起來像同步,是目前最主流的寫法。

async 函式:在 function 前加上 async,該函式的回傳值會自動包成 Promise:

1
2
3
4
5
async function greet() {
    return "Hello"; // 等同於 return Promise.resolve("Hello")
}

greet().then(msg => console.log(msg)); // Hello

await:只能用在 async 函式內,讓程式暫停等待 Promise 完成,再繼續往下執行。它不會阻塞整個程式,只是暫停當前這個 async 函式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
async function loadUserData(userId) {
    try {
        const user = await fetchUser(userId);      // 等 fetchUser 完成
        const orders = await fetchOrders(user.id); // 等 fetchOrders 完成
        const summary = await buildSummary(orders);
        return summary;
    } catch (err) {
        // 任一個 await 拋出錯誤都會到這裡
        console.error("載入失敗:", err);
        throw err;
    }
}

// async 函式回傳 Promise,所以可以用 .then/.catch
loadUserData(1)
    .then(summary => console.log(summary))
    .catch(err => console.error(err));

// 或在另一個 async 函式內繼續用 await
async function main() {
    const summary = await loadUserData(1);
    console.log(summary);
}

並行執行#

await 會等待,所以如果把多個獨立的請求逐一 await,它們會依序執行,浪費時間:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 錯誤做法:依序等待(慢)
async function sequential() {
    const a = await fetchA(); // 等 1 秒後才發出下一個請求
    const b = await fetchB(); // 再等 1 秒
    // 共需 2 秒
}

// 正確做法:先同時發出所有請求,再一起等待結果
async function parallel() {
    const [a, b] = await Promise.all([fetchA(), fetchB()]);
    // fetchA 和 fetchB 同時發出,共只需 1 秒
}

原則:沒有先後依賴關係的請求,用 Promise.all 並行發出。


實際範例:載入資料並顯示#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
async function displayUser(userId) {
    const loading = document.querySelector("#loading");
    const content = document.querySelector("#content");

    loading.hidden = false;
    content.innerHTML = "";

    try {
        const response = await fetch(
            `https://jsonplaceholder.typicode.com/users/${userId}`
        );

        // fetch 只有網路錯誤才會 reject,HTTP 4xx/5xx 需手動判斷
        if (!response.ok) {
            throw new Error(`HTTP 錯誤:${response.status}`);
        }

        const user = await response.json();

        content.innerHTML = `
            <h2>${user.name}</h2>
            <p>Email: ${user.email}</p>
            <p>城市: ${user.address.city}</p>
        `;
    } catch (err) {
        content.innerHTML = `<p style="color:red">載入失敗:${err.message}</p>`;
    } finally {
        loading.hidden = true; // 無論成功或失敗,都隱藏 loading
    }
}

Reference#