コンテンツにスキップ

fetch/axiosでファイルアップロードを実装する方法|FormData・プログレスバー・エラー処理

カテゴリ:JavaScript・フロントエンド

モダンなWebアプリケーションでファイルアップロードを実装する際、fetch API または axios を使って非同期にファイルを送信する実装が標準的になっています。単純な送信だけであれば数行で実装できますが、プログレスバー・エラーハンドリング・大容量ファイルのチャンク分割・CORS対応まで含めると、考慮すべき点が多数あります。本記事ではこれらを体系的に解説します。

FormData の使い方(append / set)

FormData は、HTMLフォームの enctype="multipart/form-data" と同等のデータをJavaScriptで構築するためのAPIです。ファイルだけでなく、テキストフィールドも含めたフォームデータ全体を扱えます。

// input[type="file"] からファイルを取得
const fileInput = document.querySelector('#file-input');
const file = fileInput.files[0];

// FormData を作成
const formData = new FormData();

// append: 同名のキーで複数の値を追加できる
formData.append('file', file);
formData.append('file', anotherFile); // 複数ファイルを同じキーで送れる
formData.append('description', 'アップロードテスト');
formData.append('userId', '12345');

// set: 既存のキーがあれば上書き(追加ではなく置換)
formData.set('file', newFile); // 既存の 'file' エントリをすべて削除して置換

// 中身の確認
for (const [key, value] of formData.entries()) {
    console.log(key, value);
}

// 複数ファイルを input[type="file" multiple] から追加する場合
const multipleFiles = fileInput.files;
Array.from(multipleFiles).forEach(f => {
    formData.append('files[]', f);
});

fetch でのファイルアップロード

fetch で FormData を送信する際に重要なのは、Content-Type ヘッダーを手動で設定してはいけないという点です。

async function uploadWithFetch(file) {
    const formData = new FormData();
    formData.append('file', file);
    formData.append('name', file.name);

    // NG: Content-Type を手動で設定してはいけない
    // fetch('/upload', {
    //     method: 'POST',
    //     headers: { 'Content-Type': 'multipart/form-data' }, // ← これは誤り!
    //     body: formData,
    // });

    // OK: Content-Type は fetch が自動で設定する(boundary パラメータも含む)
    const response = await fetch('/api/upload', {
        method: 'POST',
        body: formData,
        // headers は指定しない(または Content-Type 以外のヘッダーのみ指定)
    });

    if (!response.ok) {
        const error = await response.json().catch(() => ({ message: 'Unknown error' }));
        throw new Error(`Upload failed: ${response.status} - ${error.message}`);
    }

    return response.json();
}

// 使用例
const fileInput = document.querySelector('#file-input');
fileInput.addEventListener('change', async (e) => {
    const file = e.target.files[0];
    if (!file) return;

    try {
        const result = await uploadWithFetch(file);
        console.log('アップロード成功:', result);
    } catch (err) {
        console.error('アップロード失敗:', err.message);
    }
});

Content-Type を手動設定してはいけない理由:multipart/form-data のリクエストには boundary パラメータ(例: boundary=----WebKitFormBoundaryXXX)が必要で、これはブラウザが FormData のデータに基づいて自動生成します。手動で Content-Type: multipart/form-data を設定すると boundary が欠落し、サーバー側でパースできなくなります。

fetch でのプログレス表示(ReadableStream)

fetch API ではレスポンスの受信進捗は ReadableStream で取得できますが、アップロードの進捗は fetch 単体では取得できません。アップロードプログレスが必要な場合は axios か XMLHttpRequest を使います。

// fetch でダウンロード進捗を取得する例(アップロードとは逆方向)
async function downloadWithProgress(url, onProgress) {
    const response = await fetch(url);
    const contentLength = response.headers.get('Content-Length');
    const total = parseInt(contentLength, 10);
    let loaded = 0;

    const reader = response.body.getReader();
    const chunks = [];

    while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        chunks.push(value);
        loaded += value.length;
        onProgress(loaded, total);
    }

    return new Blob(chunks);
}

axios でのアップロード + onUploadProgress でプログレスバー実装

axios はアップロード進捗を onUploadProgress コールバックで取得できます。XMLHttpRequest をラップしているため、ブラウザのアップロード進捗イベントを利用できます。

