Svoboda | Graniru | BBC Russia | Golosameriki | Facebook
BBC Russian
VK
Building the Internet

Анимация в браузерах и как с ней работать

BBC RussianMedium
BBC Russian10 min
BBC Russian5K

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

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

Меня зовут Сергей Чикуёнок. Я ведущий разработчик в ОК. В этом материале я расскажу об основных этапах работы с анимацией для браузеров, ключевых сложностях и вариантах их нативной оптимизации. 

Event loop и его структура

Думаю, многим знакома концепция Event loop в браузерах и JS-движках. Если объяснять очень просто: Event loop — диспетчер задач, который занимается их регистрацией и исполнением. По сути он:

  • реагирует на события (пользовательские и системные);

  • создает задачу на исполнение события;

  • ставит задачу в очередь;

  • контролирует последовательное выполнение задач из очереди.

Структура задачи в браузере выглядит примерно так:

  • task — тело задачи, например, коллбэк-функция на событие click;

  • microtask — очередь микрозадач, зарегистрированных через queueMicrotask, Promise.resolve() и т. д.;

  • requestAnimationFrame — вызов коллбэков, зарегистрированных в requestAnimationFrame внутри текущей задачи;

  • Layout/reflow — пересчёт геометрии DOM-элементов;

  • Paint — отрисовка DOM-элементов.

С первыми тремя пунктами всё достаточно просто — это выполнение пользовательского кода, который мы, как разработчики, полностью контролируем. Последние два пункта — это реакция браузера на изменения в DOM (если они были) и, кажется, мы на это особо не можем повлиять. Или можем?

Неочевидные моменты в работе Layout/reflow

Layout/reflow отвечает за расчет геометрии (размер, позиция) элементов на странице. Например, мы меняем CSS-свойство width у элемента: браузеру нужно посчитать, какой будет ширина и высота элемента. А учитывая, что элемент может находиться внутри потока, нужно также посчитать размеры соседних, дочерних и родительских элементов. То есть reflow потенциально может быть очень тяжёлой операцией. Более того, в течение одного таска reflow может вызываться несколько раз!

Именно поэтому на многих ресурсах рекомендуют сократить количество reflow, чтобы улучшить производительность сайта. Но чтобы понять, как их можно сократить, нужно сначала ответить на вопрос: в какой момент происходит reflow? Кто-то говорит, что reflow происходит после каждого изменения свойства геометрии, кто-то — после каждого чтения. И оба этих ответа неправильные. Давайте рассмотрим на конкретном примере.

Представим, что у нас есть страница, на которой всем параграфам нужно указать ширину в 50% и получить их размеры. 

На первый взгляд, решение простое и имеет следующий вид.

const elems = document.querySelectorAll('p');
const rects = [];

for (const elem of elems) {
    elem.style.width = '50%';
    rects.push(elem.getBoundingClientRect());
}

У этого, казалось бы, простого примера есть серьёзная проблема с производительностью: reflow будет вызываться на каждую итерацию цикла. Всё дело в том, что reflow вызывается на чтение геометрии, если до него было изменение геометрии. Что и происходит в нашем примере: на каждую итерацию мы сначала меняем геометрию (elem.style.width = '50%'), а затем считываем её (elem.getBoundingClientRect()).

Более оптимальным вариантом в данном случае является разделение на два цикла:

  • в первом — меняем;

  • во втором — читаем.

const elems = document.querySelectorAll('p');
const rects = [];

for (const elem of elems) {
    elem.style.width = '50%';
}

for (const elem of elems) {
    rects.push(elem.getBoundingClientRect());
}

И вот как выглядит замер производительности для обоих примеров: 

Как видно из скриншота выше, второй пример работает в 30 раз быстрее и содержит ровно один Layout/reflow, тогда как в первом примере количество reflow будет равно количеству итераций цикла.

Современный цикл рендеринга

Когда-то давно, ещё во времена Internet Explorer, event loop фактически совпадал с циклом рендеринга браузера: он регистрировал коллбэк на событие (например, клик), выполнял его, пересчитывал геометрию элементов и отрисовывал финальную картинку, которую видит пользователь. И всё это работало в одном, основном потоке. Поэтому, если какой-то из этапов тормозил — это влияло на отзывчивость всего браузера.

В современных браузерах цикл рендеринга стал гораздо сложнее и выглядит он примерно так:

Первое, что бросается в глаза — это наличие дополнительных потоков помимо основного: Compositor и GPU. Самое главное, что стоит отметить:

  • Появилось несколько новых этапов отрисовки: Layerize, Animate, Effects.

  • Отрисовка выполняется в отдельном потоке Compositor, в некоторых случаях с использованием GPU.

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

У современного цикла рендеринга несколько преимуществ:

  • более плавный инерционный скроллинг;

  • быстрый Paint в отдельном потоке с использованием GPU;

  • новые возможности для плавной анимации и спецэффектов.

Рендеринг и анимация в отдельном потоке

Очень часто в интернете можно увидеть совет, что для анимации лучше всего использовать только определённые CSS-свойства, например, transform или opacity. И действительно, если анимировать transform вместо top/left, анимация будет выглядеть гораздо плавнее и приятнее. Но за счёт чего это получается? И, главное, есть ли какие-то побочные эффекты от такой анимации?

Для наглядности можно рассмотреть простой пример со страницей, на которой есть элемент А поверх текста.

Предположим, мы хотим запустить анимацию элемента А на клик. Это можно сделать примерно так:

<style>
.animate {
	animation: move 2s ease-in-out infinite alternate;
}

@keyframes move {
	from {
		transform: translateX(0);
	}

	to {
		transform: translateX(100px);
	}
}
</style>

<p>Lorem ipsum dolor sit amet...</p>
<div onclick="this.classList.add('animate')">A</div>

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

Так, на самом деле с элементом А происходит сразу несколько изменений:

  • Появляется свойство transform, которое браузер может оптимизировать как спецэффект на GPU.

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

Здесь важно отметить два момента.

  • Выделение композитного слоя всегда сопровождается Paint, даже если визуально ничего не поменялось. Дело в том, что браузеру нужно подготовить слои, которые он будет сводить на отдельном потоке, а для этого ему нужно получить два изображения, которые будут накладываться друг на друга. В примере выше, браузеру как минимум нужно отрисовать текст, который был скрыт элементом А.

  • Анимация выполняется полностью на композитном потоке. Как результат, она не блокируется основным потоком и не зависит от него — анимация будет продолжать работать, даже если в это время в основном потоке будут выполняться сложные расчеты.

Размер элементов и их хранение

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

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

Давайте посмотрим на пример ниже. Это PNG-картинка размером 200×200 пикселей, без прозрачности. В виде png-файла она весит 1414 байт. Но сколько она будет занимать памяти после того, как браузер её загрузит?

Это может быть неочевидным, но изображения в памяти компьютера представляют из себя массив цветов пикселей. Каждый пиксель описывается тремя байтами (RGB), и таких пикселей у нас 200×200 = 40 000. Итого вся эта картинка в памяти будет занимать 200×200×3 = 120 000 байт.

Теперь проделаем то же самое упражнение для HTML-элемента. Сколько будет весить изображение элемента, описанного следующим CSS?

<style>
.layer {
	width: 600px;
	height: 400px;
	border-radius: 5px;
	border: 1px solid #8F37FF;
	background-color: #D3B1FF;
	box-sizing: border-box;
}
</style>

<div class="layer">A</div>

В примере выше нужно учитывать следующее:

  • У элемента есть скругленные углы, то есть используется прозрачность. Поэтому для описания цвета пикселя используется дополнительный компонент прозрачности, а значит каждый пиксель описывается четырьмя байтами (RGBA).

  • Если элемент отрисовывается на экране с высокой плотностью пикселей, нужно нарисовать изображение бòльшего размера.

Итого вес изображения для такого элемента будет:

const width = 600;
const height = 400;
const dpi = window.devicePixelRatio; // 2
const rgba = 4; // есть прозрачность

const size = (width * dpi) * (height * dpi) * rgba;
// = 3 840 000 байт

То есть всего один небольшой элемент весит почти 4 МБ. Это может быть критично для мобильных устройств, где объем видеопамяти может быть очень сильно ограничен.

Почему текстура композитного слоя хранится в виде массива пикселей? Неужели нельзя хранить текстуру в виде PNG-изображения и сократить потребление памяти в десятки раз?

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

В GPU используются пиксельные шейдеры: это программы, которые выполняются для каждого пикселя экрана, чтобы определить его цвет. И эти программы должны выполняться очень быстро, как минимум 60 раз в секунду, чтобы достичь скорости в 60 FPS. Если бы мы отдавали пиксельному шейдеру картинку в виде PNG, ему на каждый вызов приходилось бы заново распаковывать изображение, чтобы получить цвет конкретного пикселя. Поэтому изображение хранится в виде массива пикселей, чтобы можно было максимально быстро получить цвет нужного пикселя:

const texture = {
	width: 400,
	height: 300,
	stride: 3, // RGB
	data: [0, 12, 58, 3, 57, ...]
}

function getPixelColor(x, y) {
	const offset = (x + y * texture.width) * texture.stride;
	return [
		texture.data[offset],     // R
		texture.data[offset + 1], // G
		texture.data[offset + 2], // B
	];
}

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

Браузерные оптимизации композитных слоев

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

Давайте также попробуем посчитать, сколько будет весить текстура для композитного слоя со следующим описанием:

<style>
.layer {
	width: 100px;
	height: 100px;
	background: #D3B1FF;
}
</style>

<div class="layer"></div>

По стандартной методике подсчета он должен весить 30 000 байт (100px × 100px × 3 RGB). Или ×2 для экранов с высокой плотностью пикселей.

Но, если посмотреть вкладку Layers в Safari, можно увидеть, что слой весит 0 байт. 

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

const texture = {
	width: 100,
	height: 100,
	color: [211, 177, 255]
}

function getPixelColor(x, y) {
	return texture.color;
}

Неявная композиция

Вернёмся к нашему примеру с анимацией элемента А и немного усложним его: добавим элемент В, который по z-index будет находиться выше элемента А.

<style>
.a {
	z-index: 1;
}

.b {
	z-index: 2;
}

.animate {
	animation: move 2s ease-in-out infinite alternate;
}

@keyframes move {
	from {
		transform: translateX(0);
	}

	to {
		transform: translateX(100px);
	}
}
</style>

<p>Lorem ipsum dolor sit amet...</p>
<div class="a" onclick="this.classList.add('animate')">A</div>
<div class="b">B</div>

Мы помним, что для красивой и плавной анимации браузер вынесет элемент А на композитный слой и дальше будет анимировать его и сводить в финальное изображение в отдельном потоке. Но как добиться правильного изображения, если элемент А находится на отдельном слое, который должен перекрываться элементом B? Совершенно верно: элемент B также нужно перенести на отдельный композитный слой!

И вот мы добрались до самой коварной и неочевидной проблемы таких анимаций — неявная композиция. Это ситуация, при которой браузер вынужден выносить на отдельный композитный слой элементы, которые сами не участвуют в анимации, но могут оказаться поверх анимируемого элемента. В свою очередь, это может привести к layer explosion: чрезмерное создание композитных слоев и исчерпание доступной видеопамяти, что как минимум приведет к огромным тормозам, а как максимум — крэшу браузера. 

Советы и рекомендации

Давайте теперь кратко сформулируем всё то, что мы узнали.

  • Красивые и плавные анимации в браузере получаются за счёт использования дополнительных потоков, которые не блокируются основным.

  • Плавность достигается за счёт субпиксельного сглаживания, которое можно «дёшево» получить на GPU.

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

  • Чтобы элемент анимировался через композитный слой, нужно менять только определённые CSS-свойства.

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

Понимая, как работают такие анимации, можно сформулировать ряд рекомендаций.

  • По возможности нужно анимировать только определенные CSS-свойства: transform, opacity, filter, backdrop-filter. Они могут применяться как эффекты на GPU без вызова reflow и repaint.

  • Анимируйте через CSS Animation/Transition или Element.animate(). Это позволит вынести анимацию в отдельный поток, независимый от Main thread, и заранее оптимизировать элементы, в которых могут быть изменения.

  • Создавайте слои заранее через will-change, но не переусердствуйте. Добавление свойств элементов в динамике сопровождается созданием композитных слоев, а значит, произойдет Paint, и анимация может запуститься с ощутимым лагом. Если вы знаете, что какой-то элемент будет анимироваться, вы можете заранее вынести его на композитный слой с помощью CSS-свойства will-change. Но надо помнить — чем больше слоев, тем больше потребление памяти. Поэтому выделение слоев должно быть оправдано.

  • Выносите анимацию как можно выше по z-index. Это позволит сократить или вовсе избежать создания неявных композитных слоев (layer explosion).

  • Следите за количеством и размером слоев. Всегда следите через вкладку Layers в DevTools за количеством и размером создаваемых композитных слоёв. Это позволит на раннем этапе внести изменения в верстку страницы и избежать проблем с потреблением ресурсов. Также в этой вкладке вы сможете увидеть и причину создания композитного слоя.

  • Следите за Layout/reflow. Разделяйте циклы чтения и изменения геометрии. Это поможет сократить время выполнения операций. Изменения лучше всего выполнять в requestAnimationFrame — в таком случае анимация будет перенесена в конец таска, что сократит риск запуска reflow из-за чтения геометрии до внесения изменений.

  • По возможности отделяйте одноцветную заливку в отдельный слой. Если вам нужно анимировать элемент, где большую часть текстуры занимает одноцветный фон, постарайтесь выделить его в отдельный композитный слой (например, попап и одноцветное затемнение страницы). В этом случае браузер сможет оптимизировать одноцветный слой и не создавать для него большую текстуру, там самым сократит потребление памяти.

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

    .layer {
    	width: 200px;
    	height: 100px;
    }
    
    /* можно записать так: */
    .layer {
    	width: 100px;
    	height: 50px;
    	transform: scale(2);
    }

    В первом случае размер текстуры будет 200px × 100px × 3 = 60 000 байт, а во втором — 200px × 50px × 3 = 15 000 байт. Но этот трюк подходит не всегда — на выходе после масштабирования получается размытая текстура, что не всегда допустимо.

Tags:
Hubs:
BBC Russian+43
BBC Russian8

Articles

Information

Website
vk.com
Registered
Founded
Employees
5,001–10,000 employees
Location
Россия
Representative
Миша Берггрен