Суть оптимизации

На сайте уже писал более подробную статью про оптимизацию изображений на сайте. Давайте еще раз коротко пройдемся по направлениям оптимизации.

Разрешение

Картинка должна иметь примерно то же разрешение, которое она занимает на экране. Иначе она просто сжимается браузером, и больший размер не дает никаких преимуществ, но вес файла при этом увеличивается.

Сжатие

Согласно документации Google Pagespeed, оптимизированными считаются изображения с уровнем сжатия не менее 85. Соответственно, нам нужно сжать наши файлы до этого уровня.

Формат

Разные форматы подходят для разных целей. Кратко:

  • SVG — векторный, для иконок
  • PNG — растровый, с прозрачностью, много весит, хорошая поддержка браузерами
  • JPG — растровый, без прозрачности, мало весит, хорошая поддержка браузерами
  • WEBP — растровый, с прозрачностью, мало весит, средняя поддержка браузерами

В большинстве случаев будет достаточно связки WEBP+JPG. WEBP — это современный формат от Google, который сохраняет прозрачность и при этом хорошо оптимизируется по весу, даже лучше JPG.

Проблема в том, что он поддерживается не во всех старых браузерах. Можно проверить на caniuse.com:

Статистика доступности формата WEBP в браузерах по сервису caniuse

В отличие от того же PNG, который поддерживается почти всеми браузерами - ссылка.

Статистика доступности формата PNG в браузерах по сервису caniuse

Поэтому используем WEBP, и на всякий «страхуем» его JPG или PNG — в зависимости от того, нужна прозрачность или нет.

Адаптивность

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

Разрешения экранов

На устройствах Apple, да уже и на многих аналогах премиальных сегментов часто плотность пикселей экрана превышает стандартную. Чтобы картинка не выглядела размытой на таких экранах, нужно задавать альтернативный вариант с большим разрешением.

Теперь разберем, как реализовать это все в MODX.

Вручную

Для обрезки, сжатия и оптимизации картинок есть замечательный сниппет phptumbon. Чтобы им пользоваться, установите пакет с таким же названием в установщике. Исходя из рекомендаций выше, нам нужно вставить примерно такую конструкцию:

<picture>
	<source 
		media="(max-width: 566px)" 
		srcset="[[phpthumbon?input=`[[+src]]`&options=`w=450&f=webp&q=85&dpi=72`]],
		[[phpthumbon?input=`[[+src]]`&options=`w=800&f=webp&q=85&dpi=72`]] 2x" 
		type="image/webp" alt="[[+alt]]">
	<source 
		media="(min-width: 567px)" 
		srcset="[[phpthumbon?input=`[[+src]]`&options=`w=800&f=webp&q=85&dpi=72`]],
		[[phpthumbon?input=`[[+src]]`&options=`w=1600&f=webp&q=85&dpi=72`]] 2x" 
		type="image/webp" alt="[[+alt]]">
	<source 
		media="(min-width: 567px)" 
		srcset="[[phpthumbon?input=`[[+src]]`&options=`w=800&f=jpg&q=85&dpi=72`]],
		[[phpthumbon?input=`[[+src]]`&options=`w=1600&f=jpg&q=85&dpi=72`]] 2x" 
		type="image/jpeg" alt="[[+alt]]">
	<img 
		src="[[phpthumbon?input=`[[+src]]`&options=`w=450&f=jpg&q=85&dpi=72`]]"
		srcset="[[phpthumbon?input=`[[+src]]`&options=`w=800&f=jpg&q=85&dpi=72`]] 2x" 
		alt="[[+alt]]">
</picture>

В теге picture указаны несколько альтернативных источников файла. Если браузер старый и не поддерживает такую функцию — он подтянет изображение из тега img, поэтому его нужно указывать обязательно, и со старым форматом PNG или JPG.

  • В новых браузерах для мобильных подтянутся изображения с media="(min-width: 567px)", а для десктопов — стандартных размеров с оптимальным форматом. 
  • Для разной плотности пикселей задаем альтернативные картинки большего размера с указанием 2x - они будут загружены для экранов с двойной и более плотностью.

