В трендах дизайна уже давно засели красивые анимации. UI-дизайнеры делают выверенные «карусели», загрузки, анимации меню и другие украшения, а frontend разработчики переводят их в код. Но сайт должен не только хорошо выглядеть, но и быстро работать.


Современный «фронтенд» должен оптимизировать свой код. Особенно это актуально для продуктов, у которых большая часть аудитории переходит на сайт с мобильных устройств. Некоторые методы анимации лагают даже в «Хроме» на топовых компьютерах, а должны плавно работать на среднем смартфоне.


Наши разработчики пользовались большим количеством приемов, которые помогли оптимизировать сайт и ускорить его работу. Я собрал 4 самых интересных из них. Мы делимся знаниями, которые пригодятся новичкам и профессионалам, а также даем ссылки на полезные туториалы.



1. Анимация на SCSS


На сайте много анимации. Нам нужно, чтобы браузер проигрывал ее со стабильным фреймрейтом 60 fps. На чистом CSS это сделать сложно, поэтому мы пользуемся SCSS.


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


Первое и главное условие при работе с CSS-анимацией — это использование только свойств transform и opacity. Браузеры умеют самостоятельно оптимизировать анимацию этих свойств и выдавать стабильные 60 fps. Однако, с помощью одного @keyframes невозможно написать разную анимацию для разных элементов, а анимировать каждый элемент индивидуально на чистом CSS слишком трудоемко. Как быстро написать нужную нам анимацию? Мы выбрали SCSS, диалект SASS — более функциональное расширение CSS.


Разберем использование SCSS на примере работы нашего вертикального слайдера.



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


<div class="b-vertical-carousel-slider">
    <div class="vertical-carousel-slider-wrapper slider-items">
        <div class="vertical-carousel-slider-item"></div>
        <div class="vertical-carousel-slider-item"></div>
        <div class="vertical-carousel-slider-item"></div>
        <div class="vertical-carousel-slider-item"></div>   
        <div class="vertical-carousel-slider-item"></div>
    </div>
</div>

Мы убираем видимость элементов контейнера, которые будут выходить за его пределы и задаем высоту блокам.


.b-vertical-carousel-slider {
    position: relative;
    overflow: hidden;
    height: $itemHeight * 3;

    .vertical-carousel-slider-item {
        height: $itemHeight;
    }
}

Расчет анимации меняется, только если меняется количество элементов в карусели. Далее мы пишем миксин, который принимает один параметр на вход — $itemCount


@mixin verticalSlideAnimation($itemCount) {

}

В миксине генерируем keyframe для каждого элемента, задаем ему начальное состояние и с помощью :nth-child определяем элементу анимацию.


for $i from  * 1 through $itemCount {
    $animationName: carousel-item-#{$itemCount}-#{$i};

    @keyframes #{$animationName} {
        0% {
            transform: translate3d(0, 0, 0) scale(.95);
        }
    }

    .vertical-carousel-slider-item:nchild(#{$i}) {
        animation: $stepDuration * $itemCount $animation ease infinite;
    }
}

Дальше при анимации мы будем перемещать элементы только по оси y и менять scale для элемента в центре.


Состояния элемента карусели:


  1. Покой
  2. Смещение по оси y
  3. Смещение по оси y с увеличением
  4. Смещение по оси y с уменьшением

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


@keyframes #{$animationName} {
  0% {
    transform: translate3d(0, 0, 0) scale(.95);
  }

  @for $j from 0 through $itemCount {
    $isFocusedStep: $i == $j + 2;
    $isNotPrevStep: $i != $j + 1;
    $offset: 100% / $itemCount * ($animationTime / $stepDuration);

    @if ($isFocusedStep) {
      #{getPercentForStep($j, $itemCount, $offset)} {
        transform: getTranslate($j - 1) scale(.95);
      }

      #{getPercentForStep($j, $itemCount)} {
        transform: getTranslate($j) scale(1);
      }

      #{getPercentForStep($j + 1, $itemCount, $offset)} {
        transform: getTranslate($j) scale(1);
      }

      #{getPercentForStep($j + 1, $itemCount)} {
        transform: getTranslate($j + 1) scale(.95);
      }
    } @else if ($isNotPrevStep) {
      #{getPercentForStep($j, $itemCount, $offset)} {
        transform: getTranslate($j - 1) scale(.95);
      }

      #{getPercentForStep($j, $itemCount)} {
        transform: getTranslate($j) scale(.95);
      }
    }
  }
}

