跳至主要內容
Skip to content

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();
}

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 序列化後作為一個欄位傳送:

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();
}

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,用法幾乎相同:

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,避免覆蓋 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)

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 });
});

如果是多檔案上傳,multer 常見寫法會改成 upload.array("files"),並從 req.files 讀取檔案陣列。

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 = 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 對上。


六、 進階技巧

多檔案上傳

javascript
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 約定一致。

帶有認證的上傳

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);

七、 常見錯誤排查

症狀可能原因解法
後端收不到檔案欄位名稱不一致,或後端 middleware 沒有處理 multipart對齊 append() key 與後端 parser
後端收到空物件手動設定了錯誤的 Content-Type,或 JSON 欄位沒有 JSON.parse移除 Content-Type header,確認後端解析流程
中文檔名亂碼後端或儲存系統的檔名編碼處理不完整後端重新產生安全檔名,原始檔名只當 metadata
上傳失敗 413檔案超過伺服器限制調整伺服器設定、壓縮圖片或改用分片上傳
CORS 錯誤Origin、Methods、Headers 或 Credentials 設定不完整;帶 Authorization 時通常會觸發 preflight調整後端 CORS 設定
進度條出現 NaNprogressEvent.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:非檔案值會變成什麼?

javascript
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);
}
看答案

ageactive 會變成字串。FormData 可以混合檔案與文字,但非檔案值不會保留 JavaScript 原本的型別。

練習 2:Blob 欄位和文字欄位有什麼不同?

javascript
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);
}

觀察 notefile 的差異。這能幫你理解後端為什麼會把一個當文字欄位,另一個當檔案欄位。

自我檢查

  • 使用 FormData 時要手動設定 Content-Type 嗎?
  • formData.append("data", JSON.stringify(obj)) 後端收到的是物件還是字串?
  • 多檔案上傳時,前端欄位名稱和後端 parser 為什麼要約好?
  • Authorization header 時,為什麼比較容易遇到 CORS preflight?

延伸閱讀


← 上一章:ArrayBuffer 操作 | 返回專題首頁