Ночь. Стук в дверь. Открыть. Стоят двое. "Верите ли вы в Event loop, нашу главную браузерную цепочку?" Вздохнуть. Закрыть дверь. Лечь досыпать. До начала рабочего дня еще 4 часа. А там уже ивент лупы, лейауты и прочая радость…
 В первой части мы говорили о первой загрузке и работе с ресурсами. Сегодня я расскажу о второй части оптимизации производительности фронтенда. О том, что происходит с нашей страницей, когда она загружена, на что уходит процессорное время и что с этим делать. Ключевые слова: event loop, paint \ repaint, layout \ reflow, composite.  Если хочется сразу начать поглощать контент статьи, пропустите этот раздел Event loop популярен не только в ежедневной работе, но и на собеседованиях. И пойди разберись, где он популярнее. Существует три вопроса, которые можно задать себе, чтобы проверить, понимаете ли вы этот браузерный механизм. Два из них — завсегдатаи собеседований, а вот третий показывает понимание event loop для своей практики. Они довольно простые, но будут расставлены по возрастанию сложности: Будет ли выведено "1" в консоль? Почему? Есть сайт, а на сайте ссылка, у которой при наведении cursor: pointer ставится через :hover стиль CSS и кнопка, у которой также по :hover меняется background-color c серого на синий. Добавляем скрипт: Вопрос: Что будет если навести мышку на ссылку? А на кнопку? Почему? Как анимировать выпадающий элемент по height с 0 до auto? Здесь важно обсудить способы c помощью JS и/или CSS. Кстати, если гуглить этот вопрос, то stackoverflow вначале предлагает неверный ответ. Суть понимания event loop и работы браузеров сводится к тому, как замерить то самое height = auto с помощью JS.  Прийти к достаточно глубокому пониманию вот этой схемы:
 Идти к ней будем постепенно, с подробными остановками на каждом из этапах В старых операционных системах была похожая штука. Выглядела она условно вот так: Такой код всегда забивал ЦПУ процессоров в 100%. Что и было в старых версиях windows. Сейчас планировщики операционных систем очень сложные. Там есть и приоритизация, и исполнение, и различные очереди. Итак. Чтобы начать, нам понадобится какой-то бесконечный цикл, который будет опрашивать, есть ли у нас задачи. Например такой:  Теперь нам нужно откуда-то эти задачи получать.
