🔗 前後端整合:Fetch API 與 AJAX
📌 什麼是前後端通訊?
比喻:前後端通訊就像餐廳的服務生和廚房 🍽️
前端是外場的服務生,負責接待客人、記錄點餐(使用者介面)。 後端是廚房的廚師,負責備料、烹飪、出餐(資料處理和儲存)。 AJAX / Fetch API 就是服務生和廚房之間的「傳菜系統」—— 服務生把點單送進廚房(Request),廚房做好菜後傳出來(Response)。 整個過程中客人不需要離開座位(頁面不需要重新整理)。
📌 XMLHttpRequest vs Fetch API
// === 舊方法:XMLHttpRequest(了解即可,現在已少用) ===
const xhr = new XMLHttpRequest(); // 建立一個 XMLHttpRequest 物件
xhr.open("GET", "/api/users", true); // 設定方法、網址和是否非同步
xhr.onreadystatechange = function() { // 監聽狀態變化
if (xhr.readyState === 4 && xhr.status === 200) { // 狀態 4 表示完成,200 表示成功
const data = JSON.parse(xhr.responseText); // 手動解析 JSON 字串
console.log(data); // 印出資料
}
};
xhr.send(); // 送出請求
// === 新方法:Fetch API(推薦使用) ===
fetch("/api/users") // 送出 GET 請求,回傳 Promise
.then(response => response.json()) // 將回應解析為 JSON(也是 Promise)
.then(data => console.log(data)) // 取得解析後的資料
.catch(error => console.error("錯誤:", error)); // 捕捉任何錯誤
// === 使用 async/await(最推薦的寫法) ===
async function getUsers() { // 宣告非同步函式
try {
const response = await fetch("/api/users"); // 等待回應
if (!response.ok) { // 檢查 HTTP 狀態(fetch 不會自動拋出 HTTP 錯誤)
throw new Error(`伺服器錯誤:${response.status}`); // 手動拋出錯誤
}
const data = await response.json(); // 等待 JSON 解析完成
return data; // 回傳資料
} catch (error) {
console.error("取得使用者失敗:", error); // 印出錯誤訊息
throw error; // 重新拋出讓呼叫者處理
}
}
📌 GET / POST / PUT / DELETE 請求
// === GET 請求:取得資料(像服務生去廚房拿現成的菜) ===
async function getProducts(category) { // 依分類取得產品列表
const url = new URL("/api/products", window.location.origin); // 建立 URL 物件
url.searchParams.append("category", category); // 加入查詢參數 ?category=xxx
url.searchParams.append("page", "1"); // 加入分頁參數 ?page=1
const response = await fetch(url); // 送出 GET 請求(預設就是 GET)
return await response.json(); // 解析並回傳 JSON 資料
}
// === POST 請求:建立新資料(像客人點一道新菜) ===
async function createProduct(product) { // 建立新產品
const response = await fetch("/api/products", { // 送出 POST 請求
method: "POST", // 設定 HTTP 方法為 POST
headers: { // 設定請求標頭
"Content-Type": "application/json", // 告訴伺服器送的是 JSON
"Authorization": `Bearer ${getToken()}` // 附上驗證 Token
},
body: JSON.stringify(product) // 將 JavaScript 物件轉成 JSON 字串
});
if (!response.ok) { // 檢查回應狀態
const error = await response.json(); // 解析錯誤訊息
throw new Error(error.message || "建立失敗"); // 拋出錯誤
}
return await response.json(); // 回傳新建立的產品資料
}
// 呼叫範例
const newProduct = await createProduct({ // 傳入產品資料物件
name: "筆記型電腦", // 產品名稱
price: 35000, // 產品價格
category: "electronics" // 產品分類
});
// === PUT 請求:更新整筆資料(像要求廚房重做一道菜) ===
async function updateProduct(id, product) { // 更新指定 ID 的產品
const response = await fetch(`/api/products/${id}`, { // 送出 PUT 請求到指定 ID
method: "PUT", // 設定 HTTP 方法為 PUT
headers: {
"Content-Type": "application/json" // 內容類型為 JSON
},
body: JSON.stringify(product) // 將更新資料轉成 JSON
});
return await response.json(); // 回傳更新後的資料
}
// === DELETE 請求:刪除資料(像退掉一道菜) ===
async function deleteProduct(id) { // 刪除指定 ID 的產品
const response = await fetch(`/api/products/${id}`, { // 送出 DELETE 請求
method: "DELETE", // 設定 HTTP 方法為 DELETE
headers: {
"Authorization": `Bearer ${getToken()}` // 刪除需要驗證權限
}
});
if (!response.ok) { // 檢查是否成功
throw new Error("刪除失敗"); // 失敗時拋出錯誤
}
return response.status === 204; // 204 No Content 表示刪除成功
}
📌 JSON 序列化與反序列化
// JavaScript 物件 → JSON 字串(序列化)
const user = { // 建立一個 JavaScript 物件
name: "小明", // 姓名屬性
age: 25, // 年齡屬性
hobbies: ["coding", "reading"] // 興趣陣列
};
const jsonString = JSON.stringify(user); // 將物件轉成 JSON 字串
console.log(jsonString); // 印出:{"name":"小明","age":25,"hobbies":["coding","reading"]}
// 美化輸出(第三個參數是縮排空格數)
const prettyJson = JSON.stringify(user, null, 2); // 用 2 個空格縮排
console.log(prettyJson); // 印出格式化的 JSON(方便閱讀)
// JSON 字串 → JavaScript 物件(反序列化)
const jsonData = '{"name":"小華","age":30}'; // 一段 JSON 字串
const parsed = JSON.parse(jsonData); // 將 JSON 字串解析為物件
console.log(parsed.name); // 印出:小華
// 搭配 C# 後端的對應
// C# 端:public class User { public string Name { get; set; } public int Age { get; set; } }
// JavaScript 注意:C# 的 PascalCase 會被 System.Text.Json 轉成 camelCase
const userFromApi = await fetch("/api/users/1").then(r => r.json()); // 從 API 取得使用者
console.log(userFromApi.name); // camelCase:name(不是 Name)
console.log(userFromApi.age); // camelCase:age(不是 Age)
📌 與 ASP.NET Core API 搭配
// 完整的 CRUD 服務類別
class ProductService { // 封裝所有產品相關的 API 呼叫
constructor(baseUrl = "/api/products") { // 建構子設定基礎 URL
this.baseUrl = baseUrl; // 儲存基礎 URL
}
async getAll() { // 取得所有產品
const response = await fetch(this.baseUrl); // 送出 GET 請求
if (!response.ok) throw new Error("取得產品列表失敗"); // 錯誤處理
return await response.json(); // 回傳 JSON 資料
}
async getById(id) { // 依 ID 取得單一產品
const response = await fetch(`${this.baseUrl}/${id}`); // 送出 GET 請求到指定 ID
if (response.status === 404) return null; // 找不到就回傳 null
if (!response.ok) throw new Error("取得產品失敗"); // 其他錯誤
return await response.json(); // 回傳產品資料
}
async create(product) { // 建立新產品
const response = await fetch(this.baseUrl, { // POST 到基礎 URL
method: "POST", // HTTP 方法
headers: { "Content-Type": "application/json" }, // 設定內容類型
body: JSON.stringify(product) // 將產品物件轉成 JSON
});
if (!response.ok) { // 檢查回應
const errors = await response.json(); // 解析伺服器的驗證錯誤
throw new Error(JSON.stringify(errors)); // 將錯誤訊息拋出
}
return await response.json(); // 回傳建立的產品
}
async update(id, product) { // 更新產品
const response = await fetch(`${this.baseUrl}/${id}`, { // PUT 到指定 ID
method: "PUT", // HTTP 方法
headers: { "Content-Type": "application/json" }, // 設定內容類型
body: JSON.stringify(product) // 將更新資料轉成 JSON
});
if (!response.ok) throw new Error("更新失敗"); // 錯誤處理
return await response.json(); // 回傳更新後的產品
}
async delete(id) { // 刪除產品
const response = await fetch(`${this.baseUrl}/${id}`, { // DELETE 指定 ID
method: "DELETE" // HTTP 方法
});
return response.ok; // 回傳是否成功
}
}
// 使用範例
const service = new ProductService(); // 建立服務實例
const products = await service.getAll(); // 取得所有產品
📌 CORS 跨域問題與解法
// 什麼是 CORS?
// 當前端(http://localhost:3000)向不同來源的後端(http://localhost:5000)
// 發送請求時,瀏覽器會因為「同源政策」而阻擋。
// 錯誤訊息通常長這樣:
// Access to fetch at 'http://localhost:5000/api/data' from origin
// 'http://localhost:3000' has been blocked by CORS policy
// 解法在 ASP.NET Core 後端設定(不是前端能處理的!)
// 在 Program.cs 中:
// builder.Services.AddCors(options =>
// {
// options.AddPolicy("AllowFrontend", policy =>
// {
// policy.WithOrigins("http://localhost:3000") // 允許的來源
// .AllowAnyHeader() // 允許任何標頭
// .AllowAnyMethod() // 允許任何 HTTP 方法
// .AllowCredentials(); // 允許攜帶 Cookie
// });
// });
// app.UseCors("AllowFrontend");
// 前端攜帶 Cookie 的設定
const response = await fetch("http://localhost:5000/api/data", { // 跨域請求
method: "GET", // HTTP 方法
credentials: "include" // 重點!告訴瀏覽器要攜帶 Cookie
});
📌 FormData 上傳檔案
// 使用 FormData 上傳檔案
async function uploadFile(file) { // 接受一個 File 物件
const formData = new FormData(); // 建立 FormData 物件
formData.append("file", file); // 加入檔案,key 為 "file"
formData.append("description", "產品圖片"); // 也可以加入其他欄位
const response = await fetch("/api/upload", { // 送出 POST 請求
method: "POST", // HTTP 方法
body: formData // 直接傳 FormData,不需要設定 Content-Type(瀏覽器會自動設定)
// 注意:不要手動設定 Content-Type,否則 boundary 會不正確!
});
if (!response.ok) throw new Error("上傳失敗"); // 錯誤處理
return await response.json(); // 回傳上傳結果
}
// 搭配 HTML input 使用
const fileInput = document.querySelector("#fileInput"); // 取得檔案輸入元素
fileInput.addEventListener("change", async (e) => { // 監聽檔案選擇事件
const file = e.target.files[0]; // 取得選擇的第一個檔案
if (!file) return; // 如果沒選擇檔案就跳過
if (file.size > 5 * 1024 * 1024) { // 檢查檔案大小(5MB 上限)
alert("檔案大小不能超過 5MB"); // 顯示錯誤提示
return; // 中止上傳
}
try {
const result = await uploadFile(file); // 呼叫上傳函式
console.log("上傳成功:", result); // 印出結果
} catch (error) {
console.error("上傳錯誤:", error); // 印出錯誤
}
});
// 多檔案上傳
async function uploadMultipleFiles(files) { // 接受 FileList 或 File 陣列
const formData = new FormData(); // 建立 FormData
for (const file of files) { // 遍歷所有檔案
formData.append("files", file); // 用相同的 key 加入多個檔案
}
const response = await fetch("/api/upload/multiple", { // 送出到多檔上傳端點
method: "POST", // HTTP 方法
body: formData // 送出 FormData
});
return await response.json(); // 回傳結果
}
🤔 我這樣寫為什麼會錯?
❌ 錯誤 1:fetch 的 HTTP 錯誤不會自動拋出例外
// 錯誤:以為 fetch 失敗會自動進入 catch
try {
const response = await fetch("/api/not-found"); // 即使 404,fetch 也不會拋出錯誤!
const data = await response.json(); // 試圖解析 404 頁面會出錯
console.log(data); // 可能得到意外結果
} catch (error) {
console.error(error); // 只有網路斷線才會進入這裡
}
// 正確:手動檢查 response.ok
try {
const response = await fetch("/api/not-found"); // 送出請求
if (!response.ok) { // 手動檢查 HTTP 狀態碼是否在 200-299 之間
throw new Error(`HTTP 錯誤 ${response.status}: ${response.statusText}`); // 手動拋出
}
const data = await response.json(); // 只有成功才解析
console.log(data); // 使用資料
} catch (error) {
console.error("請求失敗:", error.message); // 現在能正確捕捉 HTTP 錯誤
}
❌ 錯誤 2:POST 時忘記設定 Content-Type
// 錯誤:沒有設定 Content-Type,伺服器不知道收到的是 JSON
const response = await fetch("/api/products", { // 送出 POST 請求
method: "POST", // HTTP 方法
body: JSON.stringify({ name: "手機" }) // 有轉 JSON 字串
// 但沒有設定 Content-Type!伺服器會當成純文字處理
});
// 正確:明確設定 Content-Type 為 application/json
const response = await fetch("/api/products", { // 送出 POST 請求
method: "POST", // HTTP 方法
headers: {
"Content-Type": "application/json" // 告訴伺服器這是 JSON 格式
},
body: JSON.stringify({ name: "手機" }) // 將物件轉成 JSON 字串
});
❌ 錯誤 3:上傳檔案時手動設定 Content-Type
// 錯誤:手動設定 Content-Type 會破壞 FormData 的 boundary
const formData = new FormData(); // 建立 FormData
formData.append("file", file); // 加入檔案
const response = await fetch("/api/upload", { // 送出請求
method: "POST", // HTTP 方法
headers: {
"Content-Type": "multipart/form-data" // 錯誤!手動設定會缺少 boundary
},
body: formData // FormData 內容
});
// 正確:讓瀏覽器自動設定 Content-Type(包含正確的 boundary)
const formData = new FormData(); // 建立 FormData
formData.append("file", file); // 加入檔案
const response = await fetch("/api/upload", { // 送出請求
method: "POST", // HTTP 方法
// 不要設定 Content-Type!瀏覽器會自動加上正確的 multipart/form-data 和 boundary
body: formData // 直接傳 FormData
});