В опциях phptumbon мы указываем значение ширины картинки в параметре w, при этом не указывая высоту (параметр h). Такой вариант подойдет, если картинки в шаблоне могут отличаться по высоте. Аналогично работает и с шириной.

Если же нужно определенное соотношение сторон, то задайте параметр h и вместе с тем параметр zc=1. Если его не указать, то у картинки добавятся поля. Если указать — картинка подстроится по высоте или ширине, а лишнее обрежется.

Например, для карточек товаров с прозрачным фоном лучше не указывать, чтобы само изображение товара не обрезались. А вот для превью статей — вполне себе можно. Данный вариант можно использовать в чанках блоков или шаблонах страниц, меняя параметры вызова под определенные задачи. Помните, что в теге img должна быть картинка JPG или PNG, чтобы в старых браузерах тег picture сработал корректно, а в srcset можно указать WEBP.

Но, например, при написании статей такой способ не очень удобен. Хочется просто вставлять картинку, но чтобы она оптимизировалась автоматически при загрузке страницы. Рассмотрим и такой вариант.

Автоматизируем

В качестве сниппета для оптимизации будем использовать тот же самый phpthumbon. Сначала нужно создать чанк с разметкой блока картинки, как она должна будет выглядеть после оптимизации. Назовем его tpl.Img.

<div class="article-image__wrapper">
	<picture>
		<source 
			media="(max-width: 566px)" 
			srcset="[[phpthumbon?input=`[[+src]]`&options=`w=450&f=webp&q=85&dpi=72`]],
			[[phpthumbon?input=`[[+src]]`&options=`w=800&f=webp&q=85&dpi=72`]] 2x" 
			type="image/webp" alt="[[+alt]]">
		<source 
			media="(min-width: 567px)" 
			srcset="[[phpthumbon?input=`[[+src]]`&options=`w=800&f=webp&q=85&dpi=72`]],
			[[phpthumbon?input=`[[+src]]`&options=`w=1600&f=webp&q=85&dpi=72`]] 2x" 
			type="image/webp" alt="[[+alt]]">
		<source 
			media="(min-width: 567px)" 
			srcset="[[phpthumbon?input=`[[+src]]`&options=`w=800&f=jpg&q=85&dpi=72`]],
			[[phpthumbon?input=`[[+src]]`&options=`w=1600&f=jpg&q=85&dpi=72`]] 2x" 
			type="image/jpeg" alt="[[+alt]]">
		<img 
			loading="lazy" decoding="async"
			src="[[phpthumbon?input=`[[+src]]`&options=`w=450&f=jpg&q=85&dpi=72`]]"
			srcset="[[phpthumbon?input=`[[+src]]`&options=`w=800&f=jpg&q=85&dpi=72`]] 2x" 
			alt="[[+alt]]">
	</picture>
</div>
[[+desc:notempty=`<p class='article-img-desc'>[[+desc]]</p>`]]

Теперь осталось создать и настроить плагин, который будет запускаться перед рендерингом страницы и подменять все теги img в контенте на вызов чанка оптимизированной картинки. Создаем плагин pl.ImgAutoOptimize с таким содержимым:

if ($modx->event->name != "OnLoadWebDocument" || $modx->context->key == 'mgr') {
   return;
}

// Получаем контент страницы
$output = $modx->resource->get('content');

function optimage($img) {
	$src = preg_replace('/.* src="(.*?)".*/m', '$1', $img[0]);
	$alt = preg_replace('/.* alt="(.*?)".*/m', '$1', $img[0]);
	$desc = preg_replace('/.* title="(.*?)".*/m', '$1', $img[0]);
	
	// preg_replace возвращает полный тег, если совпадений не найдено
	if ($src == $img[0]) {
		$src = '';
	}
	if ($alt == $img[0]) {
		$alt = '';
	}
	if ($desc == $img[0]) {
		$desc = '';
	}
	
	return '[[$tpl.Img?src=`'.$src.'`&alt=`'.$alt.'`&desc=`'.$desc.'`]]';
}

