Всё началось примерно 10 лет назад, когда захотелось, чтобы вот этот вот контейнер выглядел прилично, не имел явно зазубренных граней, но при этом загружался за разумное время.

Правый контейнер ощутимо "царапает" глаз
Правый контейнер ощутимо "царапает" глаз

Текст может быть интересен тем, кто живёт примерно также, как мы.

А как это?
  1. Сайт когда-то был на готовом open source движке (мы использовали Mezzanine), но постепенно мутировал так, что можно считать его самописным.

  2. Изображения на сайт выкладывают только сотрудники. Нет никакого user generated content. Поэтому изображений не очень много, место на диске экономить незачем, на одно добавление картинки приходятся десятки тысяч просмотров.

  3. Python. У нас до сих пор 2.7 во многих местах, поэтому приходится немного патчить.

1. Отдельная версия изображений для экранов с retina.

При загрузке первой страницы проверяем window.devicePixelRatio и по нему решаем, показывать «обычные» изображения, или изображения двойного разрешения. Заодно выставляем cookie чтобы повторные визиты на сайт сразу давали нужные настройки. Для изображений обычного размера используется сжатие jpeg с качеством 90, а для изображений двойного размера – с качеством 75 или 80. Это даёт примерно 50-100% увеличение размера файла под "ретину". То есть, изображение вчтеверо большей площади по размеру не более чем вдвое больше исходного. Достоинства:

  • Работает даже на самых древних браузерах.

  • Управляемо: можно для отладки задать в URL параметр, который «насильно» включает или выключает retina и увидеть соответствующие изображения.

Недостатки:

  • Непонятно что делать с самым первым просмотром первой страницы: или показывать обычные изображения, а «красивые» – только при просмотре следующей страницы, когда мы уже узнали значение window.devicePixelRatio. Или, наоборот, сразу показывать «красивые» и начинать экономить только со второй страницы. Или узнавать наличие retina по косвенным признакам (user agent). Мы тогда выбрали первый из этих трёх вариантов.

  • Про то, что может быть ноутбук с retina и пристёгнутый к этому же ноутбуку второй экран без retina мы тогда даже не думали.

  • Сложно кэшировать: каждую страницу сайта нужно сохранять в варианте с retina и в варианте без.

Увидели ли мы рост посещаемости сайта или повышение конверсии от внедрения? Нет.

2. Минимизируем трафик при «кажущемся» высоком качестве изображений.

В 2016 году появилась библиотека Guetzli. Авторы сделали очень вычислительно тяжёлый алгоритм, который подбирает параметры сжатия изображения так, чтобы воспринимаемые глазом потери были минимальны. Для Python есть модуль pyguetzli, который довольно просто подключить.

Guetzli хорошо сжимает картинки, но работает адски долго. В нашей CMS масштабирование графики делается прямо во время формирования html-кода страницы. Разумеется, только если в файловой системе ещё нет нужного файла с масштабированной картинкой. Но если, вдруг, его нет (новая страница, заменили изображение, сбросили кэш изображений на сервере), то пока CMS не отмасштабирует все изображения со страницы под заданные размеры, html-код не будет сформирован, посетитель будет ждать.

Со включённым алгоритмом guetzli этот процесс может занимать несколько минут. Это создаёт риск «не дождался загрузки страницы, нажал refresh, снова не дождался, нажал ещё раз refresh, сайт надорвался». Поэтому мы сделали двухпроходную схему:

  1. Сначала мы масштабируем изображение обычным кодом из библиотеки Pillow.

  2. Около файла myimage-200x200.jpg помещаем «теневой файл» myimage-200x200.jpg-guetzli-me, по которому мы знаем, что это изображение сжато быстрым, но неоптимальным алгоритмом.

  3. Отдельным проходом запускаем процесс, который ищет все изображения с суффиксом -guetzli-me в имени файла и формирует их повторно, с использованием guetzli. Важно: формировать изображения надо из исходной картинки, а не из масштабированного на п. 1 файла. Иначе вся затея с качественными картинками теряет смысл.