Здесь осталось определить некоторые переменные и функции:


  • $animationTime — время анимации движения
  • $stepDuration — общее время выполнения одного шага анимации ($animationTime + время покоя карусели)
  • getPercentForStep($step, $itemCount, $offset) — функция, возвращающая  в процентах крайнюю точку одного из состояний.
  • getTranslate($step) — возвращает translate в зависимости от шага анимации

Примерные имплементации функций:


@function getPercentForStep($step, $count, $offset: 0) {
  @return 100% * $step / $count - $offset;
}

@function getTranslate($step) {
  @return translate3d(0, -100% * $step, 0);
}

У нас есть работающая карусель с увеличивающимся в середине элементом. Осталось сделать тень под увеличивающимся элементов. Первоначально каждый элемент карусели имел псевдоэлемент :after, который в свою очередь имел тень. Чтобы не анимировать свойство shadow, мы использовали для него свойство opacity, т.е. просто показывали и скрывали тень.


Но в новой реализации для такого решения нужно сгенерировать много дополнительных кейфреймов для каждого псевдоэлемента. Мы решили поступить проще: блок с тенью будет один и он будет занимать пространство ровно под средним элементом карусели.


Добавляем div, отвечающий за тень.


<div class="b-vertical-carousel-slider">
    <div class="vertical-carousel-slider-wrapper">
        <div class="vertical-carousel-slider-item"></div>
        <div class="vertical-carousel-slider-item"></div>
        <div class="vertical-carousel-slider-item"></div>
        <div class="vertical-carousel-slider-item"></div>
        <div class="vertical-carousel-slider-item"></div>
    </div>

    <div class="vertical-carousel-slider-shadow"></div>
</div>

Cтилизуем его и добавляем анимации.


@keyframes shadowAnimation {
  0% {
    opacity: 1;
  }
  80% {
    opacity: 1;
  }
  90% {
    opacity: 0;
  }
  100% {
    opacity: 1;
  }
}

.vertical-carousel-slider-shadow {
    top: $itemHeight;
    left: 0;
    right: 0;
    height: $itemHeight;

    animation: $stepDuration shadowAnimation ease infinite;
}

В этом случае не нужно генерировать и придумывать способ анимации тени под каждым элементов, мы анимируем лишь один блок, он имеет два состояния — виден и скрыт


В итоге у нас есть карусель на чистом CSS, которая анимируется только хорошо оптимизируемыми свойствами. Это позволяет браузеру использовать аппаратное ускорение для рендеринга. Отсюда ощутимый профит по сравнению с JS-анимацией:


  1. Во время скролинга страницы с анимацией на слабых устройствах в JS-анимации явно пропускались кадры, «роняя» FPS до 15-20. CSS-анимация явно улучшила состояние дел. На этих же устройствах этот показатель составлял минимум 50-55 FPS.
  2. Мы избавились от работы стороннего модуля там, где это не требовалось.
  3. Анимация будет проигрываться даже при отключенном JS

JS-анимация должна использоваться, если нужен строгий контроль над каждым кадром: пауза, реверсивное воспроизведение, перемотка, реакция на пользовательские действия. В остальных случаях рекомендуем пользоваться чистым CSS.


Полезные ссылки



2. Использование Intersection Observer API


Анимация, которую не видит посетитель сайта, проигрывается где-то вне поля зрения и нагружает CPU. С помощью Intersection Observer мы определяем, какую анимацию видно на экране прямо сейчас, и проигрываем только ее.


