Возобновляемая загрузка файлов
Как реализовать возобновляемую загрузку файлов на JavaScript: разбивка на чанки, продолжение после сбоя, сервер на Node.js, resumable.js и File.slice + fetch.
Загрузка 2 ГБ видео через нестабильное мобильное соединение с помощью одного запроса fetch ненадёжна: одно разорванное соединение на 95% — и пользователь начинает всё заново. Возобновляемая загрузка файлов решает эту проблему, разбивая файл на небольшие части, загружая их поочерёдно и запоминая, какие части уже получены — так прерванная загрузка продолжается с того места, где остановилась, а не начинается сначала.
На этой странице рассматривается полная картина: как работает пофрагментная возобновляемая загрузка концептуально, рабочий сервер на Node.js + Express, сохраняющий и собирающий чанки, клиент на базе библиотеки resumable.js, а также версия без зависимостей с использованием File.slice и fetch. Вы также узнаете о распространённой ошибке при сборке чанков и советах по упрочнению системы для продакшена.
Как работает возобновляемая загрузка
Основная идея проста и держится на трёх взаимодействующих элементах:
- Разбить файл на чанки. Браузер делит выбранный файл на части фиксированного размера (например, по 1 МБ) с помощью метода
Blob.slice, унаследованногоFile. Сам файл никогда не загружается в память целиком. - Загружать чанки по одному (или по несколько). Каждый чанк — это отдельный HTTP-запрос, содержащий его индекс (
чанк 3 из 17), общее количество чанков, имя файла и стабильный идентификатор, однозначно помечающий данную сессию загрузки. - Собирать файл на сервере. Сервер сохраняет каждый чанк на диск под его индексом. Когда все чанки получены, он объединяет их в правильном порядке в итоговый файл.
Возобновляемость обеспечивается шагом 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 — упаковка бинарных данных с дополнительными полями в одном запросе.