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

Проблема и её дебаггинг

В современных веб-приложениях частые обновления интерфейса приводят к регулярным циклам перерендеринга (rerender), где этап перерасчёта стилей (Style Recalculation) становится критически важным с точки зрения производительности. Некорректная оптимизация CSS приводит к задержкам отрисовки на этом этапе, негативно влияя на отзывчивость всего приложения.

Задержки отклика могут быть вызваны различными причинами, не рассматриваемыми в этой статье.

Подобные проблемы легко выявляются в процессе профилирования. Рассмотрим пример приложения со значительными задержками отклика при взаимодействии с интерфейсом. Для этого используем вкладку «Performance» в DevTools (Chrome) и записываем действие, вызывающее задержку.

Style calculation занял 5.7 секунд
Style calculation занял 5.7 секунд

На скриншоте выше видно, что наибольшую часть времени задачи занимает процесс Style Recalculation. Это уже является явным указателем на проблему в CSS. Для получения более подробной информации, активируем опцию «Enable CSS selector stats (slow)» и повторно выполняем профилирование.

Результат профилирования со статистикой по селекторам
Результат профилирования со статистикой по селекторам

В результатах появляется дополнительная вкладка со статистикой по каждому CSS-селектору, участвующему в процессе Style Calculation. Здесь отображается время, затраченное на обработку каждого селектора, количество сопоставленных элементов и количество элементов, для которых была выполнена попытка сопоставления. 

На скриншоте видно, что для обработки некоторых селекторов ушло достаточно много времени. Именно в этом и заключалась наша проблема. 

Следует отметить, что анализируемая страница обладает сложной структурой с примерно 100.000 DOM-узлов. Это имеет важное значение, поскольку производительность на данном этапе (Style Calculation) зависит от размера DOM и его структуры. Для сравнения — средний размер статьи на Habr — до 5.000 DOM-узлов.

Забегая немного вперёд, после оптимизации селекторов мне удалось сократить время расчёта стилей для данной страницы почти в 1000 раз. При этом визуальные изменения отсутствовали — всё было достигнуто за счёт более грамотной организации обращений к элементам и оптимизации CSS.

Style calculation занял 0,00523 секунд
Style calculation занял 0,00523 секунд

Сравнив показатели «до» и «после», помимо затраченного времени, заметно значительное уменьшение значения Elements affected. Эта метрика показывает, какое количество элементов подверглось изменению или перерасчёту стилей. 

Style Calculation 

Style Calculation — это этап рендеринга, на котором браузер для каждого DOM-элемента определяет, какие CSS-стили к нему применяются, сопоставляя его с CSSOM. При этом учитываются селекторы, каскадность, специфичность, а также наследуемые свойства.

Указателем на то, какие стили какому элементу принадлежат, являются селекторы CSS. Именно от того, как они написаны (и от количества элементов на странице) зависит то, как быстро браузер сможет произвести Style Calculation.

Стадия Style Calculation (расчёт стилей) обязательно происходит при перерисовке страницы в следующих случаях: при добавлении или удалении элементов DOM, изменении inline-стилей или классов элементов, обновлении CSS-правил или CSSOM. При изменении состояний псевдоклассов и псевдоэлементов, запуске анимаций и переходов, а также при вызове методов вроде getComputedStyle. Эти ситуации требуют пересчёта стилей для определения окончательных значений и правильного отображения страницы. В остальных случаях браузер использует кэшированные результаты, избегая лишних пересчётов.

Скорость обработки селекторов

Для начала рассмотрим базовые типы селекторов и посмотрим, насколько быстро они работают сами по себе.

Пример

Название

Скорость

Описание

.class

Класс

Очень быстро

Браузеры индексируют классы при парсинге DOM.

#id

Идентификатор

Очень быстро

Браузер сразу обращается к элементу по ID, используя быстрый lookup.

div

Тег

Быстро

Теговые селекторы обрабатываются быстро, но не индексируются так эффективно, как классы или ID.

[type=”text”]

Атрибут

Медленно

Атрибутные селекторы требуют полного обхода элементов, потому что атрибуты не индексируются как классы.

:not()

Псевдокласс

Очень медленно

Не кешируется и требует дополнительной проверки. Например, вычисление позиции в случае :nth() или полного обхода потомков в случае :has()

*

Все

Самый медленный

Сопоставляется с абсолютно каждым элементом.

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

Пример

Название

Скорость

Описание

ul > li > a

Комбинированные - Потомки

Средне

Сопоставление происходит не по всему документу, а в ограниченной вложенности.

ul li a

Комбинированные

Медленно

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

Дополнительно стоит знать про селекторы с различными операторами. Рассмотрим их в таблице ниже:

Пример

Название

Скорость

Описание

[class="classname"] 

Точное совпадение

Быстро (относительно поиска по атрибуту)

Сравниваются строки.

[class~="classname"]

Значение содержится в

Средне

Строка разбивается на массив. Поиск по массиву с точным совпадением.

[class^="classname"]

Значение начинается с …

Медленно

Производится анализ подстроки (Только в начале или конце).

[class$="classname"]

Значение заканчивается на …

[class*="classname"]

Значение содержится где угодно

Очень медленно

Производится анализ подстроки (Строка проверяется полностью)

Right-to-left

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

Браузер обрабатывает селекторы по принципу «right-to-left matching»: от правого к левому. То есть обрабатывая селектор «li a», браузер сначала найдёт всё «a», и только потом проверит, находиться ли эти «a» внутри «li». Когда не указан символ потомка «>» браузер будет для каждого «а» искать родительский «li» вверх по дереву до корневого элемента, если не встретит совпадение. 

Понимая принцип «right-to-left matching», мы можем писать более производительные селекторы. Рассмотрим пару примеров. 

Пример 1. Принцип right-to-left

Создадим небольшой файл с HTML и CSS.

<div id="root">
  <ul class="list">
    <li class="list-item">
      <a href="/" class="list-item-link">link</a>
    </li>
  </ul>
  <a href="/">link</a>
</div>

<style>
  ul li a {
    color: red;
  }
  ul li a.list-item-link {
    color: red;
  }
</style>

Мы видим 2 CSS-селектора, которые выполнят одно и то же действие — покрасят ссылки внутри списков в красный цвет. Количество элементов, которые затронут (покрасят) оба эти селектора — одинаковое.

Для наглядности размножим HTML внутри #root в 1000 раз (таким образом, чтобы получить 1000 списков и 1000 тегов «a» за пределами списков) и замерим, как много времени браузер потратил на расчёт стилей для каждого из селекторов.

Результат профилирования со статистикой по селекторам
Результат профилирования со статистикой по селекторам

Как мы видим, селектор «ul li a.list-item-link» отработал почти вдвое быстрее своего конкурента. Причиной этому стал тот факт, что попыток сопоставить этот селектор было всего 1000 против 2000 у «ul li a». Это можно видеть по колонке Match attempts.

Произошло это из-за правила right-to-left matching. В случае с селектором «ul li a» браузер сначала нашёл все «a» (которых 2000) и только после, двигаясь влево по селектору, исключил все элементы, которые не находятся внутри «li». Для этого ему пришлось делать проверку для всех 2000 элементов. А в случае с «ul li a.list-item-link» браузер изначально начал проверять только «a» с классом list-item-link, которых всего 1000.

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

Пример 2. Влияние вложенности css-селекторов

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

<div id="root">
  <ul class="list">
    <li class="list-item">
      <a href="/" class="list-item-link">link</a>
    </li>
  </ul>
  <a href="/">link</a>
</div>

<syle>
  ul li a {
    color: red;
  }
  ul a {
    color: red;
  }
</style>

Также размножим вёрстку и произведём замеры времени, потраченного на наши селекторы.

Результат профилирования со статистикой по селекторам
Результат профилирования со статистикой по селекторам

Как мы видим, селектор «ul a» потребовал меньше времени относительно «ul li a». Произошло это потому, что браузеру пришлось делать меньше проверок в DOM-дереве, проверяя, находится ли каждый «а» внутри «li».

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

Резюме

Скорость выполнения этапа Style Calculation в процессе render/rerender зависит от того, как написаны css-селекторы. Непосредственное влияние оказывают 2 вещи: 

  1. Количество попыток сопоставлений (в оптимальном случае оно должно равняться количеству сопоставленных элементов).

  2. Сложность/скорость обработки конкретного типа селектора (наиболее производительные классы, теги и id).

Общие рекомендации 

  • Пишите селекторы с учётом right-to-left matching — браузер анализирует их справа налево. Чем специфичнее правая часть (например, .list-item-link вместо просто a), тем меньше элементов браузер будет пытаться сопоставить. 

  • Избегайте избыточной вложенности. Селектор «li > a» обработается быстрее, чем «ul > li > a», — чем короче цепочка, тем меньше вычислений. 

  • Замените сложные селекторы на классы. Атрибуты ([class*="..."]) и псевдоклассы (:not()) работают медленно — индексируемые классы (.button) всегда эффективнее.

  • Опасайтесь «тяжёлых» конструкций, таких как универсальный селектор (проверяет каждый элемент), псевдоклассы :has(), :nth-child(), Атрибутные селекторы с , ^, $.