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