Перейти к содержанию
HexaTransfer
Вернуться к блогу
Технические погружения

Реализация чанковой загрузки файлов на 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 ГБ бесплатно со сквозным шифрованием. Регистрация не требуется. Ваши файлы шифруются в браузере перед загрузкой — никто другой не может их прочитать.

Отправить файл