画像やPDFなどのファイルを、サーバーへ送る機能は、管理画面やプロフィールの編集など、多くのアプリで必要になります。ブラウザのFormDataとAxiosを使えば作れますが、そのまま書くと、動かない、失敗に気づけない、安全でない、といった問題が出ます。この記事では、Vue 3(<script setup>)で、実用に耐えるアップロードの実装を、サーバー側の受け取りまで含めて整理します。Axiosの基本は非同期データの取得と表示をご覧ください。
実装の流れ
<input type="file">でファイルを選ぶ(changeイベント)- サイズと種類を、クライアント側で確認する(補助)
- 送信する瞬間に
FormDataへ追加する - Axiosで
POSTする - 結果(成功・失敗)を画面に示す
- サーバー側で、あらためて検証して保存する(必須)
完成コード(クライアント側)
複数のファイルの選択、サイズと種類の確認、画像のプレビュー、進捗の表示、失敗の表示までを備えた、完成版です。
<script setup>
import { ref, onBeforeUnmount } from 'vue'
import axios from 'axios'
const MAX_SIZE = 5 * 1024 * 1024 // 5MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'application/pdf']
const inputRef = ref(null)
const files = ref([]) // 検証を通ったファイル
const previews = ref([]) // 画像のプレビュー { name, url }
const errors = ref([])
const message = ref('')
const uploading = ref(false)
const progress = ref(0)
function clearPreviews() {
previews.value.forEach((p) => URL.revokeObjectURL(p.url)) // 作ったURLは、必ず解放する
previews.value = []
}
onBeforeUnmount(clearPreviews)
// ファイルを選んだとき:クライアント側で、サイズと種類を確認する(あくまで補助)
function onFileChange(event) {
const picked = Array.from(event.target.files)
const valid = []
errors.value = []
message.value = ''
for (const file of picked) {
if (!ALLOWED_TYPES.includes(file.type)) {
errors.value.push(`${file.name}:対応していない形式です`)
} else if (file.size > MAX_SIZE) {
errors.value.push(`${file.name}:5MBを超えています`)
} else {
valid.push(file)
}
}
clearPreviews()
files.value = valid
previews.value = valid
.filter((file) => file.type.startsWith('image/'))
.map((file) => ({ name: file.name, url: URL.createObjectURL(file) }))
}
async function upload() {
if (files.value.length === 0) {
errors.value = ['アップロードするファイルを選んでください']
return
}
// 送信する瞬間に FormData を作る。フィールド名 'files' は、サーバー側と揃える
const formData = new FormData()
for (const file of files.value) {
formData.append('files', file)
}
uploading.value = true
progress.value = 0
errors.value = []
message.value = ''
try {
// Content-Type は書かない。ブラウザが boundary 付きで自動設定する
await axios.post('/api/upload', formData, {
onUploadProgress: (event) => {
if (event.total) progress.value = Math.round((event.loaded / event.total) * 100)
}
})
message.value = 'アップロードしました'
files.value = []
clearPreviews()
if (inputRef.value) inputRef.value.value = '' // 選択状態を空に戻す
} catch (e) {
errors.value = [
e.response
? `アップロードに失敗しました(${e.response.status})`
: '通信できませんでした。ネットワークを確認してください。'
]
} finally {
uploading.value = false
}
}
</script>
<template>
<form @submit.prevent="upload">
<label for="file">画像・PDF(1ファイル5MBまで、複数選択できます)</label>
<input
id="file"
ref="inputRef"
type="file"
multiple
accept="image/jpeg,image/png,application/pdf"
@change="onFileChange"
>
<ul v-if="errors.length" role="alert" class="errors">
<li v-for="error in errors" :key="error">{{ error }}</li>
</ul>
<ul class="previews">
<li v-for="preview in previews" :key="preview.url">
<img :src="preview.url" :alt="preview.name" width="80">
</li>
</ul>
<progress v-if="uploading" :value="progress" max="100">{{ progress }}%</progress>
<button type="submit" :disabled="uploading">アップロード</button>
<p role="status">{{ message }}</p>
</form>
</template>
コードのポイント
Content-Typeは書かない
よく見かけるheaders: { 'Content-Type': 'multipart/form-data' }は、書かないでください。MDNは、FormDataをfetchやXHRで送るときに、Content-Typeを明示的に設定してはいけない、と警告しています。ブラウザが、各フィールドの区切りを示すboundaryつきの値を自動で付けられなくなり、サーバーが本文を読めなくなるためです。Axiosでは自動で扱われるのが一般的ですが、そもそも書く必要がないので、書かないのが安全です。
// fetch で FormData を送るとき
const response = await fetch('/api/upload', {
method: 'POST',
// NG:Content-Type を自分で指定してはいけない
// headers: { 'Content-Type': 'multipart/form-data' },
body: formData // これだけでよい。ブラウザが boundary 付きの Content-Type を付ける
})
accept属性は「ヒント」にすぎない
acceptは、選択ダイアログで選べるファイルを絞るヒントで、検証ではありません。MDNによると、ユーザーは選択ダイアログで切り替えて、どんなファイルでも選べてしまいます。そのため、acceptは、サーバー側の検証で裏付けるよう、MDNも明記しています。クライアント側では、Fileのsize(バイト数)とtype(MIMEタイプ)で確認できますが、これも使い勝手のための補助です。
フィールド名は、サーバーと揃える
formData.append('files', file)の'files'は、サーバー側が期待する名前と一致させます。複数のファイルは、同じ名前で複数回appendします。'files[]'という書き方はPHPの慣習で、Expressのmulterでは、upload.array('files')の名前と一致しないため、受け取れません。
プレビューのURLは解放する
URL.createObjectURL()で作ったURLは、使い終わったらURL.revokeObjectURL()で解放します。そうしないと、メモリに残り続けます。
失敗を画面に出す
失敗したのに何も表示されないと、利用者は気づけません。エラーはrole="alert"、成功はrole="status"で、読み上げソフトにも伝わるようにします。ファイル入力には、<label>をforとidで結びつけます。通知の見せ方はトースト通知を作る方法、通信の状態の扱いは非同期APIの状態管理パターンをご覧ください。
サーバー側:安全に受け取る(Express + multer)
サーバー側の例です。multerの設定だけで、サイズや種類の制限をかけられます。クライアントの検証は、すり抜けられるので、サーバー側の検証が本番の守りです。
// server.js(Express + multer)
const express = require('express')
const multer = require('multer')
const path = require('path')
const crypto = require('crypto')
// 許可する種類と、保存するときの拡張子(元のファイル名の拡張子は信用しない)
const ALLOWED = { 'image/jpeg': '.jpg', 'image/png': '.png', 'application/pdf': '.pdf' }
const storage = multer.diskStorage({
destination: '/var/app-uploads', // Web で公開されるディレクトリの外に置く
filename: (req, file, cb) => {
cb(null, crypto.randomUUID() + ALLOWED[file.mimetype]) // 名前は、サーバーが付け直す
}
})
const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024, files: 5 }, // サイズと個数の上限
fileFilter: (req, file, cb) => cb(null, file.mimetype in ALLOWED) // 許可した種類だけ受け付ける
})
const app = express()
// 複数ファイル:フィールド名 'files'、最大5個(クライアントの formData.append('files', …) と一致させる)
app.post('/api/upload', upload.array('files', 5), (req, res) => {
res.json({ count: req.files.length })
})
- サイズと個数の上限:
limitsで、1ファイルの大きさと個数を制限します。 - 種類の絞り込み:
fileFilterで、許可した種類だけを受け付けます。 - 保存先とファイル名:Webで公開されるディレクトリの外に保存し、ファイル名は、サーバーが付け直します。元のファイル名や拡張子を信用すると、意図しないファイルが置かれるおそれがあります。
- 中身の確認:クライアントが申告するMIMEタイプ(
file.mimetype)は偽装できます。本格的に守るなら、ファイルの先頭のバイト列(マジックナンバー)などで、実際の種類を確かめてください。
関連記事
- アップロードせず、ブラウザ内で読む場合はCSVファイルを読み込んで表示する方法、Excelファイルをアップロードして解析する方法
- 入力値の検証全般は入力フォームのバリデーション機能を実装する方法
よくある質問(FAQ)
FormDataを送るときは、ブラウザがboundaryつきのContent-Typeを自動で付けます。手で指定すると、boundaryが付かず、サーバーが読めなくなるおそれがあります。acceptは選択ダイアログのヒントで、利用者は切り替えて、別のファイルも選べます。サーバー側でも、種類とサイズを必ず検証してください。formData.append('files', file)と、サーバーのupload.array('files')で、同じ名前にしてください。'files[]'はPHPの書き方で、multerでは一致しません。input.value = ''で戻すのが一般的です。ただし、MDNの該当ページには、クリアの方法は記載されていません。まとめ
ファイルのアップロードは、FormDataにファイルをappendして、AxiosでPOSTする、が基本の形です。Content-Typeは書かず、フィールド名をサーバーと揃えます。クライアント側の検証は補助にとどめ、サーバー側で、サイズ、種類、保存先、ファイル名、中身まで検証してください。失敗は画面に出して、利用者が気づけるようにしましょう。

