Event loop, Layout, Paint,Composite и стек вызовов

Привет, меня зовут Вика и я — Senior Frontend-разработчик и ментор в команде CodeReview.
Так как мне приходится часто готовить ребят к техническим интервью, я заметила, что у Frontend-разработчиков часто бывает проблема с пониманием темы очереди событий: Event loop, Layout, Paint, Composite.

Чтобы не повторять 100 раз одно и тоже, решила все подробно расписать в своей статье.

Надеюсь, это внесет ясности к этой теме, и ты перестанешь на ней валиться.

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

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

Дальше стоит задуматься, как мы получаем какие-то таски на выполнение? Давайте зададим себе вопрос: какие есть триггеры для выполнения JS-кода? Вот несколько вариантов:

  1. Браузер загрузил что-то через тег <script>
  2. Появилась отложенная задача через setTimeout , setInterval ,
    requestIdleCallback
  3. Получили какой-то ответ через XmlHttpRequest , fetch и т.д.
  4. Получили уведомления о событиях и подписчиках через API браузера,
    например, сlick , mousedown , input и т.д.
  5. Promise закончил работу и изменил состояние. В некоторых случаях это
    может быть за пределами нашего кода js.
  6. Сработали наблюдатели, такие как DOMMutationObserver , IntersectionObserver

Приведу небольшой пример того, как это отработает с setTimeout :

  1. Мы выполняем следующую строчку кода: setTimeout(function f_1() {}, 100)
  2. WebAPI отложил задачу на 100 мс
  3. Через 100 мс WebAPI помещает функцию f_1() в очередь (Очередь задач).
    Event loop, Layout, Paint, Composite и стек вызовов 3
  4. EventLoop выполняет задачу в своем цикле.

Так как наш JS-код должен как-то работать с DOM (например получать размер
элементов, добавлять какие-то свойство, вызывать модалки и т.д.), то это
создает некоторые ограничения, которые накладываются на отрисовку
элементов.
Браузеру сложно запустить 2 потока, один для JS, другой для рендеринга CSS,
так как это потребует большого количество синхронизации кода, чтобы не
получить несогласованность выполнения.
Именно из-за этого и JS, и рендеринг элементов работают в одном потоке.
Поэтому, давайте добавим очередь рендеринга на схему:

Теперь у нас есть 2 точки входа. Один для большинства операций JS, а второй
для рендеринга.
Наша первая очередь называется «Какая-то JS Задача», и пришло время
рассмотреть, как она работает: браузеры используют 2 очереди для
выполнения большей части кода JS:

  1. TaskQueue для всех событий, отложенных задач, почти для всего. Элемент
    этой очереди — «Задача».
  2. MicroTaskQueue предназначен для обратных вызовов промисов и
    MutationObserver. Элемент из этой очереди — «MicroTask».

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

EventLoop неразрывно связан с фреймами. Он выполняет не только JS-код, но
и вычисляет новые кадры. Браузеры стараются отображать изменения на
страницах как можно быстрее. Но тут есть некоторые ограничения:
— Аппаратные ограничения: частота обновления экрана
— Программные ограничения: ОС, браузер, настройки энергосбережения и
т. д.


Большинство современных устройств (и приложений) поддерживают 60 FPS
(кадров в секунду). Большинство браузеров пытаются обновлять свой экран
именно с этой скоростью. Итак, в статье мы будем использовать 60 FPS, но
лучше иметь в виду, что конкретная скорость может варьироваться.
Для нашего цикла событий это означает, что если мы хотим сохранить 60
кадров в секунду, у нас есть временные интервалы по 16,6 мс для наших задач.

Что такое очередь задач?
Как только мы получаем задачи в TaskQueue, мы получаем верхнюю задачу из
очереди и выполняем ее в каждом цикле.
После выполнения, если у нас есть достаточно времени мы получаем еще одну
задачу, и еще одну, пока очередь рендеринга не получит задачу.

Вот пример
У нас есть 3 задачи: A, B, C.
Event Loop получает первую и выполняет ее. Это занимает 4 мс.
Event loop, Layout, Paint, Composite и стек вызовов 5
Затем цикл событий проверяет другие очереди (очередь микро задач и
очередь ренера). Если они пусты, то цикл событий выполняет вторую
задачу. Это занимает 12 мс. Всего две задачи используют 16 мс.
Затем браузер добавляет задачи в очередь рендеринга для отрисовки
нового кадра. Цикл событий проверяет очередь рендеринга и запускает
выполнение этих задач. Они занимают около 1 мс. После этих операций
цикл событий возвращается в к основной очереди задач.


