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

Прогрессивная загрузка файлов: UX и техническое руководство

Создавайте прогрессивные опыты загрузки файлов с drag-and-drop, индикаторами прогресса и изящной обработкой ошибок для лучшего UX.

Прогрессивная загрузка файлов даёт пользователям немедленную, достоверную обратную связь на каждом этапе: в момент перетаскивания файла — подтверждение выбора; во время загрузки — плавный индикатор прогресса с реалистичным расчётом времени; при сбое — конкретные варианты повтора; после завершения — чёткое состояние успеха с дальнейшими действиями. Технические составляющие: drag-and-drop через HTML5 DataTransfer API, чанкованные загрузки с возобновлением, fetch со стримингом через ReadableStream для байтово-точного прогресса и управление состоянием, переживающее перезагрузку вкладки через IndexedDB. При правильной реализации пользователь, загружающий файл на 5 ГБ, никогда не задаётся вопросом, не завис ли интерфейс.

Что «прогрессивный» означает здесь

«Прогрессивный» имеет два значения. Первое: прогрессивное улучшение — загрузка работает как обычный POST через <input type="file"> даже в браузере 2012 года, и обретает drag-and-drop, чанкование и повторы при наличии JavaScript. Второе: прогрессивное раскрытие — UI показывает сложность только по мере необходимости: процент во время загрузки, детали повтора — только при сбое. Оба значения ведут к одному принципу: пользователь никогда не должен заходить в тупик и ждать без информации.

Паттерн сбоя, которого нужно избегать, — «спиннер смерти»: обобщённая анимация загрузки, не дающая никаких сведений о прогрессе, расчётном времени или ошибках. Пользователи отменяют загрузки, которым не доверяют.

Drag-and-drop, не борющийся с браузером

HTML5 drag-and-drop API печально известен ошибками. Несколько правил, делающих его терпимым:

const dropzone = document.querySelector('.dropzone');
dropzone.addEventListener('dragover', (e) => {
  e.preventDefault();
  dropzone.classList.add('dragging');
});
dropzone.addEventListener('dragleave', () => {
  dropzone.classList.remove('dragging');
});
dropzone.addEventListener('drop', (e) => {
  e.preventDefault();
  dropzone.classList.remove('dragging');
  handleFiles([...e.dataTransfer.files]);
});

Вызывайте preventDefault при dragover, иначе цель сброса не примет файл. Используйте e.dataTransfer.items вместо files, если нужно принимать папки через webkitGetAsEntry() — это единственный способ рекурсивно захватить содержимое директории в Chrome и Firefox.

Сделайте запасной вариант тоже удобным: видимый <label>, обёртывающий стилизованный <input type="file" multiple>, работает для 100% пользователей, включая навигацию с клавиатуры и экранных читалок.

Прогресс, которому пользователи верят

Индикаторы прогресса дёргаются по трём причинам: неравные размеры чанков, TCP slow-start и буферизация в сетевом стеке. Сгладьте с помощью скользящего среднего за 2 секунды:

const samples = []; // [{ time, bytes }]
function recordSample(bytes) {
  const now = performance.now();
  samples.push({ time: now, bytes });
  while (samples.length > 1 && now - samples[0].time > 2000) samples.shift();
}
function throughput() {
  if (samples.length < 2) return 0;
  const delta = samples[samples.length - 1];
  const base = samples[0];
  return (delta.bytes - base.bytes) / ((delta.time - base.time) / 1000);
}

Вычисляйте расчётное время как (totalBytes - uploadedBytes) / throughput(), ограничьте отображение минимум 5 секундами и форматируйте по-человечески: «около 2 минут», а не «124,3 секунды». Показывайте и процент, и счётчик байт («340 МБ из 2,1 ГБ») — пользователи сверяют оба, когда что-то кажется не так.

Состояния ошибок с практичными путями восстановления

Обобщённые сообщения «ошибка загрузки» разрушают доверие. Классифицируйте сбои на пять групп и отображайте каждую по-своему:

  • Разрыв сети (событие offline, TCP reset): «Восстановление соединения...» с автоматическим повтором
  • 5xx сервера: «Ошибка сервера, повтор через 5 с» с кнопкой ручного повтора
  • 4xx сервера (413 слишком большой, 415 неподходящий тип): «Файл отклонён: слишком большой» с заменой файла
  • Истёкшая аутентификация (401, 403): «Сессия истекла, войдите, чтобы продолжить»
  • Сбой клиента (ошибка JS, браузер убил вкладку): Восстановление из IndexedDB при перезагрузке

