Реализация чанковой загрузки файлов на JavaScript
Реализуйте возобновляемые чанковые загрузки файлов на JavaScript. Работа с большими файлами, отслеживание прогресса и восстановление после сбоев.
Чанковая загрузка файлов в JavaScript разделяет большой файл на части фиксированного размера (обычно 5–10 МБ), загружает каждую отдельным HTTP-запросом и собирает их на сервере. Паттерн решает три реальные проблемы: браузеры и прокси обрывают запросы свыше 2 ГБ, мобильные сети теряют соединение в середине загрузки, а пользователи хотят видеть прогресс. Рабочая реализация использует File.slice() для нарезки чанков, fetch с AbortSignal на каждый чанк, серверную сборку через S3 multipart или пользовательский сборщик, и локальный индекс в IndexedDB, чтобы возобновление переживало перезагрузку вкладки.
Почему чанки лучше единого запроса
Файл в 4 ГБ, загружаемый одним запросом, падает по предсказуемым причинам: стандартный client_max_body_size Nginx составляет 1 МБ, Cloudflare ограничивает бесплатные загрузки 100 МБ на запрос, AWS API Gateway жёстко останавливается на 10 МБ, а мобильный Safari убивает вкладки, держащие ArrayBuffer на 4 ГБ в памяти. Чанковые загрузки обходят каждый из этих барьеров. К тому же вы получаете индикаторы прогресса, реально двигающиеся, повторы, не начинающиеся с нуля, и возможность паузы и возобновления. Компромисс — больше серверного состояния и больше запросов туда-обратно: примерно один HTTP-запрос на каждые 5 МБ, что для файла в 10 ГБ означает 2 000 запросов.
Выбор размера чанка
Размер чанка — компромисс между пропускной способностью и устойчивостью. Слишком маленький (менее 1 МБ) — и вы тратите больше времени на TLS-рукопожатия, чем на данные. Слишком большой (свыше 100 МБ) — разрыв соединения тратит минуты загрузки впустую. Оптимальная зона для большинства сетей — 5–10 МБ, что соответствует минимуму S3 multipart в 5 МБ и хорошо сочетается с типичными размерами TCP-окна после slow-start.
Сначала измерьте сеть пользователя:
const downlink = navigator.connection?.downlink ?? 10;
const chunkSize = downlink > 20 ? 10 * 1024 * 1024 : 5 * 1024 * 1024;
На 100 Мбит соединении чанки по 10 МБ завершаются примерно за секунду каждый. На 4G чанки по 5 МБ дают лучшее восстановление при попадании в тоннель метро.
Нарезка и хеширование файла
File.slice() возвращает Blob, ссылающийся на те же байты на диске без копирования, поэтому нарезка 20-гигабайтного файла ничего не стоит:
function* sliceFile(file, chunkSize) {
for (let offset = 0; offset < file.size; offset += chunkSize) {
yield {
index: Math.floor(offset / chunkSize),
blob: file.slice(offset, offset + chunkSize),
start: offset,
end: Math.min(offset + chunkSize, file.size)
};
}
}
Вычисляйте SHA-256-хеш каждого чанка перед загрузкой, чтобы сервер мог проверить целостность:
const buffer = await chunk.blob.arrayBuffer();
const digest = await crypto.subtle.digest('SHA-256', buffer);
const hash = Array.from(new Uint8Array(digest))
.map(b => b.toString(16).padStart(2, '0')).join('');
Для 10 ГБ данных хеширование добавляет примерно 20 секунд на современном ноутбуке — стоит того, чтобы обнаружить тихие повреждения на нестабильных мобильных каналах.
Загрузка с контролируемым параллелизмом
Последовательные загрузки тратят пропускную способность впустую; неограниченный параллелизм подвешивает браузер. Ограничение параллелизма в 3–4 одновременных чанка балансирует оба:
async function uploadAll(file, sessionId) {
const queue = [...sliceFile(file, 5 * 1024 * 1024)];
const workers = Array.from({ length: 4 }, async () => {
while (queue.length) {
const chunk = queue.shift();
await uploadChunk(chunk, sessionId);
emitProgress(chunk.index);
}
});
await Promise.all(workers);
}
Каждый вызов uploadChunk — это PUT /upload/:sessionId/:index с blob в теле и хешем в заголовке. Используйте AbortController на каждый чанк, чтобы можно было отменить отдельные запросы без завершения всего пакета.
Повторы без перегрузки сервера
Сетевые ошибки требуют экспоненциального отката, а не жёстких циклов повторов. Разумная политика: 3 попытки, базовая задержка 500 мс, джиттер до 50%:
async function uploadChunk(chunk, sessionId, attempt = 0) {
try {
const res = await fetch(`/upload/${sessionId}/${chunk.index}`, {
method: 'PUT', body: chunk.blob, headers: { 'X-Hash': chunk.hash }
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (e) {
if (attempt >= 3) throw e;
const delay = 500 * 2 ** attempt + Math.random() * 250;
await new Promise(r => setTimeout(r, delay));
return uploadChunk(chunk, sessionId, attempt + 1);
}
}
Считайте 5xx-ответы повторяемыми, 4xx — фатальными (кроме 408 и 429). При 429 следуйте заголовку Retry-After, а не местному откату.
Возобновление после перезагрузки вкладки
Сохраняйте состояние загрузки в IndexedDB после каждого успешного чанка:
await db.put('uploads', {
sessionId, fileName: file.name, fileSize: file.size,
completedChunks: [...completedSet], updatedAt: Date.now()
}, sessionId);
Когда пользователь снова открывает страницу с тем же выбором файла, сравните size, lastModified и имя файла с сохранёнными сессиями. При совпадении спросите сервер, какие чанки уже получены (простой GET /upload/:sessionId/status, возвращающий битовую карту), затем загружайте только недостающие. Протокол tus формализует именно этот паттерн через заголовок Upload-Offset, а библиотека tus-js-client поставляет готовую реализацию, если не хотите писать свою.
Сборка чанков на сервере
Два серьёзных варианта: S3 multipart upload, где каждый чанк становится PartNumber, а финальный CompleteMultipartUpload склеивает их; или пользовательский сборщик, записывающий каждый чанк во временный файл и конкатенирующий в конце. S3 multipart дешевле при масштабе, потому что вы никогда не платите за исходящий трафик при сборке, а R2 даёт нулевой исходящий трафик. Пользовательский подход проще отлаживать и позволяет стримить шифрование во время сборки.
Для S3-стиля:
const upload = await s3.createMultipartUpload({ Bucket, Key });
// для каждого чанка: s3.uploadPart({ UploadId, PartNumber, Body })
await s3.completeMultipartUpload({ UploadId, MultipartUpload: { Parts } });
Следите за ограничением в 10 000 частей — для файлов свыше 50 ГБ нужны чанки по 5 МБ+ для соблюдения этого лимита.
Отслеживание прогресса, которому пользователи доверяют
Прыгающие индикаторы прогресса выглядят сломанными. Вычисляйте прогресс как загруженные байты к общим байтам, а не как завершённые чанки, и сглаживайте скользящим средним за 2 секунды для скрытия джиттера. Используйте fetch с ReadableStream и Transform для подсчёта байт, поскольку XMLHttpRequest.upload.onprogress не всегда надёжно срабатывает в HTTP/3. Показывайте расчётное время, деля оставшиеся байты на скользящую пропускную способность, но ограничивайте минимум 5 секундами, чтобы избежать «2 секунды осталось... 10 минут».
Как избежать типичных ловушек
Три ошибки убивают чанковые загрузки в продакшене: забыть установить Content-Length для каждого чанка (ломает некоторые edge-прокси), повторно использовать тот же sessionId для разных файлов (портит сборку), позволять пользователю изменять файл в середине загрузки без версионирования сессии. Всегда хешируйте первые 1 МБ файла плюс его размер и lastModified для отпечатка сессии. И никогда не доверяйте lastModified одному — macOS Finder обновляет его при изменении метаданных.
HexaTransfer использует именно такой конвейер — чанковый плюс возобновляемый — для загрузок до 10 ГБ, добавляя клиентское AES-256-GCM к каждому чанку перед PUT. Попробуйте на hexatransfer.com — бесплатно, без регистрации, до 10 ГБ.
Собираем всё вместе
Промышленный чанковый загрузчик — это примерно 300 строк JavaScript: нарезка через File.slice, хеширование через SubtleCrypto, параллельная загрузка 3–4 чанков с экспоненциальным откатом, сохранение состояния сессии в IndexedDB, серверная склейка частей через S3 multipart или пользовательский сборщик. Тестируйте против переключения режима полёта, перезагрузок вкладки и файла 15 ГБ через 4G, прежде чем доверять ему. После этого добавление шифрования, прогресса и возобновляемости — просто дополнительные уровни поверх той же основы.
Безопасная отправка больших файлов со сквозным шифрованием
Передавайте файлы до 10 ГБ бесплатно со сквозным шифрованием. Регистрация не требуется. Ваши файлы шифруются в браузере перед загрузкой — никто другой не может их прочитать.
Отправить файл