3 сентября

Оптимизация производительности фронтенда. Часть 2. Event loop, layout, paint, composite

Блог компании HeadHunterРазработка веб-сайтовCSSJavaScriptКлиентская оптимизация
Tutorial

Ночь. Стук в дверь. Открыть. Стоят двое. "Верите ли вы в Event loop, нашу главную браузерную цепочку?" Вздохнуть. Закрыть дверь. Лечь досыпать. До начала рабочего дня еще 4 часа. А там уже ивент лупы, лейауты и прочая радость…


В первой части мы говорили о первой загрузке и работе с ресурсами. Сегодня я расскажу о второй части оптимизации производительности фронтенда. О том, что происходит с нашей страницей, когда она загружена, на что уходит процессорное время и что с этим делать. Ключевые слова: event loop, paint \ repaint, layout \ reflow, composite.



Завязка. Вопросы для самопроверки


Если хочется сразу начать поглощать контент статьи, пропустите этот раздел


Event loop популярен не только в ежедневной работе, но и на собеседованиях. И пойди разберись, где он популярнее. Существует три вопроса, которые можно задать себе, чтобы проверить, понимаете ли вы этот браузерный механизм. Два из них — завсегдатаи собеседований, а вот третий показывает понимание event loop для своей практики. Они довольно простые, но будут расставлены по возрастанию сложности:


  1. Будет ли выведено "1" в консоль? Почему?


    function loop() {
    Promise.resolve().then(loop);   
    }
    setTimeout(() => {console.log(1)}, 0);
    loop();

  2. Есть сайт, а на сайте ссылка, у которой при наведении cursor: pointer ставится через :hover стиль CSS и кнопка, у которой также по :hover меняется background-color c серого на синий. Добавляем скрипт:


    while (true);

    Вопрос: Что будет если навести мышку на ссылку? А на кнопку? Почему?


  3. Как анимировать выпадающий элемент по height с 0 до auto? Здесь важно обсудить способы c помощью JS и/или CSS. Кстати, если гуглить этот вопрос, то stackoverflow вначале предлагает неверный ответ. Суть понимания event loop и работы браузеров сводится к тому, как замерить то самое height = auto с помощью JS.



Наша цель


Прийти к достаточно глубокому пониманию вот этой схемы:


Идти к ней будем постепенно, с подробными остановками на каждом из этапах


Event Loop


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


while (true) {
  if (execQueue.isNotEmpty()) {
     execQueue.pop().exec();
    }
}

Такой код всегда забивал ЦПУ процессоров в 100%. Что и было в старых версиях windows. Сейчас планировщики операционных систем очень сложные. Там есть и приоритизация, и исполнение, и различные очереди.


Итак. Чтобы начать, нам понадобится какой-то бесконечный цикл, который будет опрашивать, есть ли у нас задачи. Например такой:



Теперь нам нужно откуда-то эти задачи получать.
Зададим себе вопрос: что является триггером, чтобы наш JS код начал выполняться?
Это может быть:


  1. Браузер загрузил тег <script>
  2. Отложенные задачи: setTimeout, setInterval, requestIdleCallback
  3. Ответ от сервера через XmlHttpRequest, fetch и т.п.
  4. События и вызовы подписчиков через браузерное API: click, mousedown, input, blur, visibilitychange, message и так далее, множество их. Часть из них вызывается пользователем (кликнул на кнопку, альт-табнулся и т.д.).
  5. Изменение состояния промисов. В некоторых случаях, это происходит за пределами нашего JS кода
  6. Обзерверы, такие как: DOMMutationObserver, IntersectionObserver
  7. RequestAnimationFrame
  8. Что-то еще? :)

Почти все эти вызовы планируются через WebAPI (его еще иногда называют браузерным API). То есть:


  1. Мы вызываем setTimeout(function a(){}, 100)
  2. WebAPI откладывает задачу на 100мс
  3. Через 100мс, WebAPI кладет function a() в очередь (TaskQueue)
  4. Event Loop на нужный виток подбирает задачу

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


Это накладывает ограничения на отрисовку элементов. Мы не можем просто так взять и запустить 2 потока, чтобы в одном выполнялся JS, а в другом CSS и отрисовка. Это потребовало бы большого количества синхронизаций кода, либо могло бы привести к неконсистентому исполнению. Поэтому и JS, и расчет отображения элементов (расположения, цвета и т.д.) работают в одном потоке. Значит в нашем Event Loop кроме JS есть "отрисовка". Давайте поместим ее в отдельную очередь и назовем render queue:



Итак, у нас есть две точки входа. Одна для большинства JS операций, другая для отрисовки. Теперь нам нужно разобраться, что же такое SomeJSTasks и как оно работает.


Для исполнения большей части JS кода у нас есть 2 очереди:


  • TaskQueue — здесь почти все ивенты, отложенные задачи и т.п. Задача из этой очереди Task
  • MicroTaskQueue — здесь обработка промисов и MutationObserver. Из этой очереди: MicroTask

Обновление экрана


Event loop неразрывно связан с обновлением экрана. В нем исполняется не только JS код, но и рассчитываются кадры страницы.


Браузеры стараются отображать изменения на странице максимально быстро. Здесь существует ряд ограничений:


  1. Хардварные ограничения: частота обновления экрана
  2. Софтверные ограничения: настройки ОС, браузера, настройки энергосбережения и т.д.

Большинство современных устройств и настроек поддерживает 60FPS. Это число будет и у подавляющего количества браузеров. Поэтому далее в статье будем пользоваться 60FPS, но в голове держим, что это число может быть другим.


Для нашего Event Loop это означает, что на исполнение тасков отводится примерно 16.6мс (при 60FPS)


Что такое TaskQueue


Как только задачи набиваются в очередь Task, на каждом витке event loop мы достаем верхний таск и исполняем его. Если у нас остается достаточно времени, т.е. в render queue не появилось тасков, мы можем достать еще одни таск и т.д., пока не придет время для отрисовки нового кадра пользователю.


Разберем пару примеров:



У нас в очереди лежит 3 таска TaskA, TaskB, TaskC. Event Loop берет первый таск и исполняет. Он занимает 4 мс. Затем Event Loop проверяет другие очереди (Microtask queue и render queue) — они пустые. Поэтому EventLoop исполняет второй таск. Второй таск занимает еще 12 мс. Итого 16 мс. Браузер добавляет в Render queue таски на отрисовку нового кадра, Event Loop берет эти таски. Они занимают примерно 1 мс. После этого Event Loop переходит обратно исполнять таски из TaskQueue.


Event Loop не умеет предсказывать, сколько времени будет исполняться тот или иной таск. И он не умеет приостанавливать таск для того, чтобы отрендерить страницу пользователя, так как браузерный движок не может знать, можно ли отрисовать то, что изменил JS или это только подготовка элементов для отображения их пользователю. То есть: Пока исполняется синхронный JS код, изменения не будут отображены пользователю, но могут быть посчитаны.


Поэтому вот другой пример:



Здесь у нас только два таска в нашей очереди. Первый таск выполнялся 240 мс, так как 60FPS предполагает рендер кадра каждые 16.6 мс. А это означает, что мы пропустили примерно 14 кадров в эту секунду. Поэтому как только таск закончится, event loop начнет выполнять ожидающие таски из render queue, чтобы нарисовать кадр. Важный момент: то, что мы пропустили 14 кадров НЕ означает, что мы 15 раз будет рисовать кадры без остановки.


Прежде чем перейти к микротаскам, разберем такую штуку, как стек вызовов:


Стек вызовов


Стек вызовов — это список, который отражает, какие функции сейчас вызываются и куда будет совершен переход, после окончания исполнения текущей функции.


Разберем пример:


function findJinny() {
  debugger;
  console.log('It seems you get confused with universe');
}

function goToTheCave() {
  findJinny();
}
function becomeAPrince() {
  goToTheCave();  
}
function findAFriend() {
   // ¯\_(ツ)_/¯
}
function startDndGame() {
    const friends = [];
  while (friends.length < 2) {
    friends.push(findAFriend());
  }
  becomeAPrince();
}
console.log(startDndGame());

Мы запускаем этот код в консоли браузера и останавливаемся на дебаггере. Как будет выглядеть наш стек вызовов?


Мы начали наш стек из inline кода, значит самым верхом будет эта строчка. Как правило, в хром она будет указана просто ссылкой на нее. Обозначим ее inline. Дальше мы падаем в startDndGame, и вызываем несколько раз findAFriend. Однако он не попадет в итоговый callStack, потому что из всех findAFriend мы вышли. Итого наш стек вызовов будет выглядеть вот так:



Что же такое микротаски?


Микротаски ограничены. Это либо колбеки для promise, либо mutationObserver. Сама идея появления микротасок довольно костыльна, но она дает нам некоторые преимущества и недостатки по сравнению с TaskQueue.


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


Например, в случае с этим стеком вызовов:



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


Любой JS код записывается в стек вызовов, что логично. Окончание стека вызовов, по сути, и есть окончание таска или микротаска. Интересный факт: Микротаски могут порождать другие микротаски, которые будут вызываться сразу же по освобождению стека вызовов. То есть рендер страницы может откладываться бесконечно. Это главный минус микротасков:



Если у нас 4 микротаски в очереди MicrotaskQueue, то они будут исполнены друг за другом, откладывая ререндер.


Недостаток микротасок — также и их преимущество. Например, момент срабатывания MutationObserver — это когда изменения на странице произошли, но пользователь их не увидит, пока колбек mutation observer не исполнится. Таким образом, мы можем эффективно управлять контентом, который видит пользователь.


Итого, наша картина event loop:



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


Но что исполняется внутри RenderQueue?


Рендер кадра можно поделить на несколько основных этапов. Каждый этап внутри может быть разделен на другие подэтапы (мы посмотрим это на примере Layout):



Остановимся на каждом этапе подробнее:


RequestAnimationFrame (raf)



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


Позволяет нам подписаться и запланировать изменения в DOM прямо перед отрисовкой страницы. Несколько важных фактов:


  1. Колбек для raf принимает аргумент: DOMHighResTimeStamp — количество миллисекунд прошедших с начала жизни документа. Поэтому внутри колбека можно не брать время через perfomance.now, нужное время уже присутствует


  2. Аналогично setTimeout, raf возвращает дескриптор (id), поэтому запланированный raf можно отменить через cancelAnimationFrame.


  3. Если пользователь переключился на другой таб, свернул браузер — перерисовки страницы не происходит. Raf тоже не вызывается


  4. JS код, который изменяет размеры элементов, считывает свойства, может зафорсить requestAnimationFrame


  5. Как посмотреть, как часто браузер обновляет кадр? Вот так:


    const checkRequestAnimationDiff = () => {
    let prev;
    function call() {
        requestAnimationFrame((timestamp) => {
            if (prev) {
                console.log(timestamp - prev); // Должно быть в районе 16.6 мс, при 60FPS
            }
            prev = timestamp;
            call();
        });
    }
    call();
    }
    checkRequestAnimationDiff();

    Вот например, мой эксперимент (запускал на hh.ru):



  6. Сафари вызывает(ал) raf после отображения кадра, а не до. Пруф: https://github.com/whatwg/html/issues/2569#issuecomment-332150901



Style (recalculation)



Браузер пересчитывает стили, которые должны примениться из-за изменений, запланированных JS. Здесь же происходит вычисление активных media queries


Пересчет включает в себя как прямые изменения a.styles.left = '10px' так и те, которые описываются через CSS файлы, например element.classList.add('my-styles-class') Все они будут пересчитаны с точки зрения работы CSSOM и получения Render tree.


Если запустить профилировщик и открыть сайт hh.ru, вот тут можно найти время, потраченное на Style:



Layout



Вычисление слоев, расчет положения элементов на странице, их размеров, взаимного влияния друг на друга. Чем больше DOM элементов на странице, тем тяжелее эта операция.
Современные браузеры могут организовывать рендер и layout дерева по-разному. Например, в Хроме, кроме Layout в профилировщике вы увидите такие процессы как update layer tree и layout shift, который и отвечает за сдвиг элементов относительно друг друга.
Здесь на графике выделенная строка — тот самый Layout сайта hh.ru при первом открытии приложения.



Layout — это очень болезненная операция для современных веб-сайтов. Болезненна она потому, что наиболее тяжелый style recalculation происходит при первом рендере, а вот Layout происходит при:


  1. Чтении свойств влияющих на размер и положение элементов (offsetWidth, offsetLeft, getBoundingClientRect, и т.д.)
  2. При записи свойств, влияющих на размер и положение элементов, за исключением некоторых свойств, вроде transform и will-change. Для transform браузер задействует composition процесс, а в случае с will-change браузер попытается использовать composition. Вот здесь список актуальных причин.

Layout отвечает за:


  • Вычисление слоев
  • Расчет взаиморасположения элементов на слое
  • Расчет влияния одних элементов на другие

Layout (а вместе с ним raf и Style) может происходить не в свою очередь, когда нужно отрендерить страницу и применить изменения, а тогда, когда JS изменил размеры элементов или считал данные. Такой процесс называется force layout. Вот полный список свойств, который приводит браузер к остановке исполнения JS и вызову Layout.


div1.style.height = "200px"; // Изменили размер элемента
var height1 = div1.clientHeight; // Считываем его размер

Браузер не сможет рассчитать clientHeight нашего div1 без пересчета его реальных размеров. В этом случае, браузер приостановит исполнение JS кода (совсем) и выполнит по очереди: Style (чтобы определить, что изменять), Layout (чтобы определить, как изменилось). Layout должен рассчитать не только элементы, которые находятся перед, но и после div1. Современные браузеры оптимизируют расчет так, чтобы не пересчитывать абсолютно все дерево. Но в худшем случае этого не избежать. Процесс пересчета элементов называется Layout Shift. Вот так его можно посмотреть (справа список всех элементов, которые были сдвинуты и модифицированы во время Layout):



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


div1.style.height = "200px";
var height1 = div1.clientHeight; // <-- layout 1
div2.style.margin = "300px";
var height2 = div2.clientHeight; // <-- layout 2 

В этом коде браузер запланировал изменение высоты div1 до 200px, но затем получил задачу на считывание. Пришлось сделать layout. Затем ситуация повторилась. Обратите внимание, браузер не произвел layout на операциях записи. Потому что в этот момент нужные данные у него уже были.


Давайте сгруппируем чтение и запись:


div1.style.height = "200px";
div2.style.margin = "300px";
var height1 = div1.clientHeight; // <-- layout 1
var height2 = div2.clientHeight;

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


Layout работает со "слоями" в нашем потоке. Посмотреть на то, как браузер выделяет слои можно в chrome devtools -> More tools -> layers:



Таким образом, наш event loop превращается из одного витка в несколько, потому что и на этапе tasks, и на этапе microtasks мы можем запустить force layout:



Базовые советы для оптимизации layout:


  1. Уменьшать количество DOM нод
  2. По возможности избегать force layout
  3. Компоновать чтение и запись свойств

Paint



На этом шаге отрисовываем элементы, применяем стили color, background и т.д. Во время первого рендера сайта мы потратим на это достаточно времени:



В целом, эта операция не критична, так как в общем случае она не занимает много времени. Особенно после первого рендера.


После того, как пиксели нашей страницы обзавелись цветом, мы приступаем к следующему шагу — Composition.


Composition



Это единственная операция, которая в классическом веб-сайте исполняется с помощью GPU. На этом этапе браузер исполняет специфические CSS стили, например transform.


Задача этой операции: совместить слои и получить готовый кадр.


Важное дополнение: само по себе свойство transform: translate не включает рендер элемента на видеокарте. То есть сделав transform: translateZ(0) вы не "перенесете элемент на видеокарту", это заблуждение.


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



Именно с помощью transform советуют создавать сложные анимации. Секрет довольно прост:


  1. Анимация на transform позволяет нам не вызывать layout каждый кадр, мы экономим время
  2. Она позволяет нам избавиться от артефактов "мыльца" при анимациях, которые иногда бывают при анимировании left, right, top, bottom

В то же время, верстать весь сайт только через transform не рекомендуется, так как это приводит к своим тормозам. Важно совмещать правильно верстку.


Как оптимизировать рендер?


Самая тяжелая операция для рендера кадра (для большинства сайтов) — layout. Сверху на графиках был layout для рендера главной страницы hh.ru. При сложных анимациях каждый кадр может пересчитываться все элементы в DOM, а это означает, что каждый кадр вы будете тратить по 13-20 мс впустую. Это ведет к пропуску кадров и проблемам с производительностью вашего сайта.


Несколько примеров:



Мы можем пропустить Layout, если изменяем цвета, фоновое изображение и т.д.



Мы можем пропустить layout и paint, если изменения основаны на стиле transform и не затрагивают чтение свойств.


Итого, советы по оптимизации можно сгруппировать так:


  1. Выносите анимации на CSS. Исполнение JS кода не бесплатно
  2. Изменяйте transform свойство для “перемещения” объектов
  3. Используйте will-change свойство — это свойство, которое позволяет браузерам "подготовить" дом элемент к изменениям определенных стилей. Важно — это свойство помогает браузеру понять, что разработчик запланировал изменить. Это свойство нельзя применять к большому количеству элементов, иначе вы получите тормоза.
  4. Используйте батчевые изменения в DOM
  5. Используйте requestAnimationFrame для планирования изменений в следующем кадре
  6. Комбинируйте задачи на запись \ чтение свойств элементов. Обращайте внимание на вызовы свойств, которые форсят layout.
  7. При возникновении сложных ситуаций, лучше всего запустить профилировщик и посмотреть на частоту и время вызовов. Это даст вам информацию о том, какой этап тормозит, оптимизируйте каждый этап отдельно.

Вместо заключения


В этой части мы разобрали каким образом работает Runtime в нашем браузере, его сильные и слабые стороны. Это позволяет нам:


  1. Понимать, как лучше писать код
  2. Знать в какую сторону смотреть при появлении проблем

Также мы получили достаточно глубокое понимание event loop и его составляющих:


Теги:event loopjavascriptrenderingclient-sideclient-side performancefrontendmicrotask
Хабы: Блог компании HeadHunter Разработка веб-сайтов CSS JavaScript Клиентская оптимизация
+20
10,7k 142
Комментарии 5
Лучшие публикации за сутки