Все приемы из прошлого пункта можно комбинировать с Intersection Observer. Этот инструмент помогает не нагружать браузер анимацией, которую посетитель сайта не видит. Раньше, чтобы понять, смотрит ли посетитель на анимированный элемент, использовали ресурсоемкие «слушатели» событий и это не давало сильного «выхлопа». Разница между использованием анимации вне viewport и использованием «слушателей» была минимальной. Intersection Observer API требует меньше ресурсов и помогает проигрывать только ту анимацию, которую видно посетителю.


На нашем сайте анимация активируется только при появлении элемента во viewport. Если бы мы этого не сделали, то страницы были бы перегружены постоянным выполнением цикличных анимаций, оставшихся за пределами видимости. Intersection Observer API позволяет следить за пересечением элемента с родителем или областью видимости документа.


Пример реализации


Для примера покажем, как оптимизировать анимацию на JS. Идея простая — анимация проигрывается, пока анимируемый элемент находится во viewport. Для реализации мы используем Intersection Observer API.


Добавим в стили обработку класса is-paused


.b-vertical-carousel-slider.is-paused {
    .vertical-carousel-slider-wrapper {
        .vertical-carousel-slider-item {
            animation-play-state: paused;
        }
    }

    .vertical-carousel-slider-shadow {
        animation-play-state: paused;
    }
}

Т.е. при появлении этого класса анимация будет поставлена на паузу.


Теперь опишем логику добавления и удаления этого класса


if (window.IntersectionObserver) {
  const el = document.querySelector('.b-vertical-carousel-slider');
    const observer = new IntersectionObserver(intersectionObserverCallback);
    observer.observe(el);
}

Здесь мы создали экземпляр IntersectionObserver, указали функцию intersectionObserverCallback, которая будет срабатывать при изменении видимости.


Теперь определим intersectionObserverCallback


function intersectionObserverCallback(entries){
    if (entries[0].intersectionRatio === undefined) {
        return;
    }

    helperDOM.toggleClass(el, 'is-paused', entries[0].intersectionRatio <= 0);
};

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


Полезные ссылки



3. Рендеринг SVG


Большое количество изображений или использование спрайтов вызывает фризы и лаги в первые секунды после загрузки. Встраивание SVG в код страницы помогает визуально сделать загрузку более плавной.


Когда мы подбирали методы работы с изображениями, у нас было 2 варианта оптимизации: встраивание SVG в HTML или использование спрайтов. Мы остановились на встраивании. Мы вставляем XML-код каждого изображения прямо в HTML-код страниц. Это немного увеличивает их размер, зато SVG подается inline, сразу с документом.


Многие разработчики продолжают пользоваться SVG-спрайтами. В чем суть метода: массив изображений (например, иконки), собираются в большое изображение-полотно, которое и называется спрайтом. Когда нужно показать конкретную иконку, вызывается спрайт, после чего даются координаты определенного куска, на котором оно находится. Так делали давно, еще на первой версии HTTP. Спрайты помогали агрессивно кэшировать файл и уменьшить количество запросов на сервер. Это было важно, потому что много одновременных запросов тормозили браузер. Использование SVG-спрайтов — это типичный костыль, с которым вы пренебрегаете логикой работы ради экономии ресурсов. Сейчас количество запросов не так важно, поэтому мы рекомендуем встраивание.


В первую очередь оно положительно влияет на производительность с точки зрения посетителя. Он видит, как иконки моментально загружаются и не страдает первые секунды после загрузки страницы. При использовании спрайта или PNG-изображений страница, которая грузится, немного подтормаживает. Особенно сильно это ощущается, если посетитель сразу скроллит загруженную страницу — FPS будет падать до 5–15 на нетоповых устройствах. Встраивание SVG в HTML помогает сократить время ожидания загрузки страницы (субъективное, с точки зрения клиента) и избавиться от фризов и пропусков кадров при загрузке.


4. Кэширование с использованием Service Worker и HTTP Cache


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


