JSON + Blob 混合上傳 — FormData 完全攻略
在實務開發中,你可能遇過這種需求:後端 API 要求同時接收 JSON 格式的資料(如使用者資訊)和 Blob 格式的檔案(如頭像圖片)。
這種情況無法單純用 JSON 解決,本篇將教你如何使用 FormData 完美解決這個問題。
一、 問題情境
假設你正在開發一個「會員資料編輯」功能,API 規格如下:
POST /api/user/profile
需要傳送:
1. 使用者資訊 (JSON):{ name: "小明", email: "ming@example.com" }
2. 頭像圖片 (Blob):一張裁切過的 JPEG 圖片二、 為什麼不能只用 JSON?
如同我們在第一篇討論的,JSON 不支援 Binary 型別。如果你嘗試這樣做:
javascript
// ❌ 這樣不行
const payload = {
name: "小明",
email: "ming@example.com",
avatar: someBlob, // Blob 物件
};
fetch("/api/user/profile", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});JSON.stringify 會把 Blob 物件變成空物件 {},後端什麼都收不到。
替代方案比較
| 方案 | 優點 | 缺點 |
|---|---|---|
| 把圖片轉 Base64 放進 JSON | 程式碼簡單 | 資料膨脹 33%,後端需解碼 |
| 分兩次請求 | 邏輯清晰 | 多一次網路往返,需處理交易一致性 |
| 使用 FormData | 標準做法,傳輸效率高 | 需了解 multipart/form-data 格式 |
三、 FormData 完整解法
FormData 是瀏覽器原生支援的物件,專門用來建構 multipart/form-data 格式的請求。這種格式天生就支援混合傳輸文字與二進制資料。
基本用法
javascript
async function updateProfile(userData, avatarBlob) {
const formData = new FormData();
// 附加一般文字欄位
formData.append("name", userData.name);
formData.append("email", userData.email);
// 附加 Blob 檔案
// 第三個參數是檔名,後端通常需要這個資訊
formData.append("avatar", avatarBlob, "avatar.jpg");
const response = await fetch("/api/user/profile", {
method: "POST",
body: formData,
// 注意:不要手動設定 Content-Type!
// 瀏覽器會自動加上正確的 boundary
});
return response.json();
}> **不要手動設定 `Content-Type`**
使用 FormData 時,瀏覽器會自動產生類似這樣的 header:
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW如果你手動設定 Content-Type: multipart/form-data,會缺少 boundary 參數,導致後端無法解析。
附加巢狀 JSON 物件
如果你的 JSON 資料比較複雜(例如巢狀物件),可以把整個 JSON 序列化後作為一個欄位傳送:
javascript
async function updateProfile(userData, avatarBlob) {
const formData = new FormData();
// 方法一:把複雜物件轉成 JSON 字串
formData.append(
"data",
JSON.stringify({
name: userData.name,
email: userData.email,
preferences: {
theme: "dark",
notifications: true,
},
})
);
// 方法二:建立一個 JSON Blob(某些後端框架更好處理)
const jsonBlob = new Blob([JSON.stringify(userData)], {
type: "application/json",
});
formData.append("metadata", jsonBlob, "metadata.json");
// 附加檔案
formData.append("avatar", avatarBlob, "avatar.jpg");
// 發送請求
const response = await fetch("/api/user/profile", {
method: "POST",
body: formData,
});
return response.json();
}四、 Axios 實戰範例
如果你使用 Axios,用法幾乎相同:
javascript
import axios from "axios";
async function uploadWithProgress(userData, avatarBlob, onProgress) {
const formData = new FormData();
formData.append("data", JSON.stringify(userData));
formData.append("avatar", avatarBlob, "avatar.jpg");
const response = await axios.post("/api/user/profile", formData, {
// 不需要設定 Content-Type,Axios 會自動處理
// 上傳進度監聽
onUploadProgress: (progressEvent) => {
const percent = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
onProgress?.(percent);
},
});
return response.data;
}
// 使用範例
uploadWithProgress({ name: "小明" }, avatarBlob, (percent) =>
console.log(`上傳進度: ${percent}%`)
);五、 後端接收範例
為了讓你更完整理解整個流程,以下是後端的接收方式:
Node.js (Express + multer)
javascript
const express = require("express");
const multer = require("multer");
const app = express();
const upload = multer({ dest: "uploads/" });
app.post("/api/user/profile", upload.single("avatar"), (req, res) => {
// 文字欄位
const userData = JSON.parse(req.body.data);
console.log("使用者資料:", userData);
// 檔案資訊
const file = req.file;
console.log("檔案路徑:", file.path);
console.log("原始檔名:", file.originalname);
console.log("檔案大小:", file.size);
res.json({ success: true });
});Python (FastAPI)
python
from fastapi import FastAPI, UploadFile, Form
import json
app = FastAPI()
@app.post("/api/user/profile")
async def update_profile(
data: str = Form(...),
avatar: UploadFile = None
):
user_data = json.loads(data)
print(f"使用者資料: {user_data}")
if avatar:
contents = await avatar.read()
print(f"檔案大小: {len(contents)} bytes")
return {"success": True}六、 進階技巧
多檔案上傳
javascript
function uploadMultipleFiles(files) {
const formData = new FormData();
// 使用相同的 key name,後端會收到陣列
files.forEach((file) => {
formData.append("files", file, file.name);
});
return fetch("/api/upload", {
method: "POST",
body: formData,
});
}帶有認證的上傳
javascript
async function authenticatedUpload(formData) {
const token = localStorage.getItem("authToken");
const response = await fetch("/api/upload", {
method: "POST",
headers: {
// 只設定 Authorization,不要設定 Content-Type
Authorization: `Bearer ${token}`,
},
body: formData,
});
return response.json();
}取消上傳
javascript
function uploadWithCancel(formData) {
const controller = new AbortController();
const promise = fetch("/api/upload", {
method: "POST",
body: formData,
signal: controller.signal,
});
return {
promise,
cancel: () => controller.abort(),
};
}
// 使用範例
const { promise, cancel } = uploadWithCancel(formData);
// 5 秒後取消(如果還沒完成)
setTimeout(() => cancel(), 5000);七、 常見錯誤排查
| 症狀 | 可能原因 | 解法 |
|---|---|---|
| 後端收到空物件 | 手動設定了錯誤的 Content-Type | 移除 Content-Type header |
| 中文檔名亂碼 | 編碼問題 | 使用英文檔名或 URL encode |
| 上傳失敗 413 | 檔案超過伺服器限制 | 調整伺服器設定或壓縮圖片 |
| CORS 錯誤 | 後端未允許 multipart/form-data | 調整後端 CORS 設定 |
總結
| 概念 | 說明 |
|---|---|
| FormData | 瀏覽器原生物件,用於建構 multipart/form-data 請求 |
| boundary | 分隔不同欄位的標記,由瀏覽器自動產生 |
| Content-Type | 使用 FormData 時不要手動設定 |
| JSON + Blob | 把 JSON 序列化後作為欄位,Blob 直接 append |
掌握 FormData 後,你就能優雅地處理任何「混合上傳」的需求,這是現代 Web 應用的必備技能。