// Заменяем 
$output = preg_replace_callback('/(<p>\s*|)<img[^>]*>(\s*<\/p>|)/m', 'optimage', $output);

// Заменяем контент 
$modx->resource->set('content', $output);

Вешаем плагин на системное событие OnLoadWebDocument.

Плагин проходит по всем тегам <strong>img</strong> в контенте ресурса, выбирает из них значения атрибутов <strong>src</strong>, alt и title и заменяет <strong>img</strong> на вызов созданного нами чанка, в котором указаны соответствующие плейсхолдеры. Значение <strong>title</strong> плагин подставит под картинку — можно стилизовать это как подписи для изображений.

Микроразметка Schema.org

Поскольку на странице получается куча временных файлов оптимизированных изображений — чтобы не терять потенциальный трафик с картинок, нужно указать ссылку на оригинальный файл. Кроме того, разметка изображений именно как объекта потенциально улучшит SEO — как минимум картинки смогут попасть в сниппет страницы в поисковой выдаче.

Поскольку через тег meta передать ссылку мы не можем, я решил это делать через тег ссылки a. Видимых минусов не нашел, ссылку просто скрываем, но поисковая система ее просканирует. Просто добавим блоку-обертке, что он объект типа картинка. А внутри разместим скрытую display:none ссылку с nofollow и пометим что она передает значение contentUrl. У ссылки добавим домен, чтобы она была абсолютная — мне кажется так корректнее.

<div class="article-image__wrapper" itemscope itemtype="https://schema.org/ImageObject">
	<a itemprop="contentUrl" href="[[++site_url]][[+src]]" style="display:none;" rel="nofollow"></a>	
	<picture>	
		<source 
		    media="(min-width: 550px)"
			srcset="..." 
			type="image/jpeg">
		<img class="lazyload"
			loading="lazy" decoding="async"
			src="..."
			alt="[[+alt]]">
	</picture>
</div>

Lazy Load (ленивая загрузка)

Нам нужно вывести ссылку на картинку в атрибуте data-src. Далее настроить специальный JS скрипт отслеживает скролла. Он будет проверять — если картинка близка к области видимости, то подставляет ссылку из data-src в src.

Существует несколько библиотек для ленивой загрузки. Мне для этих целей приглянулся код этого проекта на Github. Он написан на чистом JavaScript и корректно работает с адаптивными изображениями в теге picture, что нам и нужно.

Но есть еще нюанс — поскольку изначально картинку мы выводим как бы пустую, она будет занимать непонятную высоту. Если с этим ничего не сделать, то при скролле страницы высота самой страницы будет меняется, что не очень удобно.

Суммарный алгоритм действий:

  • добавим на страницу скрипт;
  • добавим к нашим картинкам класс lazyload;
  • выведем в чанке высоту и ширину данной картинки. Для этого основную картинку передадим в плейсхолдеры, а не выведем сразу;
  • после загрузки контента запустим скрипт, который определит реальную ширину картинки и пропорции ее размеров, и установит ей реальную высоту;
  • добавим стили для картинок 404, чтобы они занимали небольшую высоту, если картинка не загрузилась;
  • в скрипт ленивой загрузки добавим изменение высоты после загрузки на auto;
  • в скрипте ленивой загрузки поправим, чтобы у обработанной картинки сразу убирался класс lazyload. Иначе, если картинка не загружается (неверно указан адрес) — у меня это вызывало циклическую загрузку и мерцание. Некрасиво. А по сути какая разница — картинка загружена, скрипт по ней работу сделал и больше ничего не может сделать, если адрес указан некорректно.

Итак, пример чанка самой картинки получается такой (с микроразметкой):