Что мы в итоге получили:

  • Минимизировали вес страниц на сайте, размер изображений свели к минимуму. Экономия составила примерно 25% от общего размера загружаемых данных.

  • Не боимся сброса кэша или коллеги, которая решила заменить все изображения на странице на новые. Эта страница какое-то недолгое время будет иметь неоптимизированные по размеру файлов изображения, ничего страшного.

  • Совместимо со всеми браузерами.

Увидели ли мы рост посещаемости сайта или повышение конверсии от уменьшения веса страниц? Нет.

3. Уходим от javascript с window.devicePixelRatio

В какой-то момент проверка разрешения на стороне клиента начала сильно мешать. Server Side Rendering должен быть согласован с клиентским кодом, работающим в браузере. Проблема первой загрузки, когда мы ещё не знаем devicePixelRatio, никуда не делась. Мы модифицировали CMS, чтобы во все <img src="..."> автоматически подставлялся атрибут srcset="...", в котором мы задаём разные URL изображений для разных разрешений экрана. Примерно так:

<img src="/m/.../200x200-10.jpg"
        srcset="/m/.../200x200-10.jpg 1x, /m/…/200x200-20.jpg 2x"
        width="200" height="200">

Этот вариант не работает на старых браузерах iPhone с retina (телефоны с ретиной выпускались начиная с 2010 года, а атрибут srcset поддерживать начали только в 2014), но этим уже тогда можно было пренебречь: количество таких телефонов было примерно равно нулю. В результате и первая и все последующие страницы загружаются с правильными изображениями. Про компьютеры, к которым подключено несколько разных дисплеев, также можно не думать.

4. Изображения с прозрачным фоном.

Для изображений с прозрачным фоном использовать jpeg не получается. Приходится их выкладывать в формате PNG. По состоянию на январь 2023 года перейти на WEBP в качестве основного формата изображений всё ещё нельзя: есть пользователи с Safari 13 и другими браузерами, которые этот формат не поддерживают.

Для оптимизации PNG по размеру можно использовать Pngquant. Код может выглядеть примерно так:

if strFormat == 'PNG':
    oImage.convert("RGBA").quantize(method=3).save(strFileName, strFormat, ...)

5. Фотографии с прозрачным фоном, webp

В какой-то момент на сайте появились фотографии, на которых поля должны быть прозрачными. Держать их в PNG – расточительно, они занимают неоправданно много места. Подключим WEBP.

Этот формат поддерживается библиотекой Pillow "из коробки". Поэтому достаточно установить библиотеки для webp, перекомпилировать Pillow, убедиться, что он их почуял, и вызвать:

oImage.save(strWebpShadowFilename, "WEBP", ...)

При тестировании оказалось, что сформированные кодом на Питоне файлы WEBP были всегда больше по размеру, чем сформированные утилитой командной строки cwebp. Оказалось, что библиотека Pillow версии 6.2.2 (а это последняя версия, которая работает с Python 2.7, все более свежие работают только с третьим Питоном) просто игнорирует параметр method, задающий баланс время-работы / размер-выходного-файла. Мы вызывали в python

oImage.save(strWebpShadowFilename, "WEBP", method=6, ...)

а оно этот параметр просто не дотаскивало до кода на C, который, собственно, и реализует сохранение в формате WEBP. Раз уж полезли в это место, сделали патч: версия 6.2.2 библиотеки Pillow, которая принудительно передаёт метод компрессии, равный 6. Да, мы никуда не торопимся, у нас немного изображений и они не так уж часто добавляются. Если кто-то ещё не ушёл с Python 2.7, то вот патч, а вот модифицированная версия 6.2.2 библиотеки Pillow, можно установить её при помощи pip и пользоваться.

6. Avif.

