【Vue.js】ファイルアップロード機能の実装|画像・PDFをサーバーに送信する方法

【Vue.js】ファイルアップロード機能の実装|画像・PDFをサーバーに送信する方法 Vue.js

画像やPDFなどのファイルを、サーバーへ送る機能は、管理画面やプロフィールの編集など、多くのアプリで必要になります。ブラウザのFormDataとAxiosを使えば作れますが、そのまま書くと、動かない、失敗に気づけない、安全でない、といった問題が出ます。この記事では、Vue 3(<script setup>)で、実用に耐えるアップロードの実装を、サーバー側の受け取りまで含めて整理します。Axiosの基本は非同期データの取得と表示をご覧ください。

スポンサーリンク

実装の流れ

  1. <input type="file">でファイルを選ぶ(changeイベント)
  2. サイズと種類を、クライアント側で確認する(補助)
  3. 送信する瞬間にFormDataへ追加する
  4. AxiosでPOSTする
  5. 結果(成功・失敗)を画面に示す
  6. サーバー側で、あらためて検証して保存する(必須)

完成コード(クライアント側)

複数のファイルの選択、サイズと種類の確認、画像のプレビュー、進捗の表示、失敗の表示までを備えた、完成版です。

Vue:ファイルアップロード
<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では自動で扱われるのが一般的ですが、そもそも書く必要がないので、書かないのが安全です。

JavaScript:fetchで送るとき
// 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の設定だけで、サイズや種類の制限をかけられます。クライアントの検証は、すり抜けられるので、サーバー側の検証が本番の守りです。

JavaScript:server.js
// 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)は偽装できます。本格的に守るなら、ファイルの先頭のバイト列(マジックナンバー)などで、実際の種類を確かめてください。

関連記事

よくある質問(FAQ)

QContent-Typeに「multipart/form-data」を指定しなくてよいのですか?
A指定しないでください。FormDataを送るときは、ブラウザがboundaryつきのContent-Typeを自動で付けます。手で指定すると、boundaryが付かず、サーバーが読めなくなるおそれがあります。
Qacceptを指定すれば、画像以外は送れませんか?
A送れてしまいます。acceptは選択ダイアログのヒントで、利用者は切り替えて、別のファイルも選べます。サーバー側でも、種類とサイズを必ず検証してください。
Q複数のファイルを送ると、サーバーで受け取れません。
Aフィールド名が一致していない可能性があります。クライアントのformData.append('files', file)と、サーバーのupload.array('files')で、同じ名前にしてください。'files[]'はPHPの書き方で、multerでは一致しません。
Qアップロード後も、選んだファイルの名前が入力欄に残ります。
A入力欄の選択状態は、スクリプトから任意の値には設定できません。空にする場合は、input.value = ''で戻すのが一般的です。ただし、MDNの該当ページには、クリアの方法は記載されていません。

まとめ

ファイルのアップロードは、FormDataにファイルをappendして、AxiosでPOSTする、が基本の形です。Content-Typeは書かず、フィールド名をサーバーと揃えます。クライアント側の検証は補助にとどめ、サーバー側で、サイズ、種類、保存先、ファイル名、中身まで検証してください。失敗は画面に出して、利用者が気づけるようにしましょう。