Перейти к содержимому

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

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

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

Возобновляемая загрузка файлов позволяет пользователям загружать файлы частями, гарантируя, что в случае прерывания загрузки из-за сетевых проблем или других причин, её можно будет продолжить с последней успешно загруженной части. Этот метод особенно полезен для больших файлов и может значительно повысить надёжность загрузки.

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

  • Улучшенный пользовательский опыт: Пользователи могут возобновить загрузку, не начиная её заново.
  • Эффективность: Снижает объём передаваемых данных за счёт загрузки только отсутствующих частей.
  • Обработка ошибок: Корректно обрабатывает прерывания сетевого соединения.

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

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

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

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

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

bash
npm install express cors

Настройка серверной части

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

javascript
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) {
      const finalPath = path.join('uploads', fileName);
      const writeStream = fs.createWriteStream(finalPath);
      const finishPromise = new Promise((resolve, reject) => {
        writeStream.on('finish', resolve);
        writeStream.on('error', reject);
      });

      for (let i = 1; i <= totalChunks; i++) {
        const chunkPath = path.join(chunkDir, `chunk-${i}.bin`);
        fs.createReadStream(chunkPath).pipe(writeStream);
      }
      await finishPromise;
      await fs.promises.rmdir(chunkDir);
      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}`);
});

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

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

html
<!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

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

javascript
async function uploadFileNative(file) {
  const chunkSize = 1 * 1024 * 1024; // 1MB
  const totalChunks = Math.ceil(file.size / chunkSize);
  const identifier = `${file.name}-${Date.now()}`;

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

    const url = new URL('/upload', window.location.origin);
    url.searchParams.set('resumableChunkNumber', i + 1);
    url.searchParams.set('resumableTotalChunks', totalChunks);
    url.searchParams.set('resumableIdentifier', identifier);
    url.searchParams.set('resumableFilename', file.name);

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

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

Крайне важно управлять метаданными загружаемого файла и его фрагментов. Эта информация помогает возобновить загрузку с правильного фрагмента в случае прерываний. Логика сервера для отслеживания и сборки фрагментов рассмотрена в предыдущем разделе. Для продакшн-окружений следует избегать хранения только в памяти или на файловой системе, так как они не обеспечивают сохранность данных и потокобезопасность. Вместо этого используйте базу данных или кэш (например, Redis) для отслеживания завершения фрагментов и надёжной сборки файлов.

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

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

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

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

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

Practice

Какие из перечисленных являются преимуществами использования возобновляемой загрузки файлов?

Считаете ли это полезным?

Предпросмотр dual-run — сравните с маршрутами Symfony на продакшене.