Важно учитывать, что цикл событий не может предсказать, сколько времени
будет выполняться задача. Кроме того, цикл событий не может приостановить
задачу для рендеринга кадра, так как движок браузера не знает, может ли он
извлечь изменения из пользовательского кода JS или это просто какая-то
подготовка, а не финальное состояние. У нас просто нет API для этого.


А вот другой пример, тоже интересно
У нас всего 2 задачи в очереди.
Первая довольно длинная, она занимает 240 мс. Поскольку 60 кадров в
секунду означает, что каждый кадр должен рендериться каждые 16,6 мс, мы
теряем примерно 14 кадров. Поэтому, как только задача завершается, цикл
событий выполняет задачи из очереди рендеринга для отрисовки кадра.
Важное примечание: хотя мы потеряли 14 кадров, это не значит, что мы
будем рендерить 15 кадров подряд.

Стек вызовов

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

const findDebugger = () => {

debugger;

console.log('Кажется, дебаггер нас остановил');

}

const goAndFingDebugger = () => {

findDebugger();

}

const becomeADebuggerFinder = () => {

goAndFingDebugger();

}

const findSomethingElse = () => {

// ¯\_(ツ)_/¯

}

const startFinderGame = () => {

const debbugers = [];

while (debbugers.length < 2) {

debbugers.push(findSomethingElse());

}

becomeADebbugerFinder();

}

console.log(startFinderGame());

Если мы запустим этот код в консоли браузера, то он будет остановится на
дебаггере. Как будет выглядеть наш стек вызовов?
Мы запускаем наш стек тут: console.log(startFinderGame());
Следовательно, это начало стека вызовов.
Затем мы спускаемся к функции startFinderGame и несколько раз вызывается
findSomethingElse . Эта функция не будет представлена в стеке вызовов, так как
она будет завершена до того, как мы доберемся до отладчика.
Стек вызовов будет выглядеть примерно так:

  1. console.log
  2. startFinderGame
  3. becomeADebbugerFinder
  4. goAndFingDebugger
  5. findDebugger

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

Что такое очередь микро задач?

Микро задачи специфичны. Это могут быть только колбеки из Promises или
MutationObserver.
Главная особенность микро задач в том, что они будут выполняться, как только
стек вызовов станет пустым. Например, у нас может быть стек вызовов из
предыдущего примера:

  1. console.log
  2. startFinderGame
  3. becomeADebbugerFinder
  4. goAndFingDebugger
  5. findDebugger

Если у нас есть Promise в состоянии resolve или reject, то колбек будет
выполнен, как только все элементы в стеке будут завершены.
Любой js-код прописывается в стеке вызовов (что логично).
Конец стека вызовов — это конец задачи или микро задачи.
Тут, кстати, есть интересный факт: микро задачи могут создавать новые микро
задачи, которые будут выполняться, когда стек вызовов закончится.
Другими словами: рендеринг страницы может быть отложен навсегда. Это
главная «фича» микрозадач.

Пример
Если у нас в очереди микро задач 4 задачи, то они будут выполняться одна
за другой.
Event loop, Layout, Paint, Composite и стек вызовов 8
Рендеринг будет выполняться только после всех этих 4 микро задач, даже
если это может занять несколько секунд.
Все это время пользователь не сможет работать с сайтом.
Эта особенность микро задач может быть как преимуществом, так и
недостатком. Например, когда MutationObserver вызывает свой колбек при
изменении DOM, пользователь не увидит изменений на странице до его
завершения.

И вот наша схема уже выглядит вот так:

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

  1. RequestAnimationFrame
    Event loop, Layout, Paint, Composite и стек вызовов 9
  2. Стили
  3. Layout / Reflow
  4. Paint / Repaint
  5. Compose

Дальше мы разберем каждый из них детальнее.

RequestAnimationFrame (raf)
Браузер готов к запуску рендера, мы можем подписаться на это событие, чтобы
просчитать или подготовить кадр для шага анимации.
Этот колбек хорошо подходит для работы с анимацией или планирования
некоторых изменений в DOM непосредственно перед рендерингом кадра.