Оптимизировать стоит использование не только CPU/GPU, но и сети. Мобильные устройства — это ограничение не только по ресурсам, но и по скорости интернета и трафику. Здесь нам помогло кэширование. Оно позволяет сохранить ответы на HTTP-запросы и использовать их без повторного получения ответа от сервера.


Когда мы обдумывали стратегию кэширования, то выбрали одновременное использование Service Worker и HTTP Cache. Начнем с первого и более продвинутого. Service Worker — это js-файл, который может контролировать свою страницу или файл, перехватывать и модифицировать запросы, а также программируемо кэшировать запросы. Он работает как прокси-сервер между сайтом и сервером и определяет их офлайн-поведение. Все это делается на «фронте», без подключения «бэкенда».


Service Worker имеет огромную вариативность. Мы можем программировать поведение так, как нам угодно. Например, мы знаем, что посетитель, который перешел на страницу №1, с вероятностью 90% перейдет на страницу №2. Мы просим SW фоном подгрузить вторую страницу, когда посетитель находится еще на первой. Когда он перейдет на нее, страница загрузится моментально. Его можно использовать для разных задач:


  • Фоновая синхронизация данных
  • Офлайн-работа калькуляторов
  • Кастомная шаблонизация
  • Реакция на определенное время и дату.

Файлы Service Worker можно сделать в разных сервисах. Мы рекомендуем Workbox. Он достаточно простой и позволяет создавать свод правил, по которым ведется кэширование, например, precache.


Service worker поддерживают не все браузеры, например, IE, Safari или Chrome до версии 40.0. Если устройство не может с ним работать, оно выполняет правила кэширования HTTP Cache. Мы добавили страницам следующие HTTP заголовки:


cache-control: no-cache
last-modified: Mon, 06 May 2019 04:26:29 GMT

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


if-modified-since: Mon, 06 May 2019 04:26:29 GMT

В случае, если изменений не произошло, браузер получает ответ с кодом 304 Not modified и использует контент, сохраненный в кэше. Если в документ внесли изменения, ответ возвращается с кодом 200 и в хранилище браузера пишется новый ответ.


Чуть позже мы изменили метод кэширования и проверяли хеш-сумму в названии файла. Она гарантирует, что ресурс будет сохранять уникальность. Поэтому мы могли агрессивно кэшировать контент. В ответ добавлялся такой заголовок:


Cache-control:max-age=31536000, immutable

Max-age указывает максимальное время кеширование, в нашем случае оно равно 1 году. Immutable значение говорит о том, что такой ответ не нуждается в проверке на изменения.


Полезные ссылки



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


