W3docs

Возобновляемая загрузка файлов

Как реализовать возобновляемую загрузку файлов на JavaScript: разбивка на чанки, продолжение после сбоя, сервер на Node.js, resumable.js и File.slice + fetch.

Загрузка 2 ГБ видео через нестабильное мобильное соединение с помощью одного запроса fetch ненадёжна: одно разорванное соединение на 95% — и пользователь начинает всё заново. Возобновляемая загрузка файлов решает эту проблему, разбивая файл на небольшие части, загружая их поочерёдно и запоминая, какие части уже получены — так прерванная загрузка продолжается с того места, где остановилась, а не начинается сначала.

На этой странице рассматривается полная картина: как работает пофрагментная возобновляемая загрузка концептуально, рабочий сервер на Node.js + Express, сохраняющий и собирающий чанки, клиент на базе библиотеки resumable.js, а также версия без зависимостей с использованием File.slice и fetch. Вы также узнаете о распространённой ошибке при сборке чанков и советах по упрочнению системы для продакшена.

Как работает возобновляемая загрузка

Основная идея проста и держится на трёх взаимодействующих элементах:

  1. Разбить файл на чанки. Браузер делит выбранный файл на части фиксированного размера (например, по 1 МБ) с помощью метода Blob.slice, унаследованного File. Сам файл никогда не загружается в память целиком.
  2. Загружать чанки по одному (или по несколько). Каждый чанк — это отдельный HTTP-запрос, содержащий его индекс (чанк 3 из 17), общее количество чанков, имя файла и стабильный идентификатор, однозначно помечающий данную сессию загрузки.
  3. Собирать файл на сервере. Сервер сохраняет каждый чанк на диск под его индексом. Когда все чанки получены, он объединяет их в правильном порядке в итоговый файл.

Возобновляемость обеспечивается шагом 3 плюс шагом проверки перед отправкой на клиенте. Перед загрузкой чанка клиент спрашивает сервер: «У тебя уже есть чанк N?» (как правило, через HTTP-запрос HEAD). Если да — пропускает этот чанк. Таким образом, после сбоя или обновления страницы клиент повторно сканирует файл и отправляет только недостающие части. Именно стабильный идентификатор позволяет серверу распознать вернувшуюся незавершённую загрузку.

File (2.5 MB)
└─ slice into 1 MB chunks ──► [chunk 1] [chunk 2] [chunk 3 (0.5 MB)]
                                  │         │          │
              HEAD /upload?chunk=N  (already there? skip : send)
                                  ▼         ▼          ▼
                          POST /upload (one request per missing chunk)
                                  └────────┬─────────┘
                          server saves chunk-N.bin, then concatenates in order

Преимущества возобновляемой загрузки файлов

  • Улучшенный пользовательский опыт: пользователи могут продолжить загрузку, не начиная заново.
  • Эффективность: после сбоя передаются только недостающие части, а не весь файл.
  • Надёжность при плохом соединении: перебои в сети обрабатываются корректно — особенно это важно для больших файлов и мобильных соединений.
  • Меньше нагрузки на память: работа с небольшими частями позволяет избежать буферизации нескольких гигабайт в памяти.

Реализация возобновляемой загрузки файлов на JavaScript

Настройка окружения

Перед тем как приступить к реализации, убедитесь, что у вас есть следующие инструменты и библиотеки:

  • Современный веб-браузер с поддержкой JavaScript.
  • Сервер, способный обрабатывать загрузку файлов.
  • Библиотека resumable.js (или аналогичная) для управления логикой на стороне клиента.

Установите необходимые зависимости Node.js:

npm install express cors

Конфигурация на стороне сервера

Сначала настройте сервер для обработки чанков и хранения метаданных о загруженных файлах. Вот пример на Node.js и Express. Обратите внимание, что resumable.js по умолчанию передаёт метаданные чанков в строке запроса, поэтому мы читаем из req.query и используем временный каталог на каждый файл для безопасной обработки чанков, поступающих не по порядку.

const express = require('express');
const cors = require('cors');
const fs = require('fs');
const path = require('path');
const app = express();
const port = 3000;

app.use(cors());

// Handle chunk verification for testChunks: true
app.head('/upload', (req, res) => {
  res.set('Access-Control-Allow-Origin', '*');
  const chunkNumber = parseInt(req.query.resumableChunkNumber);
  const identifier = req.query.resumableIdentifier;
  const chunkPath = path.join('uploads', identifier, `chunk-${chunkNumber}.bin`);
  fs.promises.access(chunkPath)
    .then(() => res.status(200).end())
    .catch(() => res.status(404).end());
});