Раз уж влезли в это место, решили сразу встроить и поддержку ещё более свежего формата изображений, AVIF. Он пока не поддерживается библиотекой Pillow, нужно устанавливать отдельную библиотеку pillow_avif. И снова оказалось, что изображения, сформированные кодом на Питоне, имеют больший размер, чем если их формировать утилитой командной строки avifenc. Оказалось, что в обёртка на python делает вид, что понимает параметр quality, и интерпретирует его также, как и во всех остальных форматах (100 - самое лучшее качество, 80 - это "для сайта" и т. д.). Но реализовано это было очень странно (см. исходник):

    qmin = info.get("qmin")
    qmax = info.get("qmax")
    if qmin is None and qmax is None:
        # ...
        quality = info.get("quality", 75)
        # ...
        qmin = max(0, min(64 - quality, 63))
        qmax = max(0, min(100 - quality, 63))

Параметры сжатия qmin и qmax вычислялись из параметра quality таким образом, что итоговая картинка AVIF была зачастую больше по размеру, чем JPG. Вылечили чтением исходного кода утилиты командной строки avifenc и взятием параметров qmin/qmax оттуда.

Сжатие AVIF работает медленно. Быстрее, чем quetzli, но всё равно недостаточно быстро, чтобы это делать «на лету», при формировании html-кода страницы. Поэтому мы его вынесли в "медленный" процесс, сразу после сжатия jpeg алгоритмом guetzli.

7. Как это всё использовать на сайте?

Проще всего встроить поддержку webp и avif методом тюнинга сервера nginX, примерно так:

    set $webp_suffix "";
    if ($http_accept ~* "webp") {
            set $webp_suffix ".webp";
    }
    location ~* \.(jpeg|jpg|png)$ {
            add_header Vary "Accept-Encoding";
            try_files $uri$webp_suffix $uri $uri/ =404;
            add_header Cache-Control public;
    }

Аналогично можно сделать и для формата AVIF. Но мы так делать не стали. Причины:

  • Разные браузеры присылают заголовок Accept по разному. Например, Safari for iOS версии 14 не присылает строку webp при обращении к странице, но присылает при загрузке изображения с этой страницы. Здесь можно посмотреть, насколько разнообразен этот зоопарк.

  • Это трудно тестировать и отлаживать: подменить заголовок Accept сложно. Непросто даже в логах на сервере увидеть, какое именно изображение было выдано конкретному браузеру.

Поэтому встроили это "классическим" способом:

<picture>
  <source type="image/avif"
      srcset="/m/.../200x200-10.avif 1x, /m/…/200x200-20.avif 2x">
  <source type="image/webp"
      srcset="/m/.../200x200-10.webp 1x, /m/…/200x200-20.webp 2x">
  <img src="/m/.../200x200-10.jpg"
      srcset="/m/.../200x200-10.jpg 1x, /m/…/200x200-20.jpg 2x"
      width="200" height="200">
</picture>

Разумеется, в нескольких местах поломалась вёрстка из-за правил:

.some-class > img {
    ...
}

На некоторых изображениях получается, что файлы webp или avif по размеру больше, чем файлы jpg. Для них совершенно корректно работает вот такая оптимизация:

<picture>
  <source type="image/avif"
      srcset="/m/.../200x200-10.jpg 1x, /m/…/200x200-20.avif 2x">
      <!--                     ^^^^                           -->
  <source type="image/webp" 
      srcset="/m/.../200x200-10.webp 1x, /m/…/200x200-20.jpg 2x">
      <!--                                              ^^^^  -->
  <img src="/m/.../200x200-10.jpg" 
      srcset="/m/.../200x200-10.jpg 1x, /m/…/200x200-20.jpg 2x"
                width="200" height="200">
</picture>

Все браузеры нормально обрабатывают ситуацию, когда им обещали дать изображение формата WEBP, а реально дали обычный JPEG.