Приглашаем в нашу команду


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


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


  1. Carduelis
    17.06.2019 16:34

    Астрологи объявили неделю "Оптимизариуса Фронтендуса".


    По слайдеру: а какой объем выхлопного CSS получился? Я полагаю, вы отказались от Swiper'а, и написали свои триггеры на жесты, клавиатурные сочетания, меняющие классы? Swiper хорош тем, что оттестирован довольно широко, а как вы тестировали свой велосипед?


    Реквестирую отдельную статью по кешированию в Service Worker'ах. Это довольно интересная тема, особенно, если сделать библиотеку как легкое plug'n'play-решение.


    1. m_ePayments Автор
      19.06.2019 18:52

      Это совпадение, мы начали писать эту статью задолго до волны фронтовых статей :)

      По «свайперу»: возможно, вы неправильно поняли. Мы не отказались от него полностью, просто не использовали его там, где он не нужен. В некоторых слайдерах он работает: мы используем его там, где нужно дать возможность пользователю взаимодействовать со слайдером. Это современное и протестированное решение, которое поддерживают другие разработчики.

      По Service Worker: возможно, в будущем сделаем статью, спасибо за идею.


  1. kash
    18.06.2019 06:08

    А пробовали SVG спрайт, который инъектится в начало body (ну или можно инлайно вставлять, если кеширование не важно), с иконками в symbol, которые на месте подключатся через <use xlink:href='#id-иконки'/>? В моей практике это дает неплохую экономию размера файла (хотя, конечно, зависит от того насколько иконки сложны и как много их на странице)


    1. m_ePayments Автор
      19.06.2019 18:52

      Вы отчасти правы, это хорошее предложение. Но оно работает, только если мы отказываемся от старых версий IE, которые не умеют пользоваться тегом . Потому что иначе придется использовать JS-решения типа svg4everybody, который перемолотит страницу после загрузки.

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


  1. AndreasCag
    18.06.2019 11:33

    1. Анимация на SCSS

    Вы сделали одно из худших решений для слайдера.
    На вашем сайте 370 Кбайт стилей, и из них 130 КБайт — это ваш слайдер.
    Повторяющиеся стили GZIP конечно сожмет неплохо, но надо понимать, что их надо еще распарсить и много css-классов может создать нагрузку на манипуляции с DOM.

    4. Кэширование с использованием Service Worker и HTTP Cache

    Почему тогда на вашем сайте www.epayments.com стоит service worker, который ничего не делает?
    const skipWaiting = () => self.skipWaiting();
    const unregister = (event) => {
      event.waitUntil(self.clients.claim());
    
      self.registration.unregister()
        .then(() =>
          console.log('Unregistered old service worker'));
    };
    
    self.addEventListener('install', skipWaiting);
    self.addEventListener('activate', unregister);
    


    У вашего сайта вообще много проблем:

    1. Ваш сайт мультиязычный, но в head страницы нет ссылок на копии этой страницы с другой локализацией
    <link rel="alternate" hreflang="lang_code"... > 
    

    2. JavaScript весит 3.3 МБайта, что туда можно было такого добавить?

    3. Открыл ваш сайт и сразу увидел ошибки в консоли
    image


    1. m_ePayments Автор
      19.06.2019 18:53

      По худшему решению: не можем согласиться с таким утверждением. Вы говорите о весе, но это не самый сильный аргумент. В результате сжатия мы почти не увеличиваем объем используемой памяти, да. И у нашего решения есть серьезные преимущества:
      1. У нас одноразовая загрузка. К тому же, все потом сжимается GZIP и кэшируется.
      2. Решение работает плавно, мы выигрываем в производительности. Нам не нужно в трее держать работающий в основном потоке JS. Как я упомянул в начале статьи, это один из главных критериев.
      3. Высвобождается время для других операций.
      4. Решение работает на GPU, где это возможно. Браузер оптимизирует анимацию, потому что она работает на CSS.
      5. Парсинг DOM-элементов происходит 1 раз, мы не создаем огромное количество классов.
      6. Мы не модифицируем DOM (в отличие от «свайпера»). Это тяжелые операции, которые мы не делаем.

      Да, есть увеличение объема и первоначального рендеринга, но это незначительно. И все это становится еще незначительнее с учетом того профита, который мы получаем.

      По пустому Service Worker: да, он есть. У нас была небольшая трудность с настройкой SW, из-за которой неправильно поставлялся контент. Самое простое решение — взять и убрать Service Worker с сайта. Но это неправильно, потому что посетители, которые уже пользовались сайтом, уже подгрузили его. Он будет крутиться сам по себе всегда. Вместо этого мы подгрузили новый SW, который отписывает от старых и запускает свой и пустой. Таким решением мы закрыли проблему у клиента и решаем ее со своей стороны. Скоро выпустим актуальный и нормально работающий SW.

      По ссылке на копии: да, спасибо за внимательность. Недосмотрели в свое время, исправим.

      По размеру JS: мы работаем над оптимизацией и бандл пришлось увеличить буквально на прошлой неделе из-за нового функционала. К тому же в GZIP это далеко не 3 МБ. Скоро это будет весить поменьше.

      По ошибкам: спасибо за внимательность, проверим все и разберемся.


      1. AndreasCag
        19.06.2019 19:04

        Спасибо за ответы! Приятно, что вы уделяете этому внимание.