Модернизация с Web-платформой: Производительность изображений
Это первая часть серии статей о силе современной Web-платформы. Я написал каждую статью, основываясь на результатах проекта, созданного в 2018 году, а затем обновлённого в 2024 году.
История DevilsGame
В 2018 году меня наняли, чтобы я создал веб-прототип для того, что клиент надеялся превратить в приложение для iOS/Android в будущем. Это называется DevilsGame, иммерсивная кибер-новелла — не электронная книга, но в конечном счёте объёмное произведение, использующее преимущества своего размещения в Интернете, со ссылками на реальные и вымышленные сайты, оживляющие историю.
Я создал PWA с платным контентом, чтобы люди могли начать читать бесплатно, но должны были купить всю историю. Клиент был вполне доволен, но хотел сосредоточиться на нативных приложениях после того, как они будут доработаны. Для управления этой работой была нанята отдельная фирма, что оказалось дорого и медленно по сравнению с быстродействием веб-сайта.
Через несколько лет они снова обратились ко мне с вопросом, не могли бы мы ещё больше инвестировать в PWA. За это время web-платформа добавила несколько очень хороших возможностей. Поэтому я провёл ревизию старой кодовой базы и вернулся с планом по улучшению производительности, доступности и общего UX.
В этой статье речь пойдёт о производительности изображения.
Улучшение производительности изображения с <picture>
Во время первоначальной сборки я получил все ресурсы в PNG
и должен был сделать всё возможное для быстрого создания сюжета, поэтому не обращал особого внимания на производительность изображений. Во время пересборки стало очевидно, что изображения — это огромное узкое место в общей производительности.
Многие из изображений, несомненно, лучше было бы использовать в формате JPEG
, но брендинг истории диктовал, чтобы большинство изображений контента не выглядели прямоугольниками, а имели случайные рваные края. Однако благодаря значительным улучшениям в форматах изображений, поддерживаемых браузерами, в 2024 году это стало проще: я смог экспортировать изображения в формате WebP, а в некоторых случаях наиболее целесообразным оказался формат AVIF
. Оба формата поддерживают альфа-каналы в дополнение к гораздо лучшему сжатию, позволяя получать идентичные изображения, используя гораздо меньший объём.
По сути, это был поиск/замена в кодовой базе, оборачивание существующих тегов img
тегами picture
и добавление source
, имя файла которого было взято из оригинала.
<!-- до -->
<img src="/img/1/1-1_ClairePhone.png" alt="Claire looking at her BlackBerry">
<!-- после -->
<picture>
<source srcset="/img/1/1-1_ClairePhone.webp" type="image/webp">
<img src="/img/1/1-1_ClairePhone.png" alt="Claire looking at her BlackBerry">
</picture>
Ленивая предзагрузка изображений
Итак, полезная нагрузка изображений была уменьшена просто за счёт предоставления браузером поддержки более компактных форматов изображений. Ура! Однако все они загружаются довольно быстро, и более того: большинство из них находятся за всплывающими окнами. То есть может случиться так, что вы загрузите изображение, но так и не просмотрите его!
Это как раз тот случай, когда атрибут loading="lazy"
может быть востребован. Он добавляется непосредственно в тег <img>
, например:
<picture>
<source srcset="/img/1/1-1_ClairePhone.webp" type="image/webp">
<img loading="lazy" src="/img/1/1-1_ClairePhone.png" alt="Claire looking at her BlackBerry">
</picture>
Однако я всё ещё не чувствовал полного удовлетворения. Ленивая загрузка изображений означала, что всплывающее окно больше не показывало содержимое сразу после появления, а начинало загружаться только после того, как модальное окно становилось видимым. Нужен был способ предварительной загрузки изображений непосредственно перед тем, как они понадобятся, чтобы всплывающее окно загружалось и отображалось один раз без каких-либо изменений в макете.
Представляем Intersection Observer API: эффективный метод, позволяющий отслеживать, видны ли определённые части веб-страницы в области просмотра. Поскольку всплывающие окна вызываются ссылками, расположенными по всему тексту, имеет смысл подгружать изображения, как только конкретная ссылка, вызывающая всплывающее окно, становится видимой:
/**
* Определение Intersection Observer
*/
function observePopupImages () {
// Все всплывающие ссылки имеют href, начинающийся с „#note“.
const popupLinks = document.querySelectorAll('a[href^="#note"]');
// Конфигурация IntersectionObserver. Говоря простым языком: каждая всплывающая
// ссылка должна полностью войти в область просмотра, прежде чем будет считаться видимой.
const options = {
root: null, // область просмотра
rootMargin: '0px',
threshold: 1.0,
};
// Создаём Intersection Observer с обратным вызовом и опциями.
// Обратный вызов будет описан в следующем блоке кода.
const observer = new IntersectionObserver(preloadPopupImages, options);
// Назначаем Intersection Observer всем всплывающим ссылкам в области контента.
popupLinks.forEach((el) => {
observer.observe(el, options);
});
}
Следующий блок кода — это функция обратного вызова, обрабатывающая предзагрузку, изменяя значение loading
каждого элемента изображения с lazy
на eager
, как только всплывающая ссылка становится видимой в области просмотра.
/**
* Обратный вызов для Intersection Observer
*
* @array entries
* Хранит массив пересечений, произошедших с момента последнего запуска.
* @object observer
* Хранит конфигурацию Intersection Observer, заданную при его создании.
*/
function preloadPopupImages (entries, observer) {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// Поиск ID модального окна, на которое указывает всплывающее окно.
const modalId = entry.target.href.split('#')[1];
// Поиск изображений в этом модале и установка `loading="eager"`
const imgs = document.querySelectorAll(`#${modalId} img`);
imgs.forEach(el => {
el.setAttribute('loading', 'eager');
});
}
});
}
Это довольно простой обратный вызов, поскольку нет никаких негативных последствий, если обратный вызов выполняется более одного раза. Если бы требовалось предотвратить повторный запуск, это было бы сложнее, но для моего случая это подходит.
Вот видео, на котором изображения загружаются только по мере необходимости. Если просмотреть кадр за кадром, то видно, что даже первые два изображения Claire и Nathan загружаются очень быстро, но действительно лениво. Это происходит потому, что эти две всплывающие ссылки не видны до тех пор, пока контент не встанет на место. Позже, по мере прокрутки страницы, загружается третье изображение 1-1_BBSE.webp
, когда ссылка "Passport" наконец попадает в область видимости снизу.

Такое поведение гарантирует, что загружаются только те изображения, которые действительно могут быть увидены, и позволяет избежать задержки, возникающей, если бы мы ждали предварительной загрузки отдельных изображений до тех пор, пока всплывающее окно не будет отображено.
Заключение
Надеюсь, это подстегнёт ваше воображение, когда будете пересматривать старые кодовые базы, нуждающиеся в обновлении. То, что раньше требовало кучи JS, теперь требует всего нескольких строк или всего нескольких декларативных атрибутов в HTML.
Следите за продолжением серии!