Некоторые интересные факты:

  1. Обратный вызов Raf имеет аргумент: DOMHighResTimeStamp — это
    количество миллисекунд, прошедших с «старта времени» (которое
    является началом времени существования документа).
    Поэтому вам может не понадобиться использовать performance.now()
    внутри колбека, эти данные уже есть.
  2. Raf возвращает дескриптор (id), поэтому вы можете отменить raf,
    используя команду cancelAnimationFrame . (так же как, например, с
    setTimeout ).
  3. Если пользователь изменит вкладку или свернет браузер, у вас не
    будет повторного рендеринга, что означает, что у вас также не будет raf.
  4. JS код, который изменяет размер элементов или считывает свойства
    элементов, может принудительно вызвать requestAnimationFrame.
  5. Safari вызывает raf после рендеринга кадра. Это единственный браузер
    с другим поведением. Подробнее тут

Пересчет стилей
Браузер пересчитывает стили, которые следует применить. На этом шаге также
вычисляются, какие медиа-запросы будут активны.
Пересчеты включают в себя как прямые изменения div.styles.left = '0px' , так и
описанные через файлы CSS, такие как element.classList.add('my-styles-class') .
Все они будут пересчитаны с точки зрения CSSOM и дерева рендеринга.


Layout / Reflow
Layout – это про расчет, позиций элементов, их размеров и их взаимного
влияния друг на друга при первой отрисовке.
Reflow – все тоже самое, но иногда так это обзывают при последующих
переращетах.
Дальше я буду везде обзывать это операцию Layout.
Чем больше элементов DOM на странице, тем сложнее операция.
Layout – достаточно дорогостоящая операция. Перерастет происходит каждый
раз, когда вы:

  1. Производите чтение свойств, связанных с размером и положением
    элемента ( offsetWidth , offsetLeft и т. д.)
  2. Изменяете свойства, связанные с размером и положением элементов, за
    исключением некоторых из них (например, transform и will-change ).

Тут можно почитать подробнее чтение и изменение чего будет вызывать перерасчет.

Layout отвечает за:
— Размеры элементов
— Расположение элементов на слое
Важное примечание: при пересчете Layout браузер приостанавливал JS в
основном потоке, несмотря на то, что стек вызовов не пуст.

И снова пример, чтобы лучше понять
someDiv.style.height = "200px"; // Изменяем размер
var someDivHeight = someDiv.clientHeight; // Читаем свойство

Браузер не может вычислить clientHeight нашего someDiv без пересчета его
реального размера.
В этом случае браузер приостановил выполнение JS и запустил:
перерасчет стилей (чтобы проверить, что нужно изменить), Layout (чтобы
пересчитать размеры).
Layout, кстати, будет вычислять не только элементы, которые размещены
перед нашим someDiv , но и после него.
Современные браузеры оптимизируют вычисления, так что вы не будете
каждый раз пересчитывать все DOM дерево, но это все же может произойти
в плохих случаях.

Таким образом, наш цикл обработки событий преобразуется вот в это:

Несколько советов по оптимизации Layout:
— Уменьшите количество узлов DOM (сделать документ более плоским)
— Группируйте операции чтения\записи

Paint / Repaint
У нас есть элемент, его позиция в окне просмотра и его размер. Теперь нам
нужно применить цвет, фон, то есть «нарисовать» его.
Paint – первичное выполнение этой операции, все пересчеты называются
Repaint, но дальше я буду обзывать этот процесс Paint.


Эта операция обычно не занимает много времени, однако может занять много
времени при первом рендеринге.


Для оптимизации тут могу посоветовать разве что избавится от ненужных CSS
стилей.

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


Так же можно задать элементу свойство will-change , этим свойством мы
говорим браузеру о том, что мы планируем в дальнейшем анимировать этот
элемент, таким образом он заранее выносится на отдельный слой.
Event loop, Layout, Paint, Composite и стек вызовов 13
Так как этап Composition происходит в отдельном потоке композитора, а не в
основном, то вычисления в JS никак не влияют на него.
Благодаря тому что элементы расположены на отдельных слоях, Reflow и
Repaint для элементов одного слоя не затрагивают элементы на остальных
слоях.

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


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

Подведем итог и сформулируем пару советов

  • Перенесите анимацию с JS на CSS. Запуск дополнительного JS-кода не «бесплатно»
  • Анимацию transform стоит использовать для «движущихся» объектов.
  • Используйте свойство will-change . Это позволяет браузерам «подготавливать» DOM элементы к изменениям свойств.
  • Использовать пакетное изменение DOM.
  • Используйте requestAnimationFrame для планирования изменений в следующем кадре.
  • Совмещайте операции чтения\записи свойств CSS элементов, используйте мемоизацию.
  • Оптимизируйте шаг за шагом, не пытайтесь сделать все сразу.

Спасибо за ваше время!

Оцените статью
Блог - < сodereview />