<div class="article-image__wrapper"  itemscope itemtype="https://schema.org/ImageObject">
	<a itemprop="contentUrl" href="[[++site_url]][[+src]]" style="display:none;" rel="nofollow"></a>		
	<picture>
		<source 
			media="(max-width: 566px)" 
			data-srcset="[[phpthumbon?input=`[[+src]]`&options=`w=450&f=webp&q=85&dpi=72`]],
			[[phpthumbon?input=`[[+src]]`&options=`w=800&f=webp&q=85&dpi=72`]] 2x" 
			type="image/webp" alt="[[+alt]]">
		<source 
			media="(min-width: 567px)" 
			data-srcset="[[phpthumbon?input=`[[+src]]`&options=`w=800&f=webp&q=85&dpi=72`]],
			[[phpthumbon?input=`[[+src]]`&options=`w=1600&f=webp&q=85&dpi=72`]] 2x" 
			type="image/webp" alt="[[+alt]]">
		<source 
			media="(min-width: 567px)" 
			data-srcset="[[phpthumbon?input=`[[+src]]`&options=`w=800&f=jpg&q=85&dpi=72`]],
			[[phpthumbon?input=`[[+src]]`&options=`w=1600&f=jpg&q=85&dpi=72`]] 2x" 
			type="image/jpeg" alt="[[+alt]]">
		[[pthumb?input=`[[+src]]`&options=`f=jpg&q=800&dpi=72`&toPlaceholder=`thumb`]]
		<img 
			class="lazyload"
			loading="lazy" decoding="async"
			data-src="[[+thumb]]" data-width="[[+thumb.width]]" data-height="[[+thumb.height]]"
			alt="[[+alt]]">
	</picture>
</div>

Добавляем скрипт установки высоты картинки:

/* фикс высоты картинок до ленивой загрузки */
document.addEventListener("DOMContentLoaded", () => {
	var images = document.querySelectorAll("img.lazyload");
	var i = images.length;
	while (i--) {
		/* реальная ширина картинки */
		let width = images[i].offsetWidth; 
		/* пропорциональная реальная высота */
		let height = images[i].dataset.height / images[i].dataset.width * width;
		images[i].style.height = height + 'px';
	}
});

И скрипт самой ленивой загрузки, с внесенными изменениями, которые я описал выше. Можете настроить отступ, чтобы картинки загружались раньше или позже.

! function() {
    function lazyload() {
        var images = document.querySelectorAll("img.lazyload");
        var i = images.length;
        !i && window.removeEventListener("scroll", lazyload);
        while (i--) {
            var wH = window.innerHeight;
            var offset = 500;
            var yPosition = images[i].getBoundingClientRect().top - wH;
            if (yPosition <= offset) {
                /* Фикс мерцания */
                images[i].classList.remove("lazyload");
                images[i].classList.add("loaded");
                /* Фикс указанной пропорциональный высоты */
                images[i].style.height = 'auto';
                if (images[i].getAttribute("data-src")) {
                    images[i].src = images[i].getAttribute("data-src");
                };
                if (images[i].getAttribute("data-srcset")) {
                    images[i].srcset = images[i].getAttribute("data-srcset");
                };
                if (images[i].parentElement.tagName === "PICTURE") {
                    var sources = images[i].parentElement.querySelectorAll("source");
                    var j = sources.length;
                    while (j--) {
                        sources[j].srcset = sources[j].getAttribute("data-srcset");
                    };
                };
                /*  images[i].addEventListener('load', function() {
                     this.classList.remove("lazyload");
                     this.classList.add("loaded");
                }); */
            }
        }
    }
    lazyload();
    window.addEventListener("scroll", lazyload);
}();

Еще добавляем стили для незагруженных картинок, чтобы они не занимали много места по высоте. А также анимацию появления для загруженных картинок.

img[data-width=""] {
    max-height: 2rem;
}

img.loaded {
    animation: fadeIn 600ms ease;
}

@keyframes fadeIn {
    from { opacity: 0; }
    to { opacity: 1; }
}

Мне кажется, это можно делать прямо в скрипте, чтобы быстро редактировать при необходимости:

let style = document.createElement('style');
style.type = 'text/css';
style.innerHTML = `
	img[data-width=""] {
		max-height: 2rem;
	}	

	img.loaded {
		animation: fadeIn 600ms ease;
	}

	@keyframes fadeIn {
		from { opacity: 0; }
		to { opacity: 1; }
	}
`;
document.head.appendChild(style);

Вроде все, теперь обычные картинки в контенте ресурса будут автоматически оптимизироваться. Спасибо за внимание!