Сопровождайте сообщение одним действием, которое решает проблему. Если пользователь офлайн — показывайте состояние онлайн/офлайн, отслеживаемое через navigator.onLine и событие online.

Отслеживание байт через Fetch Streams

XMLHttpRequest.upload.onprogress долгое время был стандартным способом отслеживания прогресса загрузки, но он ненадёжен в HTTP/3 и не учитывает байты в буфере отправки. Современный подход использует ReadableStream для подсчёта байт в процессе производства:

function trackedStream(blob, onBytes) {
  let sent = 0;
  return new ReadableStream({
    async pull(controller) {
      const reader = blob.stream().getReader();
      while (true) {
        const { done, value } = await reader.read();
        if (done) { controller.close(); return; }
        sent += value.byteLength;
        onBytes(sent);
        controller.enqueue(value);
      }
    }
  });
}

Передавайте поток как body в fetch с duplex: 'half'. Поддержка потоков запросов в Safari появилась в 17.4; до этого — откат к XMLHttpRequest. Это даёт миллисекундно-точный прогресс, привязанный к реальным байтам, переданным в сетевой стек.

Пауза, возобновление и отмена

Пользователи ожидают кнопку паузы для всего, что занимает больше минуты. При чанкованных загрузках пауза — это просто «прекратить отправлять новые чанки», а возобновление продолжает с того места, где остановилась очередь. Отмена использует AbortController:

const ctrl = new AbortController();
cancelButton.onclick = () => ctrl.abort();
await fetch(url, { method: 'PUT', body: blob, signal: ctrl.signal });

При прерывании выполните очистку: DELETE сессии загрузки на сервере, чтобы хранилище не утекало; очистите запись IndexedDB; вернитесь к начальному состоянию. Пауза должна сохранять состояние; отмена — уничтожать его. Сделайте различие видимым в интерфейсе.

Переживание перезагрузок вкладки

Сохраняйте состояние загрузки после каждого успешного чанка:

await idb.put('uploads', {
  sessionId, fileFingerprint, fileName, fileSize,
  completedChunks: [...done], updatedAt: Date.now()
}, sessionId);

Отпечаток — SHA-256 первого мегабайта файла плюс размер и lastModified — достаточно для повторной идентификации файла, когда пользователь выбирает его заново после перезагрузки. При загрузке страницы проверяйте IndexedDB на наличие сессий возрастом менее часа и предлагайте возобновление: «У вас есть загрузка, начатая 12 минут назад. Продолжить?» Не возобновляйте автоматически без согласия — пользователи иногда перезагружают именно для отмены.

Доступные и дружелюбные к клавиатуре взаимодействия

Зона сброса, реагирующая только на перетаскивание мышью, не работает для пользователей экранных читалок и клавиатуры. Добавьте:

  • role="button" и tabindex="0" на зону сброса
  • Обработчик клавиш Enter/Space, кликающий по файловому вводу
  • aria-live="polite" на область прогресса, чтобы экранные читалки объявляли этапы
  • Видимые стили фокуса, не только hover
  • Чёткие метки — «Загрузить файл» лучше «Обзор», а оно лучше голой иконки

Тестирование с клавиатуры быстрое: отключите мышь на 10 минут и попробуйте завершить загрузку. Если не получается — ни у части ваших пользователей тоже не получится.

Загрузчик HexaTransfer использует именно этот прогрессивный паттерн: запасная форма, улучшение drag-and-drop, потоковое чтение через fetch, возобновление с IndexedDB и специфичное восстановление после ошибок. Попробуйте на hexatransfer.com — бесплатно, без регистрации, до 10 ГБ.

Детали, которые пользователи действительно замечают

Полировка, отличающая незабываемые загрузчики от великих, живёт в мелких моментах: анимация сброса, подтверждающая захват файла; индикатор прогресса, заполняющийся плавно вместо прыжков; расчётное время, становящееся точнее со временем вместо хаотичных скачков; конкретные сообщения об ошибках, говорящие что делать дальше; подсказка возобновления после случайного обновления страницы; состояние завершения, сохраняющееся достаточно долго для копирования ссылки; и поведение отмены, немедленно останавливающее загрузку. Каждый из этих пунктов — несколько строк кода. Реализуйте их все — и ваш загрузчик будет на порядок лучше стандартного <input type="file">.

Безопасная отправка больших файлов со сквозным шифрованием

Передавайте файлы до 10 ГБ бесплатно со сквозным шифрованием. Регистрация не требуется. Ваши файлы шифруются в браузере перед загрузкой — никто другой не может их прочитать.

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