Fetch API 與 AJAX#

AJAX(Asynchronous JavaScript and XML)是在不重新整理頁面的情況下,與伺服器交換資料的技術。 例如:搜尋時即時顯示建議、送出表單後只更新部分畫面,都是 AJAX 的應用。 現代 AJAX 主要透過瀏覽器內建的 Fetch API 實作,取代了舊式的 XMLHttpRequest


基本用法#

fetch() 接收一個 URL,回傳 Promise。需要兩個 await 才能取得資料:

1
2
3
4
5
6
7
const response = await fetch("https://jsonplaceholder.typicode.com/posts/1");
// 第一個 await:等待伺服器回應(HTTP 標頭回來了,但 body 還在傳輸)

const data = await response.json();
// 第二個 await:等待讀取完整的 response body,並解析成 JSON

console.log(data);

為什麼要兩個 await?因為 HTTP 回應分兩階段:先收到狀態碼與標頭,body 可能還在傳輸中(尤其是大型資料),response.json() 會等 body 完全接收後再解析。


HTTP 方法概覽#

Fetch API 支援所有 HTTP 方法,對應不同操作:

方法操作是否有 body
GET取得資料
POST新增資料
PUT完整更新
PATCH部分更新
DELETE刪除資料

GET 請求#

GET 是預設方法,不需要額外設定:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
async function getPost(id) {
    const response = await fetch(
        `https://jsonplaceholder.typicode.com/posts/${id}`
    );

    // fetch 不會因 HTTP 4xx/5xx 自動拋錯,需手動判斷
    if (!response.ok) {
        throw new Error(`HTTP 錯誤:${response.status}`);
    }

    const post = await response.json();
    return post;
}

const post = await getPost(1);
console.log(post.title);

POST 請求#

POST 需要指定 methodContent-Type 標頭,以及序列化後的 body

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
async function createPost(title, body) {
    const response = await fetch("https://jsonplaceholder.typicode.com/posts", {
        method: "POST",
        headers: {
            "Content-Type": "application/json" // 告訴伺服器 body 是 JSON 格式
        },
        body: JSON.stringify({ title, body, userId: 1 }) // 物件必須序列化成字串
    });

    if (!response.ok) throw new Error("建立失敗");

    const newPost = await response.json();
    console.log("建立成功,ID:", newPost.id);
    return newPost;
}

PUT / PATCH / DELETE#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// PUT — 完整更新(傳入所有欄位)
await fetch("/api/posts/1", {
    method: "PUT",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ title: "新標題", body: "新內容" })
});

// PATCH — 部分更新(只傳要修改的欄位)
await fetch("/api/posts/1", {
    method: "PATCH",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ title: "只更新標題" })
});

// DELETE — 刪除(通常不需要 body)
await fetch("/api/posts/1", { method: "DELETE" });

請求標頭(Headers)#

標頭(Headers)是附在請求或回應上的額外資訊,常見用途包括身份驗證、指定資料格式等:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
const response = await fetch("/api/data", {
    headers: {
        "Authorization": "Bearer your-token-here", // 身份驗證 token
        "Accept": "application/json",              // 希望伺服器回傳 JSON
        "X-Custom-Header": "value"                 // 自訂標頭
    }
});

// 讀取回應標頭與狀態
console.log(response.status);                        // 200、404、500 等
console.log(response.statusText);                    // "OK"、"Not Found" 等
console.log(response.headers.get("Content-Type"));   // 回應的資料格式
console.log(response.ok);                            // status 200-299 時為 true

回應格式#

根據伺服器回傳的資料類型,選擇對應的解析方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
const response = await fetch("/api/data");

// JSON(最常用,回傳物件或陣列)
const json = await response.json();

// 純文字(HTML、CSV、純字串)
const text = await response.text();

// Blob(圖片、影片、PDF 等二進位檔案)
const blob = await response.blob();
const url = URL.createObjectURL(blob); // 產生可用於 <img src> 的暫時 URL
const img = document.createElement("img");
img.src = url;

// ArrayBuffer(需要直接操作二進位資料時)
const buffer = await response.arrayBuffer();

注意:response.json()response.text() 等方法只能呼叫一次,body 讀取後就關閉了。若需要多次使用,先存入變數。


錯誤處理#

