- JavaScript
FormDataの使い方|fetchで送信・ファイルアップロードをコピペ解説
FormData とは、フォームの入力値やファイルを「名前と値の組」として集め、multipart/form-data 形式でサーバーへ送るための JavaScript のオブジェクトです。 fetch(url, { method: 'POST', body: formData }) と渡すだけで、ファイルを含むデータを送信できます。
この記事では、送る側(ブラウザ・Node.js)と受け取る側(Express + multer、PHP)の両方のコードを用意し、実際に動かした結果をそのまま載せています。特につまずきやすい 「Content-Type を自分で付けると Multipart: Boundary not found になる」現象を再現し、なぜそうなるのかを送信される本文の中身から説明します。複数ファイル・Blob の送信、進捗バーの付け方、JSON への変換、押したボタンを含める new FormData(form, submitter) まで、コピペで試せる形でまとめました。
FormDataの早見表
FormData でよく使う操作は、この表でほぼ足ります。
| やりたいこと | 書き方 | 結果・補足 |
|---|---|---|
| 空で作る | const fd = new FormData() |
|
| フォームから作る | new FormData(formEl) |
name 属性のある入力欄がすべて入る |
| 押したボタンも含める | new FormData(formEl, event.submitter) |
「下書き」「公開」などの区別に |
| 値を追加 | fd.append('tags', '新着') |
同じ名前で何度でも追加できる |
| 値を上書き | fd.set('tags', '在庫あり') |
同じ名前の値を1つにまとめて置き換え |
| 1つ取得 | fd.get('tags') |
最初の1つ。無ければ null |
| すべて取得 | fd.getAll('tags') |
配列で返る |
| あるか確認 | fd.has('name') |
true / false |
| 削除 | fd.delete('name') |
同じ名前をすべて削除 |
| 全件を回す | for (const [k, v] of fd) |
entries() keys() values() も使える |
| ファイルを追加 | fd.append('photo', file) |
<input type="file"> の files[0] など |
| Blob をファイルとして追加 | fd.append('memo', blob, 'memo.txt') |
第3引数がファイル名 |
| fetch で送信 | fetch(url, { method: 'POST', body: fd }) |
Content-Type は付けない |
| JSON に変換 | Object.fromEntries(fd) |
同じ名前が複数あると最後の1つだけ |
| URL エンコードで送る | new URLSearchParams(fd) |
ファイルは送れない |
| 進捗を表示 | XMLHttpRequest の xhr.upload.onprogress |
fetch には送信進捗のイベントがない |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「FormDataの使い方|fetchで送信・ファイルアップロードをコピペ解説」
動作環境
この記事のコードは次の環境で実行しています。
| 項目 | バージョン |
|---|---|
| Node.js | 22.22.2(FormData・fetch・Blob は組み込み) |
| express | 5.2.1 |
| multer | 2.4.0 |
| PHP | 8.4.21(php -S の組み込みサーバー) |
| ブラウザ確認 | Playwright + Chromium 141.0.7390.37 |
FormData と fetch は、現在の主要ブラウザと Node.js 18 以降で標準で使えます。new FormData(form, submitter) の第2引数は比較的新しい仕様のため、対応状況は MDN の互換性表で確認してください(この記事では Chromium 141 で動作を確認しました)。
FormDataのメソッド一覧と実行結果
まずはメソッドの動きを Node.js で確認します。 ブラウザでもまったく同じ結果になります。
// methods.mjs
const fd = new FormData();
fd.append('name', '佐藤');
fd.append('tags', '新着');
fd.append('tags', 'おすすめ');
console.log('get(tags) :', fd.get('tags'));
console.log('getAll(tags) :', fd.getAll('tags'));
console.log('has(name) :', fd.has('name'));
console.log('get(none) :', fd.get('none'));
fd.set('tags', '在庫あり');
console.log('set 後 getAll:', fd.getAll('tags'));
fd.append('count', 3);
console.log('数値を入れると:', typeof fd.get('count'), fd.get('count'));
fd.delete('name');
console.log('delete 後 has:', fd.has('name'));
fd.append('memo', new Blob(['備品メモ'], { type: 'text/plain' }), 'memo.txt');
const f = fd.get('memo');
console.log('Blob を入れると:', f.constructor.name, f.name, f.type, f.size + 'byte');
for (const [k, v] of fd.entries()) console.log('entries:', k, typeof v === 'string' ? v : `[${v.constructor.name} ${v.name}]`);
console.log('keys :', [...fd.keys()]);
console.log('values:', [...fd.values()].map(String));
実行結果(node methods.mjs、Node.js 22.22.2):
get(tags) : 新着
getAll(tags) : [ '新着', 'おすすめ' ]
has(name) : true
get(none) : null
set 後 getAll: [ '在庫あり' ]
数値を入れると: string 3
delete 後 has: false
Blob を入れると: File memo.txt text/plain 12byte
entries: tags 在庫あり
entries: count 3
entries: memo [File memo.txt]
keys : [ 'tags', 'count', 'memo' ]
values: [ '在庫あり', '3', '[object File]' ]
押さえておきたい動き
| メソッド | 動き | 実行結果からわかること |
|---|---|---|
append(名前, 値) |
末尾に追加。同じ名前があっても残す | tags が2つになる |
set(名前, 値) |
同じ名前をすべて消して1つにする | ['在庫あり'] の1件に。位置は最初の tags の場所 |
get(名前) |
最初の1つ | 2つあっても '新着' だけ |
getAll(名前) |
すべてを配列で | チェックボックスや複数選択に使う |
has(名前) |
存在確認 | |
delete(名前) |
同じ名前をすべて削除 | |
entries() / keys() / values() |
反復用 | for...of でそのまま回せる |
- 値は文字列か File に変換されます。 数値
3を入れても、取り出すと文字列'3'です。 - Blob を入れると File として扱われます。 第3引数を省くとファイル名は
blobになります。 console.log(fd)では中身が見えにくいので、確認には[...fd]やfor...ofを使うと便利です。
fetchでFormDataを送信する
FormData を body に渡すと、fetch が Content-Type: multipart/form-data; boundary=... を自動で付けてくれます。 ここが一番つまずきやすいポイントなので、受信サーバーを用意して確かめます。
受信側:Express 5 + multer 2 のサーバー
npm init -y
npm i express multer
// server.mjs(Express 5 + multer 2)
import express from 'express';
import multer from 'multer';
const app = express();
const upload = multer({
dest: 'uploads/', // 保存先フォルダ
limits: { fileSize: 5 * 1024 * 1024, files: 5 }, // 1ファイル5MBまで、5個まで
});
app.use(express.static('public')); // 動作確認用のHTML
app.use(express.urlencoded({ extended: false })); // URLSearchParams を受け取る
// 文字だけ(ファイルなし)の multipart
app.post('/api/member', upload.none(), (req, res) => {
console.log('[server] content-type =', req.headers['content-type']);
res.json({ body: req.body });
});
// ファイル1つ + 複数ファイル
app.post('/api/upload', upload.fields([
{ name: 'avatar', maxCount: 1 },
{ name: 'photos', maxCount: 4 },
]), (req, res) => {
const files = Object.fromEntries(Object.entries(req.files ?? {}).map(([k, list]) =>
[k, list.map((f) => ({ originalname: f.originalname, mimetype: f.mimetype, size: f.size }))]));
res.json({ body: req.body, files });
});
// URLSearchParams(application/x-www-form-urlencoded)
app.post('/api/search', (req, res) => {
res.json({ contentType: req.headers['content-type'], body: req.body });
});
// エラーを JSON で返す
app.use((err, req, res, next) => {
res.status(400).json({ error: err.message, code: err.code ?? null });
});
app.listen(7200, () => console.log('http://localhost:7200'));
node server.mjs
multer の主な受け取り方は次のとおりです。
| multer のメソッド | 受け取るもの | 結果の入る場所 |
|---|---|---|
upload.none() |
文字の項目だけ | req.body |
upload.single('avatar') |
1つのファイル | req.file |
upload.array('photos', 4) |
同じ名前の複数ファイル | req.files(配列) |
upload.fields([...]) |
名前ごとに複数 | req.files.avatar など |
upload.any() |
すべて | req.files(配列) |
送信側:Content-Type を付けない場合と付けた場合
// client.mjs
const base = 'http://localhost:7200';
const show = async (label, res) => console.log(label, res.status, JSON.stringify(await res.json()));
// 1. 正しい送り方(Content-Type を付けない)
const fd = new FormData();
fd.append('name', '佐藤');
fd.append('tags', '新着');
fd.append('tags', 'おすすめ');
await show('[OK] ', await fetch(`${base}/api/member`, { method: 'POST', body: fd }));
// 2. Content-Type を手で付ける
await show('[NG] ', await fetch(`${base}/api/member`, {
method: 'POST',
headers: { 'Content-Type': 'multipart/form-data' },
body: fd,
}));
// 3. 実際に送られるヘッダーと本文を覗く
const req = new Request(`${base}/api/member`, { method: 'POST', body: fd });
console.log('自動で付く Content-Type:', req.headers.get('content-type'));
console.log((await req.text()).replace(/\r\n/g, '⏎\n'));
実行結果(node client.mjs):
[OK] 200 {"body":{"name":"佐藤","tags":["新着","おすすめ"]}}
[NG] 400 {"error":"Multipart: Boundary not found","code":null}
自動で付く Content-Type: multipart/form-data; boundary=----formdata-undici-051908849075
------formdata-undici-051908849075⏎
Content-Disposition: form-data; name="name"⏎
⏎
佐藤⏎
------formdata-undici-051908849075⏎
Content-Disposition: form-data; name="tags"⏎
⏎
新着⏎
------formdata-undici-051908849075⏎
Content-Disposition: form-data; name="tags"⏎
⏎
おすすめ⏎
------formdata-undici-051908849075--⏎
(⏎ は改行 \r\n の位置を見やすくするために置き換えたものです。boundary の数字は実行のたびに変わります。)
なぜ Content-Type を自分で付けてはいけないのか
multipart/form-data の本文は、boundary(境界線)と呼ばれる文字列で項目ごとに区切られています。 上の実行結果の ------formdata-undici-... がその境界線です。サーバーは Content-Type ヘッダーの boundary=... を読んで、本文をどこで区切るかを知ります。
sequenceDiagram
participant B as fetch(送信側)
participant S as サーバー(multer / PHP)
B->>B: FormData から本文を作り、boundary を決める
B->>S: Content-Type: multipart/form-data; boundary=XXXX
S->>S: boundary=XXXX で本文を区切って読む
Note over B,S: 手で "multipart/form-data" だけを付けると boundary が伝わらない
S-->>B: 400 Multipart: Boundary not found
fetch は本文を作るときに boundary を決め、それを Content-Type に書き込みます。 自分で 'multipart/form-data' と書くと、その値が優先されて boundary が抜けるため、サーバーは本文を区切れません。FormData を送るときは headers に Content-Type を書かない、これだけ覚えておけば大丈夫です。Authorization など他のヘッダーは付けてかまいません。
ブラウザ(Chromium 141)で同じことをしても、結果は同じでした。
手動CT(ブラウザ): 400 {"error":"Multipart: Boundary not found","code":null}
ファイル・複数ファイル・Blobを送る
ファイルは append(名前, File または Blob, ファイル名) で追加します。 同じ名前で複数回 append すると、複数ファイルになります。
// client2.mjs(ファイル送信の部分)
import { openAsBlob, writeFileSync } from 'node:fs';
const base = 'http://localhost:7200';
const show = async (label, res) => console.log(label, res.status, JSON.stringify(await res.json()));
writeFileSync('avatar.png', Buffer.alloc(2048, 1)); // 2KB のダミー画像
const fd = new FormData();
fd.append('title', '備品の写真');
fd.append('avatar', await openAsBlob('avatar.png', { type: 'image/png' }), 'avatar.png');
for (const name of ['desk.jpg', 'chair.jpg']) {
fd.append('photos', new Blob(['dummy-' + name], { type: 'image/jpeg' }), name);
}
// JSON をファイルとして送る
fd.append('photos', new Blob([JSON.stringify({ id: 1, stock: 3 })], { type: 'application/json' }), 'stock.json');
await show('[files]', await fetch(`${base}/api/upload`, { method: 'POST', body: fd }));
// 上限超え(photos は maxCount 4)
const many = new FormData();
for (let i = 0; i < 5; i++) many.append('photos', new Blob(['x']), `p${i}.jpg`);
await show('[limit]', await fetch(`${base}/api/upload`, { method: 'POST', body: many }));
// サイズ超え(上限5MBに6MB)
const big = new FormData();
big.append('avatar', new Blob([Buffer.alloc(6 * 1024 * 1024)]), 'big.png');
await show('[size] ', await fetch(`${base}/api/upload`, { method: 'POST', body: big }));
実行結果:
[files] 200 {"body":{"title":"備品の写真"},"files":{"avatar":[{"originalname":"avatar.png","mimetype":"image/png","size":2048}],"photos":[{"originalname":"desk.jpg","mimetype":"image/jpeg","size":14},{"originalname":"chair.jpg","mimetype":"image/jpeg","size":15},{"originalname":"stock.json","mimetype":"application/json","size":18}]}}
[limit] 400 {"error":"Unexpected file field","code":"LIMIT_UNEXPECTED_FILE"}
[size] 400 {"error":"File too large","code":"LIMIT_FILE_SIZE"}
ポイント
- Node.js では
fs.openAsBlob()(Node 19.8 以降)でファイルを Blob として開けます。 ブラウザではinput.filesの File をそのまま append します。 - multer の
maxCountを超えるとLIMIT_UNEXPECTED_FILE、fileSizeを超えるとLIMIT_FILE_SIZEになります。エラーハンドラーで JSON にして返すと、画面側でメッセージを出しやすくなります。 - 送られてくる
mimetypeやファイル名は送信側が自由に決められる値です。拡張子・中身の確認、保存名の付け直しなどの対策は、別記事「ファイルアップロードのセキュリティ対策」で詳しく解説しています。
ブラウザの input type="file" から送る
<input type="file" id="photos" multiple accept="image/*">
<button id="send">送信</button>
<script>
document.getElementById('send').addEventListener('click', async () => {
const input = document.getElementById('photos');
const fd = new FormData();
for (const file of input.files) fd.append('photos', file); // 複数ファイル
const res = await fetch('/api/upload', { method: 'POST', body: fd });
console.log(await res.json());
});
</script>
input.files は FileList という反復可能な一覧なので、for...of で1つずつ append できます。
フォームから作る:new FormData(form, submitter)
<form> 要素を渡すと、name 属性のある入力欄がまとめて FormData に入ります。 第2引数に押されたボタン(submitter)を渡すと、そのボタンの name と value も含まれます。
動作確認用のHTML(public/index.html)
<!DOCTYPE html>
<html lang="ja">
<head><meta charset="utf-8"><title>備品登録</title></head>
<body>
<form id="item-form" action="/api/upload" method="post" enctype="multipart/form-data">
<input name="title" value="オフィスチェア">
<label><input type="checkbox" name="tags" value="新着" checked>新着</label>
<label><input type="checkbox" name="tags" value="おすすめ" checked>おすすめ</label>
<label><input type="checkbox" name="tags" value="在庫少">在庫少</label>
<input type="text" name="memo" value="無効な欄" disabled>
<input type="file" name="photos" multiple accept="image/*">
<button type="submit" name="status" value="draft">下書き保存</button>
<button type="submit" name="status" value="publish">公開する</button>
</form>
<progress id="bar" max="100" value="0"></progress>
<pre id="out"></pre>
<script>
const form = document.getElementById('item-form');
const out = document.getElementById('out');
form.addEventListener('submit', async (event) => {
event.preventDefault(); // 画面遷移を止める
const fd = new FormData(form, event.submitter); // 押したボタンも含める
const res = await fetch(form.action, { method: 'POST', body: fd });
out.textContent = JSON.stringify(await res.json());
});
// 進捗つき送信(XMLHttpRequest)
window.uploadWithProgress = (fd) => new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const log = [];
xhr.open('POST', '/api/upload');
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
document.getElementById('bar').value = pct;
log.push(pct);
}
});
xhr.addEventListener('load', () => resolve({ status: xhr.status, log }));
xhr.addEventListener('error', reject);
xhr.send(fd);
});
</script>
</body>
</html>
Playwright で実ブラウザから確認
このページを Express(express.static('public'))で配信し、Playwright で Chromium を動かして確認しました。
// browser_test.mjs(抜粋)
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('http://localhost:7200/');
// 1. new FormData(form) と submitter の違い
console.log('form のみ :', await page.evaluate(() =>
JSON.stringify([...new FormData(document.getElementById('item-form'))]
.map(([k, v]) => [k, typeof v === 'string' ? v : `File(${v.name || '空'})`]))));
console.log('submitter あり:', await page.evaluate(() => {
const form = document.getElementById('item-form');
const btn = form.querySelector('button[value="publish"]');
return JSON.stringify([...new FormData(form, btn)].filter(([k]) => k !== 'photos'));
}));
// 2. ファイルを選んで「公開する」ボタンで送信
await page.setInputFiles('input[name=photos]', [
{ name: 'desk.png', mimeType: 'image/png', buffer: Buffer.alloc(1000, 1) },
{ name: 'chair.png', mimeType: 'image/png', buffer: Buffer.alloc(2000, 1) },
]);
await page.click('button[value="publish"]');
await page.waitForFunction(() => document.getElementById('out').textContent !== '');
console.log('送信結果 :', await page.textContent('#out'));
await browser.close();
実行結果(Chromium 141.0.7390.37):
form のみ : [["title","オフィスチェア"],["tags","新着"],["tags","おすすめ"],["photos","File(空)"]]
submitter あり: [["title","オフィスチェア"],["tags","新着"],["tags","おすすめ"],["status","publish"]]
送信結果 : {"body":{"title":"オフィスチェア","tags":["新着","おすすめ"],"status":"publish"},"files":{"photos":[{"originalname":"desk.png","mimetype":"image/png","size":1000},{"originalname":"chair.png","mimetype":"image/png","size":2000}]}}
フォームから作るときのルール
| 入力欄 | FormData に入るか |
|---|---|
| name 属性のある input / select / textarea | 入る |
| name 属性がない | 入らない |
| チェックの入ったチェックボックス | 入る(同じ name で複数なら複数件) |
| チェックの外れたチェックボックス(「在庫少」) | 入らない |
disabled の欄(「memo」) |
入らない(送りたいなら readonly を使う) |
ファイル未選択の type="file" |
空の File(名前なし)が入る |
| submit ボタン | 第2引数に submitter を渡したときだけ入る |
event.submitter は submit イベントで「どのボタンが押されたか」を表します。 「下書き保存」と「公開する」を1つのフォームで分けたいとき、サーバー側で status の値を見て処理を切り替えられます。
名前の付け方の注意:name="action" はフォームの action を隠す
検証の途中で、ボタンの name を action にしていたところ、fetch(form.action, ...) が 404 になりました。調べると、form.action が URL ではなく [object RadioNodeList] になっていました。
form.action → [object RadioNodeList]
form.getAttribute('action') → /api/upload
フォームの中の要素の name は、form.name名 でアクセスできるしくみのため、action や method・submit といった名前を付けると、フォーム自体のプロパティより優先されます。 入力欄の name には status や mode などの別の名前を使うか、URL は form.getAttribute('action') で取り出しましょう。
進捗バーを出したいときはXMLHttpRequest
fetch には送信(アップロード)の進捗を受け取るイベントがありません。 大きなファイルで進捗バーを出したいときは、XMLHttpRequest の xhr.upload の progress イベントを使います。上の HTML の uploadWithProgress() を、3MB のファイルで呼び出しました。
const fd = new FormData();
fd.append('avatar', new Blob([new Uint8Array(3 * 1024 * 1024)], { type: 'image/png' }), 'big.png');
const result = await window.uploadWithProgress(fd);
実行結果:
XHR 進捗 : {"status":200,"count":1,"first":100,"last":100}
同じマシン上のサーバーへの送信だったため一瞬で終わり、progress イベントは100%の1回だけでした。ネットワーク越しに大きなファイルを送る場合は途中経過で何度も発生し、e.loaded / e.total で割合を計算できます(この記事では途中経過が複数回出る様子は確認していません)。
| 送信方法 | 送信の進捗 | 書きやすさ | 向いている場面 |
|---|---|---|---|
| fetch + FormData | 取れない | ◎ async/await で書ける | ほとんどの送信 |
| XMLHttpRequest + FormData | xhr.upload.onprogress で取れる |
○ イベントで書く | 大きなファイルの進捗バー |
FormDataをJSONに変換する
サーバーが JSON を受け取る API の場合は、FormData をオブジェクトに変換してから JSON.stringify します。
const form = new FormData();
form.append('name', '佐藤');
form.append('tags', '新着');
form.append('tags', 'おすすめ');
console.log('Object.fromEntries :', JSON.stringify(Object.fromEntries(form)));
const toObject = (fd) => {
const obj = {};
for (const key of new Set(fd.keys())) {
const all = fd.getAll(key);
obj[key] = all.length > 1 ? all : all[0];
}
return obj;
};
console.log('getAll で配列を残す :', JSON.stringify(toObject(form)));
実行結果:
Object.fromEntries : {"name":"佐藤","tags":"おすすめ"}
getAll で配列を残す : {"name":"佐藤","tags":["新着","おすすめ"]}
Object.fromEntries(fd) は同じ名前の値を上書きするため、tags は最後の「おすすめ」だけになります。 チェックボックスなど同じ名前が複数ある場合は、getAll() で配列にまとめる toObject() のような関数を使いましょう。JSON で送るときは headers: { 'Content-Type': 'application/json' } を付けます(こちらは付けるのが正解です)。fetch の基本的な使い方は別記事「JavaScript fetchの使い方」で解説しています。
URLSearchParamsとの違い
URLSearchParams は application/x-www-form-urlencoded(a=1&b=2 の形)で送るためのオブジェクトです。 文字だけのデータなら、こちらのほうが本文が小さくなります。
const params = new URLSearchParams(form); // 上の form(name, tags×2)
console.log('URLSearchParams :', params.toString());
await show('[urlenc]', await fetch(`${base}/api/search`, { method: 'POST', body: params }));
実行結果:
URLSearchParams : name=%E4%BD%90%E8%97%A4&tags=%E6%96%B0%E7%9D%80&tags=%E3%81%8A%E3%81%99%E3%81%99%E3%82%81
[urlenc] 200 {"contentType":"application/x-www-form-urlencoded;charset=UTF-8","body":{"name":"佐藤","tags":["新着","おすすめ"]}}
ファイルを含む FormData を URLSearchParams に変換すると、ファイルは送られず文字列になってしまいます。
q=PC&file=%5Bobject+File%5D
| 項目 | FormData | URLSearchParams |
|---|---|---|
| Content-Type(自動) | multipart/form-data; boundary=... |
application/x-www-form-urlencoded;charset=UTF-8 |
| ファイル | 送れる | 送れない([object File] という文字になる) |
| 本文の形 | 境界線で区切った複数パート | a=1&b=2 |
| 受信(Express) | multer などが必要 | express.urlencoded() |
| 受信(PHP) | $_POST / $_FILES |
$_POST |
| GET のクエリに使う | 不可 | url + '?' + params で使える |
| 向いている場面 | ファイルを含む送信 | 検索条件など文字だけの送信 |
受信側:PHPで$_POSTと$_FILESを受け取る
PHP は multipart/form-data を自動で解析し、文字は $_POST、ファイルは $_FILES に入れてくれます。 複数の値を配列で受け取るには、name の末尾に [] を付けるのが PHP のルールです。
<?php
// upload.php(PHP 8.4)
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'POST only']);
exit;
}
$saved = [];
// photos[] で送られた複数ファイルは、name・tmp_name などが配列になる
$photos = $_FILES['photos'] ?? null;
if ($photos && is_array($photos['name'])) {
foreach ($photos['name'] as $i => $originalName) {
if ($photos['error'][$i] !== UPLOAD_ERR_OK) {
$saved[] = ['name' => $originalName, 'error' => $photos['error'][$i]];
continue;
}
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
$dest = __DIR__ . '/uploads/' . bin2hex(random_bytes(8)) . '.' . $ext;
move_uploaded_file($photos['tmp_name'][$i], $dest);
$saved[] = ['name' => $originalName, 'size' => $photos['size'][$i], 'type' => $photos['type'][$i]];
}
}
echo json_encode([
'content_type' => $_SERVER['CONTENT_TYPE'] ?? '',
'post' => $_POST, // 文字の項目(tags[] は配列になる)
'files' => $saved,
], JSON_UNESCAPED_UNICODE);
mkdir -p uploads
php -S 127.0.0.1:7201
送信側(Node.js):
// client_php.mjs
const url = 'http://127.0.0.1:7201/upload.php';
const fd = new FormData();
fd.append('title', '備品の写真');
fd.append('tags[]', '新着');
fd.append('tags[]', 'おすすめ');
fd.append('photos[]', new Blob(['desk'], { type: 'image/jpeg' }), 'desk.jpg');
fd.append('photos[]', new Blob(['chair!'], { type: 'image/jpeg' }), 'chair.jpg');
let res = await fetch(url, { method: 'POST', body: fd });
console.log('[PHP OK]', res.status, await res.text());
const fd2 = new FormData();
fd2.append('tags', '新着');
fd2.append('tags', 'おすすめ');
res = await fetch(url, { method: 'POST', body: fd2 });
console.log('[[] なし]', await res.text());
res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'multipart/form-data' }, body: fd });
console.log('[PHP 手動CT]', res.status, await res.text());
実行結果(PHP 8.4.21):
[PHP OK] 200 {"content_type":"multipart\/form-data; boundary=----formdata-undici-083595773420","post":{"title":"備品の写真","tags":["新着","おすすめ"]},"files":[{"name":"desk.jpg","size":4,"type":"image\/jpeg"},{"name":"chair.jpg","size":6,"type":"image\/jpeg"}]}
[[] なし] {"content_type":"multipart\/form-data; boundary=----formdata-undici-054972901550","post":{"tags":"おすすめ"},"files":[]}
[PHP 手動CT] 200 {"content_type":"multipart\/form-data","post":[],"files":[]}
php -S のログには次の警告が出ていました。
PHP Warning: PHP Request Startup: Missing boundary in multipart/form-data POST data in Unknown on line 0
読み取れること
tags[]のように[]を付けると、PHP では配列で受け取れます。[]なしだと最後の「おすすめ」だけになります(Express + multer は[]なしでも配列にまとめてくれました)。- Content-Type を手で付けると、PHP ではエラーにならずに
$_POSTも$_FILESも空になります。 ステータスは200のまま空のデータが届くので、気づきにくい点に注意しましょう。 - アップロードできる大きさは
php.iniのupload_max_filesizeとpost_max_sizeで決まります。
| 送る名前 | Express + multer | PHP |
|---|---|---|
tags を2回 |
["新着","おすすめ"](配列) |
"おすすめ"(最後の1つ) |
tags[] を2回 |
tags という名前で ["新着","おすすめ"](配列) |
["新着","おすすめ"](配列) |
ファイル photos を複数 |
upload.array('photos') で req.files |
photos[] にすると $_FILES['photos']['name'][0] … |
multer に tags[] を2回送ったときの実行結果は {"body":{"tags":["新着","おすすめ"]}} でした。name[] 形式で送れば、Express + multer と PHP のどちらでも同じ配列で受け取れるので、両方に対応したい画面では [] 付きの名前にそろえておくと便利です。
よくあるつまずきと直し方
| 症状 | 原因 | 直し方 |
|---|---|---|
Multipart: Boundary not found(multer) |
Content-Type を手で付けた | headers から Content-Type を消す |
PHP で $_POST / $_FILES が空 |
同上(ログに Missing boundary) | 同上 |
req.body が空(Express) |
multipart を解析するミドルウェアがない | upload.none() などの multer を付ける |
LIMIT_UNEXPECTED_FILE |
multer で指定していない名前、または maxCount 超え | fields / array の名前と数を合わせる |
LIMIT_FILE_SIZE |
サイズ上限超え | limits.fileSize を見直す、画面で事前に確認 |
| チェックボックスの値が1つしか届かない | get や Object.fromEntries を使った/PHP で [] なし |
getAll、PHP は name="tags[]" |
| disabled の欄が送られない | disabled は送信対象外 | readonly にする |
| 押したボタンの値が入らない | new FormData(form) だけ |
new FormData(form, event.submitter) |
fetch(form.action) が404 |
name="action" の入力欄が action を隠している | name を変える、form.getAttribute('action') |
ファイルが [object File] という文字で届く |
URLSearchParams に変換した | FormData のまま送る |
console.log(fd) で中身が見えない |
FormData は中身を表示しない | console.log([...fd]) |
よくある質問
Q. FormData を送るとき Content-Type はどう書けばよいですか?
A. 何も書かないのが正解です。 fetch や XMLHttpRequest が boundary 付きの multipart/form-data; boundary=... を自動で設定します。手で書くと boundary が抜け、Express + multer では Multipart: Boundary not found、PHP では $_POST と $_FILES が空になることを、この記事で確認しました。
Q. FormData の中身を確認するには?
A. console.log([...fd]) か for (const [k, v] of fd) console.log(k, v) で確認できます。同じ名前の値がいくつあるかは fd.getAll('名前') で調べられます。
Q. FormData と JSON、どちらで送るべきですか? A. ファイルを含むなら FormData、文字だけで入れ子のデータ構造を送るなら JSON が目安です。FormData は値がすべて文字列になるため、数値や true/false、入れ子のオブジェクトをそのまま送りたいときは JSON が向いています。
Q. アップロードの進捗を fetch で取れますか?
A. 送信側の進捗を取るイベントは fetch にはありません。 進捗バーが必要なときは XMLHttpRequest の xhr.upload.addEventListener('progress', ...) を使います。
まとめ
- FormData は名前と値の組を集めて multipart/form-data で送るオブジェクト。
append(追加)とset(上書き)、get(最初の1つ)とgetAll(全部)を使い分ける - fetch で送るときは Content-Type を書かない。手で書くと boundary が抜け、multer は400、PHP は空のデータになる
- ファイルは
append(名前, File/Blob, ファイル名)。同じ名前で複数回 append すれば複数ファイル new FormData(form, event.submitter)で押したボタンの値も含められる。disabled の欄と外れたチェックボックスは入らない- 進捗バーは XMLHttpRequest、JSON 化は getAll で配列を残す
- 受信側は Express なら multer、PHP なら
$_POST/$_FILES。PHP で配列にするならname[] - 文字だけなら URLSearchParams も選べる。ファイルは送れない
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「FormDataの使い方|fetchで送信・ファイルアップロードをコピペ解説」
株式会社RJCでは、FormDataを使ったデータ送信の仕組みを仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。