Saltar al contenido

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

Categoría: JavaScript / Frontend

Al implementar carga de archivos en aplicaciones web modernas, se ha convertido en estándar enviar archivos de forma asincrónica utilizando la API fetch o axios. 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 Envio POST OK OK OK Serializacion JSON automatica - OK - Eventos de progreso (subida) × OK OK Cancelar una peticion AbortController CancelToken xhr.abort() Tiempo de espera manual config xhr.timeout Interceptores - 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)

FormData es una API para construir en JavaScript datos equivalentes a enctype="multipart/form-data" 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 fetch, lo importante es no establecer manualmente el encabezado 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);
    }
});

Por qué no debe configurar Content-Type manualmente: las solicitudes multipart/form-data requieren un parámetro boundary (por ejemplo, boundary=----WebKitFormBoundaryXXX), que el navegador genera automáticamente en función de FormData. Si configura manualmente Content-Type: multipart/form-data, 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 ReadableStream, pero el progreso de carga no se puede obtener solo con fetch. 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 onUploadProgress. 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 Content-Type en Access-Control-Allow-Headers 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 Authorization, siempre ocurre una solicitud de verificación previa.

Archivos de prueba para este artículo (gratis)

❓ Preguntas frecuentes

¿Debo fijar Content-Type al subir un archivo con fetch?
No. Si envías un FormData, no fijes tú la cabecera Content-Type: el navegador pone multipart/form-data junto con el boundary.
¿Cómo muestro el progreso de subida con axios?
Usa el callback onUploadProgress y calcula el porcentaje con event.loaded dividido entre event.total.
¿Debo usar fetch o axios?
El fetch nativo basta en la mayoría de los casos, pero axios resulta más cómodo si necesitas una barra de progreso. Se puede hacer con fetch y un ReadableStream, aunque el código se complica bastante.

Preguntas frecuentes

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

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

¿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.