fetch 有一個常見陷阱:只有網路層錯誤(斷線、DNS 失敗)才會 reject,HTTP 404、500 等伺服器錯誤不會拋出例外,必須手動檢查:

1
2
3
4
5
6
7
8
try {
    const response = await fetch("/api/data");
    console.log(response.ok);     // false(即使是 404 也不拋錯)
    console.log(response.status); // 404
} catch (err) {
    // 這裡只有「完全無法連線」才會進來
    console.error("網路錯誤");
}

正確的錯誤處理:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
async function safeFetch(url, options = {}) {
    try {
        const response = await fetch(url, options);

        if (!response.ok) {
            // 嘗試讀取伺服器回傳的錯誤訊息
            const errorData = await response.json().catch(() => ({}));
            throw new Error(errorData.message || `HTTP ${response.status}`);
        }

        return await response.json();
    } catch (err) {
        if (err.name === "TypeError") {
            throw new Error("網路連線失敗,請檢查網路狀態");
        }
        throw err; // 重新拋出讓呼叫者處理
    }
}

逾時(Timeout)#

Fetch 原生不支援 timeout 設定。可透過 AbortController 在指定時間後強制中止請求:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
async function fetchWithTimeout(url, timeout = 5000) {
    const controller = new AbortController();

    // 超過 timeout 毫秒後,呼叫 abort() 中止請求
    const timer = setTimeout(() => controller.abort(), timeout);

    try {
        const response = await fetch(url, {
            signal: controller.signal // 將 signal 傳給 fetch
        });
        clearTimeout(timer); // 請求成功,取消計時器
        return await response.json();
    } catch (err) {
        if (err.name === "AbortError") {
            throw new Error(`請求逾時(超過 ${timeout}ms)`);
        }
        throw err;
    }
}

實際範例:搜尋使用者#

 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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<!DOCTYPE html>
<html>
<body>
  <input id="search" type="text" placeholder="輸入使用者 ID(1-10)">
  <button id="btn">搜尋</button>
  <div id="result"></div>

  <script>
    const btn = document.querySelector("#btn");
    const input = document.querySelector("#search");
    const result = document.querySelector("#result");

    btn.addEventListener("click", async () => {
        const id = input.value.trim();
        if (!id) return;

        result.innerHTML = "載入中...";
        btn.disabled = true;

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

            if (response.status === 404) {
                result.innerHTML = "找不到此使用者";
                return;
            }

            if (!response.ok) throw new Error(`HTTP ${response.status}`);

            const user = await response.json();
            result.innerHTML = `
                <h3>${user.name}</h3>
                <p>Email: ${user.email}</p>
                <p>網站: ${user.website}</p>
                <p>城市: ${user.address.city}</p>
            `;
        } catch (err) {
            result.innerHTML = `<span style="color:red">錯誤:${err.message}</span>`;
        } finally {
            btn.disabled = false; // 無論成功或失敗,都恢復按鈕
        }
    });
  </script>
</body>
</html>

封裝成 API 模組#

實際專案中,通常會將 fetch 封裝成模組,統一處理 base URL、token、錯誤,避免每個地方重複相同的邏輯:

 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
31
// api.js
const BASE_URL = "https://api.example.com";

async function request(path, options = {}) {
    const token = localStorage.getItem("token");

    const response = await fetch(`${BASE_URL}${path}`, {
        ...options,
        headers: {
            "Content-Type": "application/json",
            // 有 token 才加入 Authorization 標頭
            ...(token && { "Authorization": `Bearer ${token}` }),
            // 允許呼叫者覆蓋標頭
            ...options.headers
        }
    });

    if (!response.ok) {
        const err = await response.json().catch(() => ({}));
        throw new Error(err.message || `HTTP ${response.status}`);
    }

    return response.json();
}

export const api = {
    get:    (path)       => request(path),
    post:   (path, data) => request(path, { method: "POST",   body: JSON.stringify(data) }),
    put:    (path, data) => request(path, { method: "PUT",    body: JSON.stringify(data) }),
    delete: (path)       => request(path, { method: "DELETE" })
};
1
2
3
4
5
6
// 使用
import { api } from "./api.js";

const users   = await api.get("/users");
const newUser = await api.post("/users", { name: "Alice" });
await api.delete("/users/1");

Reference#