Saltar al contenido

Cómo implementar carga de archivos con fetch/axios | FormData, barra de progreso, manejo de errores

Categoría: JavaScript / Frontend
Este artículo está disponible actualmente solo en japonés. Estamos trabajando en las traducciones.

Al implementar carga de archivos en aplicaciones web modernas, se ha convertido en estándar enviar archivos de forma asincrónica utilizando la API <code>fetch</code> o <code>axios</code>. Aunque la transmisión simple se puede implementar en solo unas pocas líneas, considerar barras de progreso, manejo de errores, fragmentación de archivos grandes y soporte CORS requiere atención a muchos factores. Este artículo explica sistemáticamente todos estos.

Comparación de fetch / axios / FormData Comparación de soporte de funciones fetch axios XHR + FormData POST送信 OK OK OK JSON自動シリアライズ - OK - 進捗イベント (upload) × OK OK リクエスト中断 AbortController CancelToken xhr.abort() タイムアウト manual config xhr.timeout インターセプター - OK - Elegir axios o XHR si necesita barra de progreso
Fig 1: Comparación de soporte de funciones fetch / axios / XHR+FormData

Cómo usar FormData (append / set)

<code>FormData</code> es una API para construir en JavaScript datos equivalentes a <code>enctype="multipart/form-data"</code> en formularios HTML. Puede manejar datos de formulario completos incluyendo no solo archivos sino también campos de texto.

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

Carga de archivo con fetch

Cuando se envía FormData con <code>fetch</code>, lo importante es <strong>no establecer manualmente el encabezado Content-Type</strong>.

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

Por qué no debe configurar Content-Type manualmente: las solicitudes <code>multipart/form-data</code> requieren un parámetro <code>boundary</code> (por ejemplo, <code>boundary=----WebKitFormBoundaryXXX</code>), que el navegador genera automáticamente en función de FormData. Si configura manualmente <code>Content-Type: multipart/form-data</code>, faltará el boundary y el servidor no podrá analizarlo.

Visualización del progreso con fetch (ReadableStream)

Con la API fetch, el progreso de recepción de respuesta se puede obtener a través de <code>ReadableStream</code>, pero <strong>el progreso de carga no se puede obtener solo con fetch</strong>. Si se necesita el progreso de carga, use axios o 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);
}

Carga de archivo con axios + Implementación de barra de progreso con onUploadProgress

axios puede recuperar el progreso de carga a través de la devolución de llamada <code>onUploadProgress</code>. Dado que envuelve XMLHttpRequest, puedes utilizar los eventos de progreso de carga del navegador.

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

Carga dividida en fragmentos para archivos grandes

Para archivos demasiado grandes para enviar en una sola solicitud (cientos de MB a GB), puede dividirlos en fragmentos (piezas pequeñas) y enviarlos por separado. El servidor recibe cada fragmento y los combina al final.

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

Manejo de errores (413 / error de red / tiempo de espera)

Manejar errores que pueden ocurrir durante la carga de archivos por patrón.

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; // それ以外は再スロー
    }
}

Puntos clave de configuración CORS

Cuando carga a una API en un origen diferente (dominio o puerto), se requiere configuración de CORS (Cross-Origin Resource Sharing). Las solicitudes POST que contienen multipart/form-data no son "solicitudes simples" y desencadenarán una solicitud previa (método 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;
}

Puntos que requieren especial atención de CORS con respecto a cargas de archivos:

  • incluir <code>Content-Type</code> en <code>Access-Control-Allow-Headers</code> puede generar malentendidos de que el navegador puede establecer Content-Type libremente
  • Con FormData, es posible que no se produzcan solicitudes preflight porque el encabezado Content-Type no se configura de forma personalizada
  • Cuando se envía un token de autenticación en el encabezado <code>Authorization</code>, siempre ocurre una solicitud de verificación previa.

Archivos de prueba para este artículo (gratis)

  • → <a href="/ja/files/threshold/" class="text-primary-600 dark:text-primary-400 hover:underline">Lista de archivos de prueba de valores límite</a> — Verificar el comportamiento de fetch/axios con archivos de varios tamaños
  • → <a href="/ja/files/images/" class="text-primary-600 dark:text-primary-400 hover:underline">Lista de imágenes de prueba</a> — Pruebe cargas con varios formatos de imagen como JPEG, PNG y WebP

Artículos relacionados

  • → <a href="/ja/blog/php-file-upload/" class="text-primary-600 dark:text-primary-400 hover:underline">Cómo Implementar la Carga de Archivos en PHP | Guía Completa de Validación, Almacenamiento y Seguridad</a>
  • → <a href="/ja/blog/multipart-form-data-overhead/" class="text-primary-600 dark:text-primary-400 hover:underline">Calcular con Precisión el Overhead de multipart/form-data</a>
  • → <a href="/ja/blog/file-validation-checklist/" class="text-primary-600 dark:text-primary-400 hover:underline">Lista de Verificación de Implementación de Validación de Archivos en Formularios Web</a>

Preguntas frecuentes

¿Debe establecer Content-Type al cargar archivos con fetch?

No, cuando utilice FormData, no establezca manualmente el encabezado <code>Content-Type</code>. El navegador establecerá automáticamente <code>multipart/form-data</code> y <code>boundary</code>.

¿Cómo mostrar el progreso de carga con axios?

Usa la devolución de llamada onUploadProgress de axios. Puedes calcular el porcentaje de progreso usando event.loaded / event.total.

¿Cuál debería usar: fetch o axios?

El fetch estándar del navegador es suficiente, pero axios es conveniente si necesita mostrar el progreso de carga. Es posible implementar con ReadableStream de fetch, pero el código se vuelve más complejo.