Итоги:

  • Внедрение webp дало дополнительные 15% уменьшения размера изображений. Avif даёт больше, примерно 30%. При этом формируемый html-код и таблицы стилей немного потяжелели, примерно на 5%.

  • На многих файлах webp и avif дают совсем небольшую экономию по сравнению с guetzli, а иногда просто занимают больше места, чем jpeg. Чаще всего это можно наблюдать на на маленьких изображениях (200х200 px).

  • Если не внедрять сжатие guetzli, а сразу встроить webp и avif, то экономия более заметна. Но полностью уйти от jpeg всё равно пока не получается: браузеры, поисковые машины, превью-боты работают именно с этим форматом.

  • Необходимости внедрять webp и avif для 100% страниц сайта при наличии guetzli нет. Если одно-два изображения отдаются "по старинке", без тега picture, это настолько мало сказывается на общем времени загрузки, что можно задушить внутреннего перфекциониста и отложить 100% внедрение на потом.

Увидели ли мы ускорение работы сайта при переходе на webp/avif на Яндекс.Метрике? Да, метрика "время до загрузки DOM" уменьшилась примерно на 15%.

Увидели ли мы рост посещаемости сайта или повышение конверсии от уменьшения веса страниц? Нет.

О компании GrinDin: мы с 2011 года занимаемся доставкой здорового питания по Москве и Московской области.

