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 型別。如果你嘗試這樣做:
// ❌ 這樣不行
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 格式的請求。這種格式天生就支援混合傳輸文字與二進制資料。
基本用法
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();
}FormData.append() 傳入非 Blob/File 的值時,瀏覽器會把它轉成字串。因此數字、布林值、日期等資料如果需要保留型別,通常要放進 JSON 字串,再由後端解析。
WARNING
不要手動設定 Content-Type
使用 FormData 時,瀏覽器會自動產生類似這樣的 header:
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW如果你手動設定 Content-Type: multipart/form-data,會缺少 boundary 參數,導致後端無法解析。
附加巢狀 JSON 物件
如果你的 JSON 資料比較複雜(例如巢狀物件),可以把整個 JSON 序列化後作為一個欄位傳送:
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();
}JSON 字串欄位 vs JSON Blob
| 寫法 | 後端通常看到 | 適合情境 |
|---|---|---|
formData.append("data", JSON.stringify(userData)) | 一個文字欄位 | 大多數 API,Node/FastAPI 都好處理 |
formData.append("metadata", jsonBlob, "metadata.json") | 一個檔案欄位 | 後端框架支援以 part 的 Content-Type 處理 JSON |
如果沒有特殊需求,優先用 JSON 字串欄位即可。JSON Blob 不是錯,但後端要明確知道要從檔案 part 解析 metadata。
四、 Axios 實戰範例
如果你使用 Axios,用法幾乎相同:
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,避免覆蓋 multipart boundary
// 上傳進度監聽
onUploadProgress: (progressEvent) => {
if (!progressEvent.total) {
return;
}
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)
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 });
});如果是多檔案上傳,multer 常見寫法會改成 upload.array("files"),並從 req.files 讀取檔案陣列。
Python (FastAPI)
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 = None
):
user_data = json.loads(data)
print(f"使用者資料: {user_data}")
if avatar:
contents = await avatar.read()
print(f"檔案大小: {len(contents)} bytes")
return {"success": True}FastAPI 會把 data 當成表單文字欄位,把 avatar 當成檔案欄位。欄位名稱必須和前端 formData.append() 的 key 對上。
六、 進階技巧
多檔案上傳
function uploadMultipleFiles(files) {
const formData = new FormData();
// 常見做法是使用相同的 key name;是否解析成陣列取決於後端框架與 middleware
files.forEach((file) => {
formData.append("files", file, file.name);
});
return fetch("/api/upload", {
method: "POST",
body: formData,
});
}有些後端習慣使用 files[] 作為欄位名稱。兩種都可行,重點是前端 key name 和後端 parser 約定一致。
帶有認證的上傳
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();
}取消上傳
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);七、 常見錯誤排查
| 症狀 | 可能原因 | 解法 |
|---|---|---|
| 後端收不到檔案 | 欄位名稱不一致,或後端 middleware 沒有處理 multipart | 對齊 append() key 與後端 parser |
| 後端收到空物件 | 手動設定了錯誤的 Content-Type,或 JSON 欄位沒有 JSON.parse | 移除 Content-Type header,確認後端解析流程 |
| 中文檔名亂碼 | 後端或儲存系統的檔名編碼處理不完整 | 後端重新產生安全檔名,原始檔名只當 metadata |
| 上傳失敗 413 | 檔案超過伺服器限制 | 調整伺服器設定、壓縮圖片或改用分片上傳 |
| CORS 錯誤 | Origin、Methods、Headers 或 Credentials 設定不完整;帶 Authorization 時通常會觸發 preflight | 調整後端 CORS 設定 |
進度條出現 NaN | progressEvent.total 不存在 | 計算前先檢查 total |
總結
| 概念 | 說明 |
|---|---|
| FormData | 瀏覽器原生物件,用於建構 multipart/form-data 請求 |
| boundary | 分隔不同欄位的標記,由瀏覽器自動產生 |
| Content-Type | 使用 FormData 時不要手動設定 |
| JSON + Blob | 把 JSON 序列化後作為欄位,Blob 直接 append |
掌握 FormData 後,你就能優雅地處理任何「混合上傳」的需求,這是現代 Web 應用的必備技能。
常見誤解
| 誤解 | 正確理解 |
|---|---|
| FormData 只能傳檔案 | 它可以混合文字欄位與檔案欄位 |
使用 FormData 就要手動設 Content-Type | 瀏覽器會自動補上 boundary,手動設反而容易壞 |
append("age", 18) 後端會收到數字 | 非檔案值會被轉成字串 |
前端欄位叫 files,後端一定會自動變陣列 | 是否為陣列取決於後端 parser 與欄位命名約定 |
練習:檢查 FormData 送出去前長什麼樣
練習 1:非檔案值會變成什麼?
const formData = new FormData();
formData.append("name", "小明");
formData.append("age", 18);
formData.append("active", true);
formData.append("meta", JSON.stringify({ theme: "dark" }));
for (const [key, value] of formData.entries()) {
console.log(key, value, typeof value);
}看答案
age 和 active 會變成字串。FormData 可以混合檔案與文字,但非檔案值不會保留 JavaScript 原本的型別。
練習 2:Blob 欄位和文字欄位有什麼不同?
const formData = new FormData();
const blob = new Blob(["hello"], { type: "text/plain" });
formData.append("note", "hello");
formData.append("file", blob, "note.txt");
for (const [key, value] of formData.entries()) {
console.log(key, value instanceof Blob, value);
}觀察 note 和 file 的差異。這能幫你理解後端為什麼會把一個當文字欄位,另一個當檔案欄位。
自我檢查
- 使用 FormData 時要手動設定
Content-Type嗎? formData.append("data", JSON.stringify(obj))後端收到的是物件還是字串?- 多檔案上傳時,前端欄位名稱和後端 parser 為什麼要約好?
- 帶
Authorizationheader 時,為什麼比較容易遇到 CORS preflight?