Привет, интернет!
Я Антон Небыков, Frontend TechLead в ИдаПроджект.
Хочу поделиться опытом автоматизации отображения изображений на сайте, их оптимизацией и — как следствие — улучшением показателей в Lighthouse.
На первый взгляд, работа с изображениями сводится к простому добавлению элемента img и ссылки на изображение в атрибуте src. Но на практике все намного сложнее :)
Погнали разбираться!
Оглавление
→ Проблемы, которые нужно было решить
→ Оптимизация размеров и сжатие изображений
→ Двухэтапная ленивая загрузка
→ Предварительная загрузка изображений
Проблемы, которые нужно было решить
Неоптимальные размеры изображений
Неоптимальное сжатие изображений
Отсутствие адаптивных изображений
Отсутствие превью
Неудобное добавление изображений в preload
Низкие баллы в Lighthouse из-за неоптимизированных изображений
Некоторые из этих пунктов можно решить в «ручном режиме». Это было бы справедливо для лендинга в три экрана, но не для средних и крупных порталов, которые могут насчитывать тысячи изображений с различными требованиями для оптимального отображения и загрузки.
Вообще проблем, конечно, было гораздо больше, чем я написал, и часть из них связаны с особенностями работы SSR.
Например, для улучшения показателей в Lighthouse, мы используем ленивую гидратацию, однако ее применение влечет за собой ряд «побочных» эффектов. Вот лишь один из них: любой код, выполняемый на клиенте, будет запускаться только после гидратации — либо по таймауту, либо при взаимодействии пользователя с сайтом. Соответственно, если формирование нужных атрибутов для изображений (например генерацию sizes, srcset, ленивую загрузку и т.д.) реализовать на клиент, то изображение не появится до завершения процесса.
Эта особенность справедлива только для первой загрузки сайта и первого экрана. Мы реализовали двухэтапную ленивую загрузку изображений (об этом расскажу попозже), и ее реализация предполагает исполнение кода — в том числе на клиенте. На первом экране ленивая загрузка не требуется — наоборот, надо как можно скорее отобразить изображения, поэтому мы добавили пропс no-lazy к компоненту VImage, который отключает ленивую загрузку.
Что получилось в итоге
На данный момент мы реализовали следующий функционал:
Интеграция с ImgProxy: для сжатия в WebP и отдачи любого нужного размера на лету
Двухэтапная загрузка изображений (плюс нативная через пропс): для улучшения показателей Lighthouse
Адаптив изображений под любой экран, в том числе экраны с высокими PPI
Превью изображений
Предварительная загрузка изображений (preload)
Улучшение показателей в Lighthouse
Все эти фичи входят в состав npm-пакета, который подключается ко всем новым проектам. Упаковка в пакет позволяет централизованно и очень быстро обновлять реализацию везде и сразу.
Для конечного пользователя (разработчика) все сводится к использованию компонента VImage с нужными пропсами.
Дальше расскажу подробнее о некоторых аспектах реализаций.
Оптимизация размеров и сжатие изображений
Чтобы изображения всегда были нужного размера, веса и качества, используем связку @nuxt/image и ImgProxy.
ImgProxy — это сервис, который берет оригинальное изображение и изменяет его размер, формат и качество, в зависимости от нужных параметров.
@nuxt/image — позволяет удобно связать наше приложение с ImgProxy или аналогичными сервисами. Для этого нужно разработать провайдер, который будет преобразовывать пропсы изображения в понятный ImgProxy формат. Использование @nuxt/image дает возможность в будущем заменить ImgProxy на другой сервис с минимальными временными затратами.
Пример использования:
На входе:
<VImage
:width="800"
image="https://test.ru/images/test.jpg"
/>
На выходе:
<img src="https://test.ru/proxy/w:800/q:80/plain/https://test.ru/images/test.jpg@webp>
То есть в итоге мы получаем ссылку на изображения с нужными модификаторами. Ширина изображения width преобразовалось в w:800, сама ссылка на изображение стала модификатором, и добавился формат @webp. После перехода по ссылке мы получим webp изображения с шириной в 800px.
Адаптивные изображения
Если одно и то же изображение применяется на разных устройствах (телефон, ноутбук, десктоп), то для отображения оптимальных изображений можно использовать атрибуты sizes и srcset.
В этих атрибутах перечисляются брейкпоинты и ссылки на изображения в нужном размере. Далее дело за браузером: он, основываясь на атрибуте sizes, выберет из набора srcset наиболее подходящее по размеру изображение.
Важно понимать, что плотность пикселей у разных устройств может быть разная, и браузер выберет то изображение из набора, которое лучше всего подойдет именно для этого устройства.
Для генерации sizes и srcset можно воспользоваться встроенным в @nuxt/image методом.
Мы же пошли чуть дальше и разработали свой метод, чтобы более гибко настраивать атрибуты и полностью контролировать процесс генерации.
Но в любом случае встроенный в @nuxt/image метод генерации позволяет в «два клика» получать sizes и srcset с нужными модификаторами изображений. Он также принимает список брейпоинтов, которые можно вынести в настройки проекта (например, в app.config.ts) и синхронизировать с scss брейкпоинтами.
Пример настроек в файле app.config.ts:
export default defineAppConfig({
images: {
breakpoints: {
mobile: 0,
tablet: 768,
laptop: 1280,
desktop: 1440,
ultra: 1921,
}
}
});
Пример использования:
На входе:
<VImage
image="https://test.ru/images/test.jpg"
sizes="mobile:100vw tablet:100vw laptop:600px desktop:800px"
/>
На выходе:
<img
src="https://test.ru/proxy/q:20/bl:30/dpr:0.5/plain/https://test.ru/images/test.jpg@webp"
sizes="(max-width: 767px) 100vw, (max-width: 1279px) 100vw, (max-width: 1439px) 600px, 800px"
srcset="https://test.ru/proxy/w:768/q:80/plain/https://test.ru/images/test.jpg@webp 768w, https://test.ru/proxy/w:1536/q:80/plain/https://test.ru/images/test.jpg@webp 1536w, https://test.ru/proxy/w:1280/q:80/plain/https://test.ru/images/test.jpg@webp 1280w, https://test.ru/proxy/w:2560/q:80/plain/https://test.ru/images/test.jpg@webp 2560w, https://test.ru/proxy/w:600/q:80/plain/https://test.ru/images/test.jpg@webp 600w, https://test.ru/proxy/w:1200/q:80/plain/https://test.ru/images/test.jpg@webp 1200w, https://test.ru/proxy/w:800/q:80/plain/https://test.ru/images/test.jpg@webp 800w, https://test.ru/proxy/w:1600/q:80/plain/https://test.ru/images/test.jpg@webp 1600w"
>
Обратите внимание на атрибут src: в нем лежит наше превью, а именно, размытое блюром (bl:30) и уменьшенное (dpr:0.5) изображение в низком качестве (q:20).
Двухэтапная ленивая загрузка
Для улучшения показателей в lighthouse мы воспользовались хаком с подменой изображений на крошечный gif — до момента их показа, чтобы даже превью не грузились до попадания изображения в экран.
Суть метода в том, что сначала мы показываем пустое изображения, после чего с помощью vanilla-lazyload подгружаем его легковесное превью и только потом — само изображение.
Делается это путем добавления data-атрибутов с превью и исходным изображением, которые последовательно подменяют значение атрибута src. Что касается адаптивных изображений, то там все аналогично: sizes и srcset спрятаны в data-атрибутах до момента попадания изображения в экран.
На входе:
<VImage
image="https://test.ru/images/test.jpg"
:width="100"
/>
До попадания в экран:
<img
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
data-src="https://test.ru/images/proxy/q:20/bl:30/dpr:0.5/plain/https://test.ru/images/test.jpg@webp"
data-lazy-src="https://test.ru/images/proxy/w:100/h:0/q:80/plain/https://test.ru/images/test.jpg@webp"
>
src — пустой gif
data-src — превью
data-lazy-src — конечное изображение
После попадания в экран и загрузки превью:
<img src="https://test.ru/images/proxy/w:100/h:0/q:80/plain/https://test.ru/images/test.jpg@webp">
Таким образом мы гарантируем, что пока изображение не покажется на экране, ни оно, ни его превью не будут подгружены браузером.
Поведение ленивой загрузки можно изменить; для этого у компонента VImage есть два пропса: two-steps и native-lazy.
two-steps — позволяет выключить двухэтапную загрузку.
native-lazy — включает (для изображения и его превью) коробочный loading="lazy" и отключает двухэтапную загрузку.
Коробочным loading="lazy" мы пользуемся только в специфических случаях. По нашим наблюдениям его работа не всегда так прозрачна, как хотелось бы. Однако мы реализовали его поддержку, поскольку надеемся, что в будущем браузеры явно улучшат работу.
Предварительная загрузка изображений
Если требуется чтобы изображение загрузилось как можно скорее (например, если оно находится на первом экране), то можно добавить на него ссылку в head.
<html>
<head>
<link rel="preload" as="image" href="https://test.ru/proxy/w:100/q:80/plain/https://test.ru/images/test.jpg@webp">
</head>
<body>
...
</body>
</html>
В масштабах средних и крупных проектов ручное добавление ссылок в head на каждой странице не очень удобное занятие. Чтобы автоматизировать процесс добавление ссылок на изображения в head, мы воспользовались встроенным в Nuxt useHead.
Для разработчика требуется добавить пропсы no-lazy и preload.
no-lazy — выключает ленивую загрузку
preload — добавляет ссылку на изображение (и его превью) в head
Для адаптивных изображений все аналогично: в ссылке добавляются imagesrcset и imagesizes.
Пример использования:
<VImage
:width="900"
preload
no-lazy
image="https://test.ru/images/test.jpg"
/>
Результаты в Lighthouse
На показатели в Lighthouse влияют множество факторов.
Использование адаптивных изображений вкупе с двухэтапной ленивой загрузкой приносят по нашим тестам от 5 до 30 баллов. Все очень зависит от количества изображений на странице и блоков на ней.
Мы остались довольны результатом — особенно в сравнении с результатами похожих проектов.
Также на проектах мы применяли другие техники для ускорения загрузки: ленивую гидратацию (я говорил о ней в самом начале статьи), «правильную» загрузку внешних скриптов и т.д. Это несколько выходит за рамки моей статьи, но если будет интерес, напишу про эти техники отдельный материал — кидайте запросы в комментарии :)
Пример результатов
До оптимизации:

После:

Заключение
Я рассмотрел основные моменты работы с изображениями на проектах ИдаПроджект. Однако методы постоянно совершенствуется, мы постоянно ищем новые, более оптимальные решения существующих проблем.
Для некоторых простых проектов реализация всех вышеперечисленных техник избыточна и требует ресурсов на ее реализацию. Я не призываю использовать их все, но часть из них можно взять хотя бы для сокращения времени на разработку (долой рутинный труд разработчиков и контент-менеджеров!).
В чем преимущество нашей реализации: простота использования и подключения к новым проектам. От разработчика потребуется только подключить npm-пакет к своему Nuxt проекту (если он еще не подключен), и использовать компонент VImage с различными вариациями пропсов. Изи!
На этом у меня все :)
P. S. Что мы используем: Nuxt 3, ImgProxy, Vanilla-lazyload, @nuxt/image, nuxt-delay-hydration и Verdaccio.