Комментарии (19)


  1. anayks
    02.01.2023 17:08
    +2

    А как давно была сделана эта работа?

    Как я слышал и сталкивался, поисковый движок того же яндекса или гугла, даже несмотря на большие изменения или оптимизацию сайта, далеко не сразу реагирует

    Быть может, через 2-3 недели эффект немного «аукнется»?


    1. vlad_shabanov Автор
      02.01.2023 17:15
      +1

      Guetzli внедрили в 2017 году. Тогда оно никак не сказалось на посещаемости.

      Webp и avif внедрили в ноябре 2022. Получить измеримый прирост конверсии на такой технической оптимизации вряд ли возможно. Да и на ранжирование в поисковиках это, скорее всего не повлияет.

      Мы это делаем в формате "не увеличивать долг". Добавили вещь, которая утяжеляет сайт на X, запомнили, что надо это X чем-нибудь скомпенсировать. Потом устроили субботник и скомпенсировали.


  1. borovinskiy
    02.01.2023 17:32

    У меня в электронной библиотеке тоже есть задача динамического сжатия PDF-страниц и обложек ресурсов.
    Обложки выводятся React, вначале подставляется URL на кешированное значение обложки, если его нет, срабатывает fallback на динамическую генерацию нужной ширины. Сгенерированные обложки кладутся в кеш и в следующий раз обращение попадет в кеш и отдастся напрямую nginx.

    Из форматов если исходный SVG, то отдается SVG. Если исходник не в SVG, то если браузер поддерживает, отдается webp, а если не поддерживает, то PNG или JPG в зависимости от того, в каком формате исходник (PNG на случай если в исходном PNG есть прозрачность). Про AVIF думал, но пока долго динамически генерировать, не хочется, чтобы на сервер слишком большая нагрузка пришлась.

    По поводу сжатия, у меня WebP дает те самые 20-25% что гугл и обещал по сравнению с JPEG при, как правило, лучшем качестве.

    Про devicePixelRatio не понял в чем проблема прочитать его из JS и выставить правильный URL. srcset я не использую именно из-за fallback при промахе мимо кеша: при промахе надо динамически подставить другой URL и в это время и devicePixelRatio известен.

    Также есть забавный момент, что реализован собственный аналог loading="lazy" так как он лениво загружает когда изображение выходит за пределы экрана по вертикали, а когда по горизонтали (горизонтальный скролл), то все равно грузит. Ну и не очень устраивало в какой момент браузер решает подгрузить изображение.

    AVIF пока не использую так как слишком большая нагрузка и у меня обложки в кеш не на все время попадают, а чистятся если место под кеш закончится (первым удаляются обложки к ресурсам, которые давно не открывались). Думаю как-нибудь стоит потыкать в видюхи Intel ARC, может они смогут аппаратно генерить AVIF через аппаратный кодировщик?

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


    1. vlad_shabanov Автор
      02.01.2023 18:40

      У нас используется server side rendering. Поэтому разрешение дисплея надо знать до того, как содержимое уедет на клиента. Ради этого раньше была вся возня с cookie, в которой сохраняли pixel ratio. И вдвое больше вариантов кэшируемой страницы.

      "Навар" от webp при обычных jpeg (сформированных libjpeg-turbo), конечно, выше. Guetzli динамически подбирает параметры cжатия jpeg так, чтобы потери были в тех местах, где они незаметны. Поэтому у Вас 25%, а у нас 15% – мы 10 процентных пунктов получили на guetzli.

      А про расход CPU да, непреятно. Это у нас CPU условно бесплатное, т.к. сервера наши собственные, ядер и памяти много, счёт за электричество – константа. А вот если на ходу генерировать, да ещё и места нет для AVIF под все 100% изображений, то проблема.


      1. borovinskiy
        02.01.2023 19:19

        А какую-то часть кода из SSR исключить нельзя, чтоб она так и передалась как <script>..?

        Вообще с бесплатностью CPU история более сложная: если на сервере рендеринг утилизирует все ядра, значит отвечать сервер на другие (конкурентные) запросы будет медленнее и начнут падать показатели по скорости загрузки страниц.

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


        1. vlad_shabanov Автор
          02.01.2023 19:23

          Можно, но браузер сейчас может грузить картинки параллельно с загрузкой javascript, причём это окончательные картинки (js их ни на что не поменяет). А так получаются строго две стадии: грузим страницу, потом грузим js и только потом грузим картинки. Или, что ещё хуже – грузим страницу со ссылками на одни картинки, браузер пошёл уже какие-то из них качать, а тут мы решили, что умнее, и подменили ему картинки на другие.

          Кроме того, с SSR столько проблем, что лучше не усложнять.


      1. borovinskiy
        02.01.2023 19:34

        Вспомнил еще один хинт с обложками: если быстро крутится полоса прокрутки, то обложки грузятся не сразу, а после задержки на тот случай, если их уже прокрутили через экран, то не начинать загрузку.

        Ну собственно, это одна из причин реализации собственного loading="lazy".


        1. vlad_shabanov Автор
          02.01.2023 20:09

          Не боитесь получить кусок страницы с заглушкой вместо картинки? Это ведь надо как-то по особому отслеживать, даже с ходу не придумать как именно.

          Был когда-то инцидент – разваливалась вёрстка в android browser 4.4 (возможно, до сих пор так и разваливается, не проверял, т.к. это уже музейный экспонат). Можно было узнать, что вёрстка развалилась, по вычисленной ширине страницы. Она была сильно больше, чем надо. При обнаружении можно было послать соотв. сигнал на сервер и посчитать, сколько таких в сутки имеем. Если меньше 10, то ничего не делаем :)

          А тут просто не было, например, атрибута src, браузер и не знал, что надо что-то качать. Но размеры обложки фиксированные, поэтому ничего не поползло. Как узнать, что посетитель увидел дырку?


          1. borovinskiy
            02.01.2023 20:46

            Ну отслеживать не сложно. Координаты изображения можно узнать от верха экрана. Высота и ширина экрана известны. Математика уровня 5 класса.

            Тут надо пояснить проблему и почему именно такое решение. Вот пользователь схватил скролл и проскролил одним рывком 100 обложек. При классическом подходе или с loading="lazy" попав в вьюпорт они начнут все отображаться. Но встанут в очередь и будет единовременно в процессе загрузки не более 6 (так как столько потоков по умолчанию в браузерах). И все, пока все эти 100 не загрузятся по 6 штук, 101 обложка, до которой домотал пользователь, грузиться не будет. А сколько это времени займет? Ну если одна обложка по 50к, то 5 Мб пока не скачается...

            То есть если пользователь резко крутанул скролл, он и должен увидеть заглушку (если она предполагается на время загрузки) и лишь когда перестанет быстро мотать заглушки постепенно (по 6 шт) сменятся обложками.


            1. vlad_shabanov Автор
              02.01.2023 21:53

              Я, похоже, кусок своей мысли проглотил и предыдущий комментарий получился непонятным. Я имел в виду "получить кусок страницы с заглушкой вместо картинки, если что-то пошло не так". Событие не пришло, последовательность действий какая-нибудь неочевидная и т. д.

              То есть, если всё хорошо, то да, ленивый скролл хорошо оптимизирует трафик. А вот если не все 100% сценариев учтены, то как узнать, что у кого-то так и остались заглушки?


              1. borovinskiy
                02.01.2023 22:42

                Ну я пока сталкивался только с невозможностью автоматической генерации обложки (не может изображение из PDF извлечь, не может отресайзить, кончилось таки место в кеше и т.д.) ну и просто в логи попадает ошибка и команда с помощью которой обложку пытались сгенерировать.

                А на фронте особо нечему ломаться, главное оттестировать непосредственно сам алгоритм ленивой загрузки с fallback при ошибке.


  1. LabEG
    02.01.2023 22:15
    +1

    Мы то же оптимизируем.

    https://habr.com/ru/company/ru_mts/blog/645305/

    Только механизм чуть более хитрый. В дом ничего лишнего не генерирует. Но картинку запрашиваем в зависимости от размера экрана. В итоге выигрыш до 10 раз доходит по размеру картинок на странице.


    1. vlad_shabanov Автор
      02.01.2023 22:57
      +1

      Да, это здорово. Мы не стали в этом направлении идти из-за SSR. А заранее знать, что браузер умеет, а чего нет, невозможно. Точнее, можно вести коллекцию user agent и (почти) не ошибаться, но это не наш метод.

      А 10 раз, это, наверное, от исходного изображения. А мы за точку отсчёта брали уже масштабированные картинки при помощи libjpeg-turbo.


  1. borovinskiy
    02.01.2023 22:16

    Раз пошла речь за avif, то у кого CentOS8, вот репозитарий с libavif-tools (avifenc), чтоб руками не собирать: https://elibsystem.ru/node/641


  1. SaemonZixel
    02.01.2023 22:27

    Скачал патч, наложил и перекомпилил библиотеку. Буду теперь экспериментировать с webp на своём сайте. Спасибо)


    1. vlad_shabanov Автор
      02.01.2023 22:57

      Отрадно видеть, что не мы одни бьёмся с "проклятием python 2.7" :)


  1. YuryB
    03.01.2023 17:16
    +2

    есть хорошая статья, про web, avif, jpeg. мораль проста: webp не так хорош, как о нём нарассказывал google, при условии что вы сжимаете jpeg специальным оптимизированным кодеком (и это не обязательно guetzli, я тестил MozJPEG из статьи и он работает мгновенно) ну и вам не нужна прозрачность, на шакальном качестве на сколько помню webp немного лучше. по этому мозилла так долго не включала у себя его поддержку. кстати есть ещё момент: а как быстро отрисовываются картинки на клиенте с разных кодеков, jpeg тут наверняка самый оптимизированный, возможно для телефона это будет иметь какой-то смысл

    https://medium.com/@inna_netum/действительно-ли-webp-лучше-jpeg-91639d852035


    1. vlad_shabanov Автор
      04.01.2023 01:03

      Да, очень быстро, буквально, на третьей-четвёртой картинке мы напоролись на то, что webp даёт больший по размеру файл, чем guetzli. Не стали бы заморачиваться вообще, но понадобилась прозрачность вот для этой страницы.

      А про скорость отрисовки даже думать не хочется... Помимо времени работы кодека имеет ведь значение и количество задействованных кодеков. Одно отдать в jpeg, другое – в webp, а третье – в avif, и у телефона уже есть много разнообразной работы.


    1. vlad_shabanov Автор
      05.01.2023 13:15

      Есть ещё один момент. Webp разные участки картинки сжимает по-разному. Я когда тестировал на вот этом изображении, считал, сколько прожилок у листочков видно. Получалось, что при том же размере файла листочки были чуть-чуть "прожилистей". А avif в режиме 4:4:4, когда оно по размеру немного больше, чем jpeg, – совсем хороши. То есть, внутренний маньяк-перфекционист может быть доволен при переходе на новые форматы даже без экономии места.