app.post('/upload', async (req, res) => {
  try {
    const chunkNumber = parseInt(req.query.resumableChunkNumber);
    const totalChunks = parseInt(req.query.resumableTotalChunks);
    const identifier = req.query.resumableIdentifier;
    const fileName = req.query.resumableFilename;

    const chunkDir = path.join('uploads', identifier);
    await fs.promises.mkdir(chunkDir, { recursive: true });

    // Read raw body (resumable.js sends chunks as application/octet-stream)
    const buffer = await new Promise((resolve, reject) => {
      const chunks = [];
      req.on('data', chunk => chunks.push(chunk));
      req.on('end', () => resolve(Buffer.concat(chunks)));
      req.on('error', reject);
    });

    const chunkPath = path.join(chunkDir, `chunk-${chunkNumber}.bin`);
    await fs.promises.writeFile(chunkPath, buffer);

    const receivedChunks = (await fs.promises.readdir(chunkDir)).length;
    if (receivedChunks === totalChunks) {
      // Concatenate chunks IN ORDER, one at a time (see warning below).
      const finalPath = path.join('uploads', fileName);
      await fs.promises.writeFile(finalPath, ''); // start with an empty file
      for (let i = 1; i <= totalChunks; i++) {
        const data = await fs.promises.readFile(
          path.join(chunkDir, `chunk-${i}.bin`)
        );
        await fs.promises.appendFile(finalPath, data);
      }
      await fs.promises.rm(chunkDir, { recursive: true, force: true });
      res.status(200).send('File uploaded successfully');
    } else {
      // resumable.js expects a 200 OK for successful chunk uploads
      res.status(200).send('Chunk uploaded successfully');
    }
  } catch (error) {
    console.error('Upload error:', error);
    res.status(500).send('Server error during upload');
  }
});

app.listen(port, () => {
  console.log(`Server running at http://localhost:${port}`);
});
Внимание

Собирайте чанки последовательно, а не параллельно. Распространённая ошибка — передавать поток чтения каждого чанка в один поток записи одновременно (fs.createReadStream(...).pipe(writeStream) внутри цикла). Потоки конкурируют друг с другом, байты перемешиваются в неправильном порядке, а первый завершившийся поток закрывает поток записи раньше времени — в результате файл повреждается. Читайте и добавляйте по одному чанку за раз, как показано выше.

Реализация на стороне клиента

Теперь реализуем клиентскую логику с помощью JavaScript и библиотеки resumable.js. Убедитесь, что библиотека resumable.js подключена в вашем проекте. Мы используем версию 2.1.0 для совместимости с современными браузерами. Для продакшен-окружений рассмотрите стандартизированный протокол tus или нативный File.slice с fetch для лучшего контроля и кросс-платформенной поддержки.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Resumable File Upload</title>
</head>
<body>
  <input type="file" id="fileInput" />
  <button id="uploadButton">Upload</button>
  <p id="progress">Ready</p>

  <script src="https://unpkg.com/[email protected]/resumable.min.js"></script>
  <script>
    const fileInput = document.getElementById('fileInput');
    const uploadButton = document.getElementById('uploadButton');
    const progressEl = document.getElementById('progress');

    const r = new Resumable({
      target: '/upload',
      chunkSize: 1 * 1024 * 1024, // 1MB chunks
      simultaneousUploads: 1,
      testChunks: true,
      throttleProgressCallbacks: 1,
    });

    r.assignBrowse(fileInput);

    uploadButton.addEventListener('click', () => {
      if (r.files.length > 0) {
        r.upload();
      } else {
        alert('Please select a file to upload.');
      }
    });

    r.on('progress', (file, loaded, total) => {
      const percent = Math.round((loaded / total) * 100);
      progressEl.textContent = `Uploading ${file.fileName}: ${percent}%`;
    });

    r.on('fileSuccess', (file, message) => {
      console.log(`File ${file.fileName} uploaded successfully.`);
      progressEl.textContent = 'Upload complete!';
    });

    r.on('fileError', (file, message) => {
      console.error(`Error uploading file ${file.fileName}: ${message}`);
      progressEl.textContent = 'Upload failed.';
    });
  </script>
</body>
</html>

Нативная альтернатива: File.slice + fetch

