Как создавать миниатюры изображений перед загрузкой
Создавайте миниатюры перед загрузкой: превью FileReader, уменьшение в Canvas toBlob, отправка FormData и совпадение полей multer.
Превью показывает выбранный пользователем файл в уменьшенном виде, но на сервер уходят ровно те же байты; миниатюра же — это по-настоящему перекодированное изображение меньшего размера, которое можно загрузить вместе с оригиналом — или вместо него.
Если вам когда-нибудь доводилось выкатывать функциональность, которую вы считали «миниатюрами», а затем наблюдать, как 12-мегабайтная фотография с телефона всё равно неспешно ползёт на сервер, вы уже знаете, что разница здесь принципиальна. Большинство руководств смазывают это различие и называют уменьшенный <img> «миниатюрой», что неверно: уменьшение изображения на экране средствами CSS никак не меняет то, что уходит по сети. В этой статье мы разделим два подхода, а затем пройдём путь от простейшего превью на FileReader до настоящей миниатюры, уменьшенной через Canvas и готовой к загрузке, а заодно разберём подводные камни очистки памяти и валидации, которые ломают интерфейсы загрузки в продакшене.
Ключевые выводы
- Превью на
FileReader.readAsDataURL— только для отображения: на сервер уходят ровно исходные байты, и ничего по сети не уменьшается. - Чтобы получить файл меньшего размера, нужно перерисовать изображение на
<canvas>и перекодировать его черезcanvas.toBlob(callback, 'image/jpeg', 0.7). canvas.toBlob()асинхронен и передаёт Blob в колбэк, а не возвращает его, поэтому оберните вызов в Promise, чтобы использоватьawait.- Загружайте оба файла в одном
FormData(оригинальныйFileиBlobминиатюры с явно заданным именем файла), а на сервере принимайте их черезupload.fields()в multer. - Каждому
URL.createObjectURL()должен соответствоватьURL.revokeObjectURL(), иначе исходный файл останется в памяти до выгрузки документа.
Превью или настоящая миниатюра: что вам нужно?
Определитесь с этим до написания кода, поскольку у этих двух путей почти нет общей логики. Превью визуально подтверждает, что выбран нужный файл. Миниатюра — это новый графический ресурс меньшего размера (меньше байт, меньше размеры), который сокращает время загрузки и объём серверной обработки, а также может храниться как изображение для сетки без повторной обработки оригинала.
| Цель | Приём | Создаёт ли новый файл меньшего размера? | Когда использовать |
|---|---|---|---|
| Мгновенно показать выбранное изображение | FileReader или URL.createObjectURL(file) | Нет | Нужно только визуальное подтверждение |
| Уменьшить объём загружаемых данных / сохранить маленький вариант | Canvas + toBlob() | Да | Вы загружаете файлы на сервер или CDN |
| Drag-and-drop, прогресс, несколько размеров, валидация | Библиотека (FilePond) | Да | Нужно решение «из коробки» |
Превью через FileReader readAsDataURL ничего не уменьшает при передаче по сети. Чтобы действительно сократить объём загружаемых данных, нужно перерисовать изображение на <canvas> и перекодировать его через canvas.toBlob().
Как показать превью изображения с помощью FileReader?
Discover how at OpenReplay.com.
Для превью «только для отображения» подпишитесь на событие change файлового input, прочитайте файл через FileReader.readAsDataURL и присвойте полученный data URL элементу <img> внутри reader.onload. Это самый быстрый способ выкатить функциональность, и он не изменяет размер файла.
<input type="file" id="fileInput" accept="image/*" multiple>
<div id="previews"></div>
const input = document.getElementById('fileInput');
const previews = document.getElementById('previews');
input.addEventListener('change', (e) => {
previews.innerHTML = '';
Array.from(e.target.files).forEach((file) => {
if (!file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = (ev) => {
const img = new Image();
img.src = ev.target.result; // base64 data URL
img.alt = `Preview of ${file.name}`;
previews.appendChild(img);
};
reader.readAsDataURL(file);
});
});
Обратите внимание на паттерн Array.from(files).forEach(...). В распространённом jQuery-руководстве по работе с несколькими файлами одна переменная reader переиспользуется внутри цикла for, из-за чего каждый onload замыкается на последнем файле: классическая ошибка с замыканием, при которой одно и то же изображение показывается многократно. Каждому файлу нужен собственный FileReader внутри итерации, и forEach даёт вам это бесплатно.
Более лёгкая альтернатива для крупных файлов — URL.createObjectURL(file), которая возвращает короткий blob-URL вместо base64-строки и избавляет от разрастания памяти при кодировании всего файла в data URL — ценой обязательного последующего вызова revokeObjectURL().
Как сгенерировать уменьшенную миниатюру с помощью Canvas?
Чтобы получить действительно файл меньшего размера, загрузите изображение, масштабируйте его размеры с сохранением соотношения сторон, отрисуйте на canvas и перекодируйте. Для сохранения пропорций масштабируйте оба измерения на один и тот же коэффициент (maxSize / длинная сторона), а не задавайте ширину и высоту независимо.
canvas.toBlob(callback, 'image/jpeg', 0.7) асинхронен: он передаёт Blob в свой колбэк, а не возвращает его, поэтому оберните вызов в Promise, если хотите использовать await для получения миниатюры. У обычного canvas нет варианта, возвращающего промис; нативно Promise возвращает только OffscreenCanvas.convertToBlob().
function canvasToBlob(canvas, type, quality) {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error('toBlob failed'))),
type,
quality
);
});
}
async function makeThumbnail(file, maxSize = 200) {
const url = URL.createObjectURL(file);
try {
const img = await new Promise((res, rej) => {
const i = new Image();
i.onload = () => res(i);
i.onerror = rej;
i.src = url;
});
const scale = Math.min(1, maxSize / Math.max(img.width, img.height));
const w = Math.round(img.width * scale);
const h = Math.round(img.height * scale);
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
return await canvasToBlob(canvas, 'image/jpeg', 0.7);
} finally {
URL.revokeObjectURL(url);
}
}
Третий аргумент toBlob задаёт качество кодирования по шкале от 0 до 1. Его учитывают только форматы со сжатием с потерями, поэтому он влияет на вывод для image/jpeg и image/webp и ничего не делает для image/png. Значение 0,6–0,8 — практический оптимум. Перекодирование многомегапиксельной фотографии в 200-пиксельный JPEG обычно уменьшает её размер на один-два порядка, а ведь именно в этом и состоит весь смысл выполнения операции на клиенте.
Показ миниатюры и загрузка обоих файлов
Отобразите полученный Blob через URL.createObjectURL(blob), а затем отправьте его на сервер вместе с оригиналом. Для загрузки добавьте оба файла в один FormData: оригинальный File и Blob миниатюры, передав имя файла третьим аргументом. Отправьте это через fetch, а на сервере upload.fields() из multer примет два поля по отдельности.
async function upload(file) {
const thumb = await makeThumbnail(file);
const preview = new Image();
preview.src = URL.createObjectURL(thumb); // remember to revoke later
document.body.appendChild(preview);
const form = new FormData();
form.append('originalFiles', file, file.name);
form.append('thumbnails', thumb, `thumb-${file.name}.jpg`);
await fetch('/api/upload', { method: 'POST', body: form });
}
На бэкенде multer разбирает multipart-тело запроса. Имена полей в upload.fields() должны в точности совпадать с ключами FormData.append, иначе поле молча отбрасывается:
const upload = multer({ dest: 'uploads/' });
app.post('/api/upload', upload.fields([
{ name: 'originalFiles', maxCount: 10 },
{ name: 'thumbnails', maxCount: 10 },
]), (req, res) => res.json({ ok: true }));
Ветка multer 2.x содержит исправления безопасности, которых нет в ветке 1.x, а её package.json устанавливает нижнюю планку на уровне Node.js 10.16.0, а не Node 18. Минимум в виде Node 18 появляется в ветке 3.x, которая пока находится в альфа-версии. Самый свежий стабильный релиз в changelog multer — 2.2.0, так что сверьтесь с ним, прежде чем фиксировать версию.
Вариант с библиотекой: FilePond и аналоги
Когда вам нужны ещё и drag-and-drop, индикация прогресса загрузки, валидация и несколько уменьшенных вариантов, возьмите FilePond с его плагинами для изображений вместо того, чтобы писать всё вручную. Плагин filepond-plugin-image-preview отрисовывает превью, filepond-plugin-image-resize записывает метаданные изменения размера, а filepond-plugin-image-transform выполняет само уменьшение и отдаёт вам итоговые Blob-объекты.
FilePond.registerPlugin(
FilePondPluginImagePreview,
FilePondPluginImageResize,
FilePondPluginImageTransform
);
FilePond.create(document.querySelector('input[type="file"]'), {
imageResizeTargetWidth: 256,
imageResizeMode: 'contain',
});
Зафиксируйте FilePond на ветке версии 4 (@^4), как рекомендует документация по установке FilePond. Стабильной является ветка 4.x — в changelog это 4.32.12, тогда как v5 всё ещё в бета-версии. Незафиксированная загрузка unpkg.com/filepond отслеживает тег latest, поэтому сегодня она отдаёт текущий стабильный релиз, но переведёт вас на следующий мажорный выпуск в тот день, когда та ветка получит статус стабильной.
Если нужно именно более качественное уменьшение, Pica (10.0.2) применяет полноценный фильтр ресемплинга и может работать в Web Worker; алгоритм выбирается через опцию filter, например { filter: 'lanczos3' }. Пакет browser-image-compression (2.0.2) — ещё один вариант, но его последний релиз вышел в марте 2023 года, и Snyk оценивает его сопровождение как неактивное, так что взвесьте это перед внедрением.
Подводные камни и лучшие практики
Вот те сценарии отказа, которые чаще всего всплывают в сессионных записях интерфейсов загрузки (незаметный рост потребления памяти, повёрнутые миниатюры и вкладки, зависающие на огромных файлах):
- Освобождайте object URL. Всегда вызывайте
URL.revokeObjectURL(), когда удаляете превью: каждыйcreateObjectURL()удерживает исходный файл в памяти до тех пор, пока URL не будет явно освобождён или документ не выгрузится. Неконтролируемое количество object URL — классический источник постепенного роста потребления памяти вкладкой. - FileList доступен только для чтения. Поскольку
FileListтолько для чтения, вы не можете вырезать файл из<input>, поэтому ведите собственный редактируемый массив и формируйте загрузку на его основе. - Валидируйте до обработки. Сочетайте
accept="image/*"с проверкой в рантаймеfile.type.startsWith('image/')и ограничением по размеру.accept— это UX-подсказка, а не средство контроля. - Ограничивайте размеры, чтобы защитить вкладку. Очень большие изображения могут исчерпать память и уронить вкладку при декодировании; отклоняйте файлы, превышающие порог по объёму, и ограничивайте
maxSizeперед отрисовкой. - EXIF-поворот. Изменение размера через canvas может отбросить флаг ориентации EXIF, из-за чего портретная фотография с телефона может получиться повёрнутой набок. Всегда тестируйте на реальных вертикальных фотографиях с телефона.
- Качество уменьшения. При сильном уменьшении один вызов
drawImage()может дать грубый результат; уменьшайте пошагово или используйте библиотеку вроде Pica, которая применяет полноценный фильтр ресемплинга для более чётких миниатюр. - «Порча» canvas (tainting) касается только кросс-доменных изображений, загруженных с других доменов; выбранные пользователем файлы никогда не «портят» canvas, поэтому обработка
crossOriginздесь не нужна. - Доступность. Задавайте каждому превью
<img>осмысленныйalt-текст и помечайте кнопки удаления атрибутами ARIA, чтобы интерфейсом можно было пользоваться, не видя миниатюры.
Подводя итоги
Выбирайте путь, соответствующий вашей цели: превью через FileReader или object URL, когда нужно только визуальное подтверждение, и миниатюру через Canvas toBlob, когда действительно нужно меньше байт в сети. Начните с приведённого выше хелпера makeThumbnail, обёрнутого в Promise, загружайте оригинал и миниатюру вместе в одном FormData и внедряйте очистку через revokeObjectURL с самого первого коммита, а не прикручивайте её после того, как проявится утечка памяти.
Часто задаваемые вопросы
Уменьшает ли создание превью размер файла, который загружает пользователь?
Нет. Превью через FileReader readAsDataURL или через URL.createObjectURL предназначено только для отображения и загружает ровно исходные байты без какого-либо уменьшения в сети. Чтобы действительно уменьшить загружаемый файл, нужно перерисовать изображение на canvas в меньших размерах и перекодировать его через canvas.toBlob, а затем загрузить этот Blob вместо оригинала или вместе с ним.
Почему сгенерированная через canvas миниатюра повёрнута не в ту сторону?
Изменение размера изображения через canvas может отбросить флаг ориентации EXIF, который телефоны записывают в портретные фотографии, поэтому корректно ориентированный оригинал получается повёрнутым набок в миниатюре. Браузеры автоматически ориентируют обычный элемент img, но drawImage не всегда переносит эту ориентацию на canvas. Всегда тестируйте механизм изменения размера на реальных вертикальных фотографиях, снятых на телефон, а не только на горизонтальных тестовых изображениях.
Как использовать await с canvas.toBlob, если он принимает только колбэк?
Оберните его в Promise, поскольку canvas.toBlob асинхронен и передаёт Blob в свой колбэк, а не возвращает его. Создайте хелпер, который возвращает new Promise и вызывает canvas.toBlob с resolve, отклоняя промис, когда колбэк получает null. У обычного canvas нет нативной формы, возвращающей промис; напрямую Promise возвращает только OffscreenCanvas.convertToBlob.
Почему поле с миниатюрой не доходит до бэкенда на multer?
Имена полей, передаваемые в upload.fields в multer, должны в точности совпадать с ключами FormData.append на клиенте, иначе поле молча отбрасывается без ошибки. Если на клиенте вы добавляете 'thumbnails', то на сервере в upload.fields должно быть объявлено имя 'thumbnails'. Также проверьте версию multer: ветка 2.x содержит исправления безопасности, которых нет в ветке 1.x.