import axios from 'axios';

async function uploadWithAxios(file, onProgress) {
    const formData = new FormData();
    formData.append('file', file);

    const response = await axios.post('/api/upload', formData, {
        headers: {
            // axios は FormData を自動検出して Content-Type を設定するが、
            // 明示的に undefined を指定して axios に任せることもできる
        },
        onUploadProgress: (progressEvent) => {
            if (progressEvent.total) {
                const percent = Math.round(
                    (progressEvent.loaded * 100) / progressEvent.total
                );
                onProgress(percent);
            }
        },
        // タイムアウト設定(ミリ秒)
        timeout: 300000, // 5分
    });

    return response.data;
}

// プログレスバーと組み合わせた使用例
const progressBar = document.querySelector('#progress-bar');
const progressText = document.querySelector('#progress-text');

async function handleUpload(file) {
    try {
        const result = await uploadWithAxios(file, (percent) => {
            progressBar.style.width = `${percent}%`;
            progressBar.setAttribute('aria-valuenow', percent);
            progressText.textContent = `${percent}%`;
        });
        console.log('完了:', result);
    } catch (err) {
        if (axios.isAxiosError(err)) {
            console.error('ステータス:', err.response?.status);
            console.error('メッセージ:', err.response?.data?.message);
        } else {
            console.error('予期しないエラー:', err);
        }
    }
}

大容量ファイルのチャンク分割アップロード

単一のリクエストで送れないサイズ(数百MB~GB)のファイルは、チャンク(小さなかたまり)に分割して送信する方法があります。サーバー側でチャンクを受け取り、最後に結合します。

const CHUNK_SIZE = 5 * 1024 * 1024; // 5MiB ずつ分割

async function uploadInChunks(file, onProgress) {
    const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
    const uploadId = crypto.randomUUID(); // アップロードセッションID

    for (let i = 0; i < totalChunks; i++) {
        const start = i * CHUNK_SIZE;
        const end = Math.min(start + CHUNK_SIZE, file.size);
        const chunk = file.slice(start, end);

        const formData = new FormData();
        formData.append('chunk', chunk);
        formData.append('uploadId', uploadId);
        formData.append('chunkIndex', String(i));
        formData.append('totalChunks', String(totalChunks));
        formData.append('filename', file.name);

        await fetch('/api/upload/chunk', {
            method: 'POST',
            body: formData,
        });

        // 各チャンク送信後に進捗を更新
        const percent = Math.round(((i + 1) / totalChunks) * 100);
        onProgress(percent);
    }

    // 全チャンク送信後にサーバーへ結合を指示
    const response = await fetch('/api/upload/complete', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ uploadId, filename: file.name }),
    });

    return response.json();
}

エラー処理(413 / ネットワークエラー / タイムアウト)

ファイルアップロードで発生しうるエラーをパターン別に処理します。

async function robustUpload(file) {
    const formData = new FormData();
    formData.append('file', file);

    try {
        const response = await fetch('/api/upload', {
            method: 'POST',
            body: formData,
            signal: AbortSignal.timeout(60000), // 60秒でタイムアウト
        });

        // ステータスコード別のエラー処理
        if (response.status === 413) {
            throw new Error('ファイルサイズが大きすぎます。サーバーの上限を超えています。');
        }

        if (response.status === 415) {
            throw new Error('サポートされていないファイル形式です。');
        }

        if (response.status === 422) {
            const data = await response.json();
            throw new Error(`バリデーションエラー: ${data.message}`);
        }

        if (!response.ok) {
            throw new Error(`サーバーエラー: ${response.status} ${response.statusText}`);
        }

        return await response.json();

    } catch (err) {
        if (err.name === 'AbortError' || err.name === 'TimeoutError') {
            throw new Error('アップロードがタイムアウトしました。ネットワーク接続を確認してください。');
        }

        if (err instanceof TypeError && err.message.includes('Failed to fetch')) {
            throw new Error('ネットワークエラーが発生しました。接続を確認してください。');
        }

        throw err; // それ以外は再スロー
    }
}

CORSの設定ポイント

異なるオリジン(ドメイン・ポート)のAPIにアップロードする場合、CORS(Cross-Origin Resource Sharing)の設定が必要です。multipart/form-data を含む POST リクエストは「シンプルリクエスト」ではなく、プリフライトリクエスト(OPTIONS メソッド)が発生します。

// フロントエンド側:特別な設定は不要(ブラウザが自動でプリフライトを送信)
const response = await fetch('https://api.example.com/upload', {
    method: 'POST',
    body: formData,
    // credentials: 'include', // クッキーを含む場合(サーバー側で credentials: true が必要)
});
# Nginx でのCORS設定例
location /api/upload {
    # プリフライトリクエスト(OPTIONS)への応答
    if ($request_method = 'OPTIONS') {
        add_header 'Access-Control-Allow-Origin' 'https://frontend.example.com';
        add_header 'Access-Control-Allow-Methods' 'POST, OPTIONS';
        add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type';
        add_header 'Access-Control-Max-Age' '86400';
        add_header 'Content-Length' '0';
        return 204;
    }

    add_header 'Access-Control-Allow-Origin' 'https://frontend.example.com';
    proxy_pass http://app_backend;
}

ファイルアップロードに関してCORSで特に注意が必要な点:

  • Access-Control-Allow-HeadersContent-Type が含まれていると、ブラウザが Content-Type を自由に設定できると誤解されることがある
  • FormData の場合は Content-Type ヘッダーをカスタム設定していないため、プリフライトが発生しない場合もある
  • 認証トークンを Authorization ヘッダーで送る場合はプリフライトが必ず発生する

この記事で使えるテストファイル(無料)

よくある質問

fetchでファイルアップロードする際にContent-Typeを設定すべき?

いいえ、FormDataを使う場合はContent-Typeヘッダーを手動で設定しないでください。ブラウザが自動的にmultipart/form-dataとboundaryを設定します。

axiosでアップロードの進捗(プログレス)を表示するには?

axiosのonUploadProgressコールバックを使います。event.loaded / event.totalで進捗率を計算できます。

fetchとaxiosのどちらを使うべき?

ブラウザ標準のfetchで十分ですが、アップロードの進捗表示が必要な場合はaxiosが便利です。fetchのReadableStreamでも実装可能ですがコードが複雑になります。

📚 関連記事

PNG vs WebP vs AVIF|画像フォーマットの選び方と変換方法

PNG / JPEG / WebP / AVIF の特徴・用途・ブラウザ対応状況を比較。picture 要素での出し分け、DevLab の画像フォーマット変換ツールの使い方も解説。

2026-04-18

Whois でドメイン情報を調べる方法|有効期限・ネームサーバー・登録者

Whois でわかること (登録者・有効期限・レジストラ・NS)、GDPR によるプライバシー保護の影響、ドメイン管理の実務的な使い方を解説。

2026-04-18

HTTP ステータスコード完全ガイド|よくあるエラーの原因と対処法

開発者が頻出する HTTP ステータスコード (200/301/302/400/401/403/404/413/422/429/500/502/503/504) の意味・原因・対処法を解説。301 vs 302 の SEO 影響、400 vs 422 の使い分けも。

2026-04-18

cURL コマンドを JavaScript fetch・Python requests に変換する方法|DevTools 連携

Chrome DevTools の Copy as cURL を fetch / axios / Python requests / PHP cURL / Go net/http に変換する手順を解説。主要 cURL オプション (-X / -H / -d / -F / -u / -b / -L) の変換パターン、認証トークンの扱い、注意点まで。

2026-04-16

Cookie のセキュリティフラグ完全ガイド|Secure / HttpOnly / SameSite / __Host-

Cookie のセキュリティ属性 Secure / HttpOnly / SameSite (Strict/Lax/None) / __Host- __Secure- プレフィックス / 4096 バイト制限を解説。CSRF / XSS / セッションハイジャック対策と、Laravel / Express の実装例。

2026-04-16

JWT のセキュリティベストプラクティス|alg none 攻撃 / 有効期限 / 署名検証

JWT (JSON Web Token) の代表的な脆弱性 6 種類 (alg none 攻撃 / 鍵混同 / 無期限トークン / payload への機密情報 / 失効不可 / 弱いシークレット) と対策。リフレッシュトークンパターン、失効リスト、HttpOnly Cookie 格納まで。

2026-04-16