Для проектов, предпочитающих нулевые зависимости, можно реализовать возобновляемую загрузку нативными средствами, используя метод File.slice и fetch. Это даёт полный контроль над заголовками, повторными попытками и — что особенно важно — логикой возобновления. Функция ниже формирует строку запроса для каждого чанка, проверяет через запрос HEAD, есть ли чанк уже на сервере, и загружает только недостающие. Вызов функции повторно после прерывания пропускает всё, что уже было загружено:

async function uploadFileNative(file) {
  const chunkSize = 1 * 1024 * 1024; // 1MB
  const totalChunks = Math.ceil(file.size / chunkSize);
  // A stable identifier so a re-run resumes the same upload session.
  const identifier = `${file.name}-${file.size}`;

  for (let i = 0; i < totalChunks; i++) {
    const params = new URLSearchParams({
      resumableChunkNumber: i + 1,
      resumableTotalChunks: totalChunks,
      resumableIdentifier: identifier,
      resumableFilename: file.name,
    });
    const url = `/upload?${params}`;

    // Resume support: skip chunks the server already has.
    const probe = await fetch(url, { method: 'HEAD' });
    if (probe.status === 200) continue;

    const start = i * chunkSize;
    const end = Math.min(start + chunkSize, file.size);
    const chunk = file.slice(start, end); // a Blob, sent as the request body

    await fetch(url, { method: 'POST', body: chunk });
  }
  console.log('Native upload complete');
}

Чтобы сделать это решение пригодным для продакшена, следует обернуть каждый POST в цикл повторных попыток с экспоненциальной задержкой и поддержать отмену с помощью AbortController.

Управление метаданными

Крайне важно управлять метаданными о загружаемом файле и его чанках — индексом чанка, общим количеством, именем файла и стабильным идентификатором. Именно эта информация позволяет серверу возобновить загрузку с нужного чанка после прерывания. Логика сервера для отслеживания и сборки чанков рассмотрена в предыдущем разделе.

В продакшене не стоит полагаться только на файловую систему для отслеживания прогресса: у неё нет гарантий сохранности данных, и она небезопасна при одновременном поступлении нескольких чанков (проверка длины через readdir может дать гонку). Используйте базу данных или кэш (например, Redis), чтобы фиксировать завершённые чанки, и собирайте файл только после подтверждения каждого индекса. Если нужно отправить дополнительные структурированные метаданные вместе с чанком, API FormData позволяет упаковать поля и бинарный blob в один запрос.

Пример: загрузка больших файлов

Конфигурация клиента остаётся идентичной предыдущему примеру. Для оптимизации загрузки больших файлов можно увеличить chunkSize (например, до 5 МБ) и настроить simultaneousUploads в зависимости от мощности сервера и условий сети.

Профессиональные советы по возобновляемой загрузке файлов

  • Оптимизация размера чанка: подбирайте размер чанка исходя из средней скорости сети и размера файла, чтобы сбалансировать скорость загрузки и надёжность.
  • Обработка ошибок: реализуйте надёжные механизмы обработки ошибок для устойчивости к перебоям в сети и проблемам на сервере.
  • Обратная связь с пользователем: предоставляйте пользователям информацию о прогрессе загрузки и возникающих проблемах в реальном времени.
  • Безопасность: обеспечьте безопасность процесса загрузки файлов, проверяя типы файлов и реализуя надлежащую аутентификацию и авторизацию.
  • Современные альтернативы: для продакшен-окружений рассмотрите стандартизированные протоколы, такие как tus, или нативный File.slice с fetch для лучшего контроля, возобновляемости и кросс-платформенной совместимости.

Следуя этим рекомендациям и примерам, вы сможете реализовать надёжную и эффективную систему возобновляемой загрузки файлов на JavaScript — такую, которая переживёт нестабильную сеть и даст пользователям уверенность в том, что большая загрузка не будет потрачена впустую.

Связанные темы

  • Fetch API — современный способ отправки каждого чанка на сервер.
  • Fetch: прогресс загрузки — чтение потокового тела ответа для отображения прогресса.
  • Fetch: отмена — отмена активной загрузки с помощью AbortController.
  • Blob — тип, возвращаемый File.slice, представляющий каждый чанк.
  • File и FileReader — чтение файла, выбранного пользователем.
  • FormData — упаковка бинарных данных с дополнительными полями в одном запросе.

Практика

Практика
Какие из следующих утверждений являются преимуществами использования возобновляемой загрузки файлов?
Какие из следующих утверждений являются преимуществами использования возобновляемой загрузки файлов?
Was this page helpful?