Как достичь производительного рендеринга в браузере
Один великий китайский философ сказал: «каждый разработчик должен понимать, как исполняется его программа». Что ж, давайте разбираться. Говорить будем про рендеринг и его производительность.
Меня зовут Глеб Михеев, я CTO Skillbox Holding, а также руководитель программного комитета FrontendConf. Уже как 19 лет работаю в коммерческой разработке. Сегодня я расскажу, как устроен браузерный конвейер поставки кадров на экран и что нужно знать каждому разработчику, чтобы его интерфейсы были отзывчивыми, не лагали, а анимации были плавными и выдавали 60fps даже при высокой вычислительной нагрузке на main thread.
Введение
Рендеринг начинается с того, что скачивается HTML и CSS. Далее они парсятся и превращаются в DOM и CSSOM. Следующим шагом они объединяются в так называемое дерево рендеринга RenderTree. Идея в том, что там отсутствуют элементы, которые не будут отрендерены, например, head, метатеги или display: none; элементы. В момент, когда это дерево готово, начинается расчёт макета: отступы, ширина, высота содержимого и общая высота блока. На выходе с этой части конвейера получается уже полностью готовая геометрия, но на ней ещё не нарисовано ни одного пикселя. Об этом следующий блок — Paint. Его главная задача — взять макет и полностью заполнить его пикселями, то есть отрисовать шрифты, залить цветами, нарисовать градиенты, тени, чтобы на выходе получить готовую текстуру. В конце, браузер передаёт её на видеокарту, чтобы вывести на экран.
Посмотрим на странички Google и DevTools, открытой на вкладке Layers:
Здесь одна большая картинка находится в памяти. Её можно подвигать, потрогать, так как она уже целиком отрендерена, включая ещё то, что находится снизу страницы вне вьюпорта.
Но вопрос: как же вносить в страницу изменения?
Слои
Самое простое изменение, которое можно сделать — это поскроллить страницу.
Так будет выглядеть DevTools на вкладке Layers после скроллинга страницы:
Страница просто чуть-чуть съехала вверх-вниз и при этом появились два тонких блока (один горизонтальный, другой вертикальный). Так браузер представляет скроллы для себя. Не смущайтесь этого.
Почему это происходит? И тут мы подошли к теме слоёв.
Посмотрим на эту же самую страницу. Я её проскроллил и сверху появился фиксированный элемент — меню.
Браузер понимает, что есть какой-то блок, который фиксирован поверх экрана. Он его выделяет в отдельный слой для того, чтобы не вносить изменения в общую текстуру. Поэтому появляются несколько слоёв. При этом на каждый кадр, когда мы скроллим страницу, слой сверху фиксирован, а страница ездит ровно под ним. Получается, что простейшее изменение положения слоёв происходит сразу на видеокарте. Причём не только от скролла.
Также есть такие свойства как transform и opacity — это матричные трансформации, которые очень выгодно и эффективно можно применять на видеокарте, чем и пользуются разработчики браузеров. Это позволяет не вызывать заново перерисовки, то есть не повторять конвейер. Изменения вносятся сразу на готовых текстурах прямо на видеокарте.
Как это дебажить
Вернёмся к нашему примеру с DevTools на вкладке Layers:
Здесь есть меню. Если нажать на него, потом на More tools и выбрать пункт Rendering, то снизу появится чек-бокс, который называется Layer Borders. Благодаря ему можно увидеть, как в реалтайме браузер работает со слоями, но уже на самой странице. Он золотистым цветом выделяет слои. В нашем случае одна большая страничка — это один большой слой. Также сверху меню, оно тоже выделено золотистым цветом, что значит, что оно является отдельным слоем.
Но ОК, хорошо, со слоями понятно: тут же достаточно всё примитивно. А что, если я хочу, например, цвет кнопки поменять или что-то ещё сделать?
Reflow
Эта штука говорит о том, что мы начинаем двигаться обратно по конвейеру.
Например, если просто поменять бэкграунд, то мы вернёмся назад на Paint. И это произойдет при изменении, например таких свойств как background-color, border-color, box-shadow и прочих свойств, которые отвечают только за отображение, но не за геометрию.
Как это работает в DevTools? Посмотрим на вкладку рендеринг, которая открыта снизу:
Здесь есть кнопка Paint Flashing, которая подсвечивает изменяющиеся элементы. Браузер показывает зелёным то, что мы сейчас поменяли и перерисовали. Это очень важная и крайне полезная штука.
Что будет, если поменять саму геометрию?
Такие свойства как width, height, padding, margin и прочие свойства, которые могут влиять на геометрию, , например, размер шрифта.
Есть один важный момент. Когда я навожусь на поисковую выдачу, перерисовывается не только сам пункт поисковой выдачи, но ещё и все элементы списка, включая даже футер!
Это очень забавное поведение. Сразу скажу, оно может вызывать большие неприятности. Если бы страничка была не 2–3 экрана вниз, а 20–30, и мы бы в ней начали что-то менять по высоте, то нас ждала бы просадка производительности и фризы экрана.
Как это отлаживать
В DevTools есть ещё одна, крайне важаная штука — вкладка Performance. Она позволяет записать все изменения, которые происходят на странице в кишочках браузера и позволяет их проанализировать.
В этой вкладке фиолетовым цветом обозначатся перерасчеты геометрии и стилей, а зелёным задачи по перерисовкам.
Чем больше вы будете использовать эту вкладку, тем лучше будете понимать, как работает браузер изнутри.
Откуда возникают лаги
Высокая производительность означает работу без лагов. Поэтому давайте поговорим о них. Что такое лаги и почему они вообще появляются?
FPS и Time Budget
Что такое FPS мы все знаем из шутеров — это количество кадров в секунду, которые у нас поставляются какой-то программой на экран, будь то игра или браузер.
Частота смены кадров
Мониторы бывают разные. Геймерские, например, выдают 120Hz, что равно 120 обновлениям изображения на экране за секунду. Есть заряженные мониторы по 240Hz. Если у нас садится ноутбук, то он вообще может упасть до 30 кадров в секунду, чтобы сэкономить батарею. Но в среднем обычные мониторы работают со скоростью 60Hz, то есть они изображение на экране 60 раз в секунду.
Поэтому мы возьмем это значение за основное и будем говорить в разрезе 60 кадров в секунду. Но бывают разные ситуации. Тот же самый Айфон легко выдаст 120Hz в хорошую погоду.
Если посмотреть соотношение секунд и Герцев, то получается, что на один кадр у нас достаточно немного времени:
1 sec / 60 Hz = 16,6 ms
Это и есть наш Time Budget. Главная задача вообще всех программ, которые занимаются выведением информации на экран, будь то игры, сайта или торговый терминал., то есть всего, что требует постоянного обновления информации — это успеть вывести за этот бюджет новый кадр. Когда мы не успеваем вывести новый кадр, то ничего не обновляется, а значит появляются просадки производительности, подёргивание. Иными словами, всё, что нас люто бесит в играх.
Рассмотрим пример сайта, который успевает вносить все изменения и поставить кадр на экран:
Это, конечно, немножко идеализированная ситуация, потому что здесь немного нагрузки, но мы явно видим, что из кадра в кадр браузер успевает проделать всю работу, и в целом, большую часть времени браузер ожидает работу
Синхронизация с поставкой кадра на экран
Браузеры, как и компьютерные игры, синхронизируются с монитором, и его циклом поставки кадра на экран, поэтому важно делать все изменения в правильный момент времени, чтобы браузер успел внести все изменения, отрисовать текстуру и успеть её передать на монитор, прежде чем наступит момент вывода конечного изображения на экран
Для нас, как для разработчиков, для синхронизации с этим циклом доступенглобальный метод window.requestAnimationFrame () , который говорит браузеру: держи блок кода и исполни его, когда ты начнёшь готовить новый кадр. Он позволяет нашему коду работать синхронно с циклом отрисовки браузера и не делать несвоевременных вещей.
Forced Reflow
То, что нужно всегда избегать — это досрочная рекалькуляция. Поясню её на примере того, как вносятся изменения.
Допустим я хочу взять блок, измерить его высоту, увеличить её на 1 пиксель и повторить это 2 раза. Тогда я напишу:
Для простоты восприятия примеров мы опустили обращение к CSSStyleDeclaration, доступному в свойстве .style
Попробуем этот код исполнить и посмотреть, как браузер будет на него реагировать.
Сначала исполнится div.offsetHeight и мы получим высоту элемента. После этого к нему прибавится 1 пиксель и результат запишется в div. style.height.
Как только это произойдет браузер подумает:
«Мне только что инвалидировали геометрию элемента, потому что поменяли одно из свойств, которое на неё влияет. Ок, когда я буду готовить следующий кадр, то обязательно пересчитаю геометрию.»
Но, следующим шагом мы же сразу просим отдать offsetHeight, то есть код написан так, что значение нужно прямо сейчас. А ведь браузер ещё не считал его! Он же отложил.
«Но, раз надо прямо сейчас, то я пойду и досрочно рассчитаю заново геометрию, чтобы дать актуальное значение»
Иными словами, браузер досрочно повторит расчёт геометрии для того, чтобы вернуть нам актуальное значение. Если изначально было 40, то браузер вернёт 41, так как мы добавили единичку. После чего мы говорим: «Спасибо, а теперь давай по новой», и заново устанавливаем инкреминтированное значение высоты и инвалидируем геометрию.
Этот очень тривиальный пример, но с ним можно столкнуться в реальной жизни.
Рассмотрим такой код:
Он генерит 200 квадратиков. Потом в цикле обходит каждый, спрашивает его размер и добавляет 1.
Его флейм-чарт во вкладке Performance выглядит так:
Она вся забита работой, хотя на экране кроме 200 маленьких квадратиков больше ничего нет. Процессор не справляется, потому что не хватает времени.
Внизу есть 199 маленьких фиолетовых штучек с красной рюшечкой сверху. Если на них навести, то можно увидеть, что форсится перерасчёт нашей геометрии. Оказывается, мы 199 раз инвалидировали геометрию, и поэтому она была рассчитана досрочно, чтобы мы по ходу исполнения кода получить актуальный размер блока.
В чём дело? Тут прикольная штука. Несмотря на то, что эти блоки не вложены друг в друга, они идут друг за другом, браузер всё равно инвалидирует всю геометрию этих блоков, так как они могут друг на друга косвенно влиять. Поэтому инвалидированная геометрия первого блока инвалидирует все последующие. Это и есть причина того, почему в примере из поисковой выдачи Google перерисовываются все последующие элементы выдачи и сам футер.
Как это исправить? Кажется, мы написали код ровно так, как хотели: из каждого блока по очереди мы брали значение и инкрементировали. Поэтому если мы не хотим сбрасывать геометрию, то должны разделить цикл чтения и цикл записи, то есть сначала у каждого блока прочитать размер, а потом каждому блоку вставить новый размер. Тогда не будет происходить инвалидации.
Вот тот же самый пример кода, где это разделено.
Я взял массив из 200 квадратиков, сначала у всех прочитал высоту, а потом следующим циклом всё выставил. Теперь всё работает прекрасно!
Менеджмент слоев
Эта штука помогает нам правильно распределить нагрузку. Она состоит из трёх пунктов:
Выделяйте изменения в слои;
Как это сделать? Есть такое свойство will-change: transform. Используя его, на видеокарте создаётся страничка с пустыми дырками под элементы, а элементы, которым мы указываем это свойство выносятся браузером в отдельные слои. И когда мы захотим перерисовать один из этих слоев, будет перерисован только сам слой. Так мы можем изолировать изменения.
Из предыдущего примера возьмём элемент, при наведении на который перерисовывается вся страница снизу, и посмотрим как он работает без этого свойства:
А теперь, мы ко всем элементам списка применим свойство will-change: transform, и после этого перерисуется только один блок.
Прибирайте слои за собой;
Представим ситуацию, что у нас есть сайт с анимацией. Мы заходим туда, начинаем скроллить, в результате чего анимируются куча элементов, слетаясь в композицию в центре экрана и после всё это висит статично. Но слои остаются разделенными и занимают место в памяти, нагружая процесс подготовки каждого кадра. Это неправильно. Чем больше слоёв на видеокарте, тем больше работы ей придётся делать, чтобы эти слои скомпоновать между собой. Поэтому если анимируется какой-то блок, текст или картинка, которые вы не планируете как-то двигать в дальнейшем, то убирайте их из слоёв. Например, им можно убрать свойства, которые вызывают трансформацию, в нашем примере это можно сделать убрав will-change: transform.
Следите за слоями на постоянной основе
Во время разработки мы должны постоянно следить за тем, что лежит в слоях, какое их количество и сколько памяти они занимают. Есть инструмент в DevTools, который называется Frame Rendering Stats. Это окошко, которое появляется в углу экрана.
Сверху показано, как прошёл рендеринг, сколько кадров в секунду успевает подготовить браузер и не потеряли-ли мы какой кадр, а снизу количество памяти на видеокарте, которое занято нашими слоями.
По-хорошему, Frame Rendering Stats надо всегда держать открытым при разработке, особенно когда мы занимаемся нагруженными с точки зрения анимации или интерфейса сайтами. Ведь он показывает, когда занятой памяти становится слишком много, что, как правило, является индикатором низкой производительности. Также сверху показано количество кадров в секунду, это тоже служит явным показателем производительности.
Выводы
Полезные и вредные советы
— Стремитесь анимировать на «конце» конвейера (на видеокарте)
Стадия компоновки очень производительна. Поэтому зачастую анимации, посчитанные на видеокарте, будут намного эффективнее, особенно, когда мы говорим о том, что при анимации слои могут ездить, вращаться и многое другое. Для работы с этим на видеокарте используется пиксельное сглаживание. Если вы сейчас проведёте эксперимент, где будете анимировать два блока, например, один свойством left, а второй transform, то увидите, что transform очень плавный и мягкий. Это потому, что он движется не по-пиксельно, а меж пикселей (применяется subpixel antialiasing — субпиксельное сглаживание).
— Измеряйте стоимость изменений (иногда дешевле перерисовать)
Выше говорилось о том, что нужно стремиться анимировать на конце конвейера. Тогда можно подумать: «хорошо, мне нужен большой блок 5000 px x 5000 px, например — круг, и я хочу, чтобы он чуть-чуть дышал по размеру, скейлился туда-сюда». Однако если на конце конвейера мы сделаем такую большую картинку и закинем её на видеокарту, чтобы поскейлить, то она начнёт лагать. Почему? А потому, что получается одна большая текстура на 25 мегапикселей. Она действительно большая. Дешевле прямо с нуля перерисовать эту картинку. В случае круга, например, достаточно просто сделать svg и каждый раз менять у него размер. Она будет отрисована с нуля быстрее чем отскейлина на видеокарте.
— Подсказывайте браузеру через will-change, что вы планируете сейчас делать
Браузер не всегда может предсказать ваше дальнейшее поведение и разделить композицию по слоям таким образом, чтобы изменения вносились наиболее оптимальным образом. Помогите браузеру, указав wiil-change: transform.— Синхронизируйте изменения с кадровым конвейером.
Для того чтобы не создавать лишней нагрузки, то есть не повторять какие-то операции впустую между кадрами, важно всегда синхронизировать с браузером действия, если это возможно. Конечно, есть ситуации, когда иногда нужно что-то сделать досрочно.
— Следите за памятью, которую занимают слои
У меня и моей команды был проект, в котором в целом не сложная страница занимала 500 мБ памяти, от чего всё сильно тормозило. Это была просто страница с какими-то рюшками-анимациями и списком из 50 вакансий, в которых находились списки требований с буллитами. Оказалось, что эти буллиты были не очень опрятно сверстаны через position: absolute. Мы начали во вкладке Layers разбираться в чём дело. Браузер написал причину, которая звучала так: may overlapping (могут наслаиваться). Во избежания этого браузер вынес каждый буллит в этих списках их в отдельные слои, что повлекло за собой трату практически 500 мБ памяти.
— Не анимируйте то, что вне вьюпорта
Это важный принцип, который нужно соблюдать. Например, по дефолту я думаю о том, что именно у меня анимируется и в какой момент времени. У меня есть свои наработки, которые умеют сами включать и выключать анимации, как только они уходят из вьюпорта. Это экономит много ресурсов, а ресурсы никогда не бывают лишними.
Ссылки
Вот ссылка на реп, где собраны полезные материалы по теме: https://github.com/glebmachine/browser-rendering-performance
Ближайшая конференция FrontendConf пройдет 24 и 25 октября 2022 года в Москве в Старт Хаб (Start Hub). Полная программа, расписание и билеты на сайте.
При бронировании и оплате билетов до 18 сентября дарим бонус — все видеозаписи закрытого плейлиста с конференции FrontendConf 2021. Торопитесь!