Зададим себе вопрос: что является триггером, чтобы наш JS код начал выполняться?
Это может быть: Почти все эти вызовы планируются через WebAPI (его еще иногда называют браузерным API). То есть: Нашему JS коду нужно как-то взаимодействовать с DOM. Считывать размеры элементов, добавлять свойства, рисовать какие-нибудь выпадающие элементы. Делать интерфейс живым. Это накладывает ограничения на отрисовку элементов. Мы не можем просто так взять и запустить 2 потока, чтобы в одном выполнялся JS, а в другом CSS и отрисовка. Это потребовало бы большого количества синхронизаций кода, либо могло бы привести к неконсистентому исполнению. Поэтому и JS, и расчет отображения элементов (расположения, цвета и т.д.) работают в одном потоке. Значит в нашем Event Loop кроме JS есть "отрисовка". Давайте поместим ее в отдельную очередь и назовем render queue:  Итак, у нас есть две точки входа. Одна для большинства JS операций, другая для отрисовки. Теперь нам нужно разобраться, что же такое SomeJSTasks и как оно работает. Для исполнения большей части JS кода у нас есть 2 очереди: Event loop неразрывно связан с обновлением экрана. В нем исполняется не только JS код, но и рассчитываются кадры страницы. Браузеры стараются отображать изменения на странице максимально быстро. Здесь существует ряд ограничений: Большинство современных устройств и настроек поддерживает 60FPS. Это число будет и у подавляющего количества браузеров. Поэтому далее в статье будем пользоваться 60FPS, но в голове держим, что это число может быть другим. Для нашего Event Loop это означает, что на исполнение тасков отводится примерно 16.6мс (при 60FPS) Как только задачи набиваются в очередь 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 раз будет рисовать кадры без остановки. Прежде чем перейти к микротаскам, разберем такую штуку, как стек вызовов: Стек вызовов — это список, который отражает, какие функции сейчас вызываются и куда будет совершен переход, после окончания исполнения текущей функции. Разберем пример: Мы запускаем этот код в консоли браузера и останавливаемся на дебаггере. Как будет выглядеть наш стек вызовов? Мы начали наш стек из inline кода, значит самым верхом будет эта строчка. Как правило, в хром она будет указана просто ссылкой на нее. Обозначим ее inline. Дальше мы падаем в startDndGame, и вызываем несколько раз findAFriend. Однако он не попадет в итоговый callStack, потому что из всех findAFriend мы вышли. Итого наш стек вызовов будет выглядеть вот так:  Микротаски ограничены. Это либо колбеки для promise, либо mutationObserver. Сама идея появления микротасок довольно костыльна, но она дает нам некоторые преимущества и недостатки по сравнению с TaskQueue. Главная особенность микротасков: они будут исполнены сразу же, как только освобождается стек вызовов. Например, в случае с этим стеком вызовов:  Если у нас в любой момент исполнения этого кода появляется fulfilled или rejected promise, то он будет исполнен сразу же, как только закончатся элементы в нашем стеке вызовов. Любой JS код записывается в стек вызовов, что логично. Окончание стека вызовов, по сути, и есть окончание таска или микротаска. Интересный факт: Микротаски могут порождать другие микротаски, которые будут вызываться сразу же по освобождению стека вызовов. То есть рендер страницы может откладываться бесконечно. Это главный минус микротасков:  Если у нас 4 микротаски в очереди MicrotaskQueue, то они будут исполнены друг за другом, откладывая ререндер. Недостаток микротасок — также и их преимущество. Например, момент срабатывания MutationObserver — это когда изменения на странице произошли, но пользователь их не увидит, пока колбек mutation observer не исполнится. Таким образом, мы можем эффективно управлять контентом, который видит пользователь. Итого, наша картина event loop:  Мы уже разобрали, что таски могут следовать друг за другом, пропуская RenderQueue (если в ней нет тасок). Рендер кадра можно поделить на несколько основных этапов. Каждый этап внутри может быть разделен на другие подэтапы (мы посмотрим это на примере Layout):  Остановимся на каждом этапе подробнее:  Браузер готов начать рендер, а мы можем подписаться на это и что-то досчитать/санимировать. Этот колбек хорошо подходит для отрисовки анимаций или изменения чего-то до отрисовки следующего кадра. Позволяет нам подписаться и запланировать изменения в DOM прямо перед отрисовкой страницы. Несколько важных фактов: Колбек для raf принимает аргумент: DOMHighResTimeStamp — количество миллисекунд прошедших с начала жизни документа. Поэтому внутри колбека можно не брать время через perfomance.now, нужное время уже присутствует Аналогично setTimeout, raf возвращает дескриптор (id), поэтому запланированный raf можно отменить через cancelAnimationFrame. Если пользователь переключился на другой таб, свернул браузер — перерисовки страницы не происходит. Raf тоже не вызывается JS код, который изменяет размеры элементов, считывает свойства, может зафорсить requestAnimationFrame Как посмотреть, как часто браузер обновляет кадр? Вот так: Вот например, мой эксперимент (запускал на hh.ru):  Сафари вызывает(ал) raf после отображения кадра, а не до. Пруф: https://github.com/whatwg/html/issues/2569#issuecomment-332150901  Браузер пересчитывает стили, которые должны примениться из-за изменений, запланированных JS. Здесь же происходит вычисление активных media queries Пересчет включает в себя как прямые изменения a.styles.left = '10px' так и те, которые описываются через CSS файлы, например element.classList.add('my-styles-class') Все они будут пересчитаны с точки зрения работы CSSOM и получения Render tree. Если запустить профилировщик и открыть сайт hh.ru, вот тут можно найти время, потраченное на Style:   Вычисление слоев, расчет положения элементов на странице, их размеров, взаимного влияния друг на друга. Чем больше DOM элементов на странице, тем тяжелее эта операция.
Современные браузеры могут организовывать рендер и layout дерева по-разному. Например, в Хроме, кроме Layout в профилировщике вы увидите такие процессы как update layer tree и layout shift, который и отвечает за сдвиг элементов относительно друг друга.
Здесь на графике выделенная строка — тот самый Layout сайта hh.ru при первом открытии приложения.  Layout — это очень болезненная операция для современных веб-сайтов. Болезненна она потому, что наиболее тяжелый style recalculation происходит при первом рендере, а вот Layout происходит при: Layout отвечает за: Layout (а вместе с ним raf и Style) может происходить не в свою очередь, когда нужно отрендерить страницу и применить изменения, а тогда, когда JS изменил размеры элементов или считал данные. Такой процесс называется force layout. Вот полный список свойств, который приводит браузер к остановке исполнения JS и вызову Layout. Браузер не сможет рассчитать clientHeight нашего div1 без пересчета его реальных размеров. В этом случае, браузер приостановит исполнение JS кода (совсем) и выполнит по очереди: Style (чтобы определить, что изменять), Layout (чтобы определить, как изменилось). Layout должен рассчитать не только элементы, которые находятся перед, но и после div1. Современные браузеры оптимизируют расчет так, чтобы не пересчитывать абсолютно все дерево. Но в худшем случае этого не избежать. Процесс пересчета элементов называется Layout Shift. Вот так его можно посмотреть (справа список всех элементов, которые были сдвинуты и модифицированы во время Layout):  Также браузеры пытаются не форсить layout каждый раз. Поэтому, чтобы выиграть по скорости операции чтения и записи группируют: В этом коде браузер запланировал изменение высоты div1 до 200px, но затем получил задачу на считывание. Пришлось сделать layout. Затем ситуация повторилась. Обратите внимание, браузер не произвел layout на операциях записи. Потому что в этот момент нужные данные у него уже были. Давайте сгруппируем чтение и запись: Такая группировка позволяет нам избежать второго layout, потому что браузер уже посчитал расположение элементов. Layout работает со "слоями" в нашем потоке. Посмотреть на то, как браузер выделяет слои можно в chrome devtools -> More tools -> layers:  Таким образом, наш event loop превращается из одного витка в несколько, потому что и на этапе tasks, и на этапе microtasks мы можем запустить force layout:  Базовые советы для оптимизации layout:  На этом шаге отрисовываем элементы, применяем стили color, background и т.д. Во время первого рендера сайта мы потратим на это достаточно времени:  В целом, эта операция не критична, так как в общем случае она не занимает много времени. Особенно после первого рендера. После того, как пиксели нашей страницы обзавелись цветом, мы приступаем к следующему шагу — Composition.  Это единственная операция, которая в классическом веб-сайте исполняется с помощью GPU. На этом этапе браузер исполняет специфические CSS стили, например transform.  Задача этой операции: совместить слои и получить готовый кадр. Важное дополнение: само по себе свойство transform: translate не включает рендер элемента на видеокарте. То есть сделав transform: translateZ(0) вы не "перенесете элемент на видеокарту", это заблуждение. Однако современные браузеры могут сами переносить часть операций на GPU. Лучше всего с актуальной информацией по этому вопросу знакомиться из исходников.   Именно с помощью transform советуют создавать сложные анимации. Секрет довольно прост: В то же время, верстать весь сайт только через transform не рекомендуется, так как это приводит к своим тормозам. Важно совмещать правильно верстку.  Самая тяжелая операция для рендера кадра (для большинства сайтов) — layout. Сверху на графиках был layout для рендера главной страницы hh.ru. При сложных анимациях каждый кадр может пересчитываться все элементы в DOM, а это означает, что каждый кадр вы будете тратить по 13-20 мс впустую. Это ведет к пропуску кадров и проблемам с производительностью вашего сайта. Несколько примеров:  Мы можем пропустить Layout, если изменяем цвета, фоновое изображение и т.д.  Мы можем пропустить layout и paint, если изменения основаны на стиле transform и не затрагивают чтение свойств. В этой части мы разобрали каким образом работает Runtime в нашем браузере, его сильные и слабые стороны. Это позволяет нам: Также мы получили достаточно глубокое понимание event loop и его составляющих:  -