Селекторы и производительность. Часть 1

Артемий Ломов

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

Попытаемся описать «сферический в вакууме» сценарий обработки CSS-правил браузером. Подчеркну, для начала обсудим именно что «сферический в вакууме», то есть классический, эталонный, теоретический сценарий. На практике реальные браузеры дополнительно оптимизируют те или иные составляющие процесса, значительно отличаясь друг от друга многочисленными индивидуальными особенностями. Об этом побеседуем предметно, с тестами на руках, несколько позже.

Справа налево!

Для каждого текущего обрабатываемого элемента HTML-разметки браузер последовательно проверяет применимость всех относящихся к данному HTML-документу CSS-правил, разбирая селекторы в направлении справа налево. Рассмотрим для определенности пример:

.content ul li {…}

Встречая CSS-правило, начинающееся таким селектором, браузер сперва проверяет, является ли текущий обрабатываемый элемент пунктом списка (li).

Если ответ — «нет», обработка данного CSS-правила завершается. Разумеется, оно не применяется к текущему элементу. Браузер переходит к обработке следующего CSS-правила (или же следующего элемента HTML-разметки, если для текущего элемента проверена применимость всех имеющихся правил, то есть данное правило было последним в очереди).

Если же ответ — «да», браузер следует вверх по DOM-дереву в поисках элемента ul с целью проверить, является ли наш пункт списка (li) потомком какого-либо неупорядоченного списка (ul).

Дальше события развиваются аналогичным образом. Если упомянутое условие вложенности элементов выполняется, браузер следует еще выше по дереву DOM, проверяя, является ли этот список (ul) потомком некоего элемента с классом content.

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

Кто самый быстрый?

Вполне очевидно, что наиболее предпочтительным с точки зрения производительности является использование селекторов, требующих минимального количества проверок, причем проверок самых простых. Как вы понимаете, в первую очередь под это описание подходят простые селекторы по идентификатору и по классу:

#intro {…}
.content {…}

В рамках «сферической в вакууме» модели с такими селекторами вполне сравнимы простые селекторы по типу элемента («по тегу»), например:

div {…}

Теоретически в данном случае на производительность обработки CSS-правил (но не собственно селекторов) может повлиять тот факт, что HTML-документы обычно содержат большее количество элементов какого-то определенного типа (например, div), чем элементов с каким-либо одним заданным классом. Ну, а элемент с конкретным идентификатором по природе своей может быть вообще лишь единственным в пределах всей страницы — в противном случае код разметки будет невалидным. Можно сформулировать общее правило — при прочих равных условиях более предпочтительным с точки зрения производительности оказывается применение селекторов с большей специфичностью.

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

Это наталкивает на мысль, что производители браузеров дополнительно оптимизируют обработку селекторов по идентификаторам и по классам. По всей видимости, для CSS-правил с такими селекторами строится нечто вроде индексов, использующихся в СУБД для ускорения доступа к отдельным записям. Разумеется, достоверно установить этот факт позволит только исследование исходного кода браузерных движков, что не входит в компетенцию и задачи автора.

Так или иначе, практика использования в CSS-коде только простых селекторов по классам и никаких более, сложившаяся в рамках парадигмы верстки независимыми блоками (включая, разумеется, разработку с применением методологии БЭМ), имеет под собой веские основания в том числе и с точки зрения производительности.

От быстрых к медленным. Простые селекторы

Итак, если бескомпромиссная производительность критически важна, можно порекомендовать ограничиться исключительно применением простых селекторов по идентификаторам и по классам, но ведь спецификации CSS дразнят веб-разработчиков несравненно более богатым выбором селекторов. Более того, этот «ассортимент» существенно дополнился разнообразными «вкусными» новшествами в спецификациях селекторов третьего и четвертого уровней. (Напомню, Selectors Level 3 — уже почти год как стабильная рекомендация W3C, полностью реализованная к настоящему моменту во всех приличных браузерах, а Selectors Level 4 — активно разрабатываемый черновик, некоторые части которого уже начинают получать экспериментальную поддержку.) Если звезды зажигают более ресурсоемкие селекторы все же существуют, значит, это кому-нибудь нужно?..

Разумеется, их вполне можно использовать в реальной практике, только подходить к делу следует с умом. Понимание того, как браузер анализирует селекторы, поможет не допустить глупостей на ранних стадиях работы над проектом и избежать необходимости в будущем переделывать «тормознутый» сайт с нуля.

Обсудим некоторые другие, более «медленные», чем перечисленные выше, простые селекторы. Попытаемся ранжировать их в порядке снижения производительности, обусловленного возрастанием количества всевозможных проверок и/или их усложнением.

Универсальный селектор (селектор-«звездочка») обладает наименьшей возможной специфичностью (0). Поэтому обработка CSS-правила, начинающегося с такого селектора, отнимет объективно больше времени, чем обработка правила, предваряемого селектором по типу элемента (при прочих равных условиях, конечно). CSS-правило, начинающееся селектором-«звездочкой», будет применяться буквально к каждому элементу документа:

* {…}

Еще больше ресурсов отнимет обработка CSS-правила с селектором по атрибуту:

[title] {…}

В данном случае для каждого элемента HTML-документа будет проверяться наличие атрибута title — только при соблюдении данного условия правило окажется применимым к элементу.

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

[title*="foo"] {…}

Также крайне «медленным» будет простой селектор, использующий динамический псевдокласс:

:hover {…}

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

Уточнения на пользу и во вред

Простые селекторы, перечисленные в предыдущем разделе, на практике используются редко. (За исключением, возможно, «звездочки», которой зачастую начинают CSS-правило, обнуляющее значения свойств margin, padding и border, по умолчанию отличные от нуля для некоторых элементов. Речь идет о так называемом «ластике», вместо которого предпочтительнее использовать более деликатный reset.css, рекомендованный Эриком Мейером — мы уже упоминали такую деталь в одной из предыдущих статей.) Как правило, эти синтаксические конструкции чаще применяются не сами по себе, а в составе последовательностей простых селекторов наподобие img[title] или a:hover. Подобные уточнения увеличивают специфичность соответствующих селекторов по сравнению с простыми селекторами [title] и :hover и сокращают время обработки относящихся к ним CSS-правил за счет существенного ограничения множества элементов, в отношении которых должны производиться сложные ресурсоемкие проверки.

Тем не менее, дополнительные уточнения не всегда полезны, а иногда и вовсе вредны. В частности, нет никакого смысла заменять простой селектор по идентификатору #intro на последовательность, скажем, header#intro. Элемент с соответствующим идентификатором в любом случае может быть лишь единственным во всем HTML-документе — иначе разметка окажется невалидной. Совершенно незачем требовать от браузера осуществления абсолютно лишней дополнительной проверки — принадлежит ли элемент с заданным идентификатором (intro) какому-то конкретному типу (header).

Сказанное почти полностью справедливо и для простых селекторов по классу. Скажем, нет никакого смысла употреблять последовательность вида ul.navigation вместо простого селектора .navigation. Или, точнее, если такой смысл появляется — значит, скорее всего, со стилем кодирования что-то не в порядке. Грамотные веб-разработчики назначают имена классам осознанно, в соответствии с четкими правилами внутренней организации кода, стараясь ни в коем случае не допускать ситуаций, когда один и тот же класс мог бы быть назначен нескольким совершенно различным по смысловой нагрузке элементам. (Мы уже затрагивали похожую проблему, обсуждая уровни семантики.)

От быстрых к медленным. Комбинированные селекторы

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

Очевидно, из всех типов часто используемых на практике комбинированных селекторов, описанных в спецификациях CSS ранних уровней, наиболее «шустрым» будет такой:

h1 + p {…}

В данном случае для каждого элемента p требуется произвести одну сравнительно несложную проверку — предшествует ли ему непосредственно элемент h1.

Вот такой комбинированный селектор отнимет, при прочих равных, чуть больше ресурсов:

ul > li {…}

Здесь потребуется в отношении каждого элемента li произвести проверку, является ли его родителем (то есть непосредственным предком, располагающимся внутри DOM строго на один уровень выше) некоторый элемент ul.

Селектор, использующийся в нижеследующем правиле, при прочих равных отнимет еще большее количество ресурсов:

pre code {…}

Тут браузеру надлежит в отношении всякого элемента code выяснить, не является ли его предком (находящимся на каком угодно более высоком уровне иерархии внутри DOM) какой-либо элемент pre. В худшем случае для констатации неприменимости CSS-правила браузеру потребуется дойти при продвижении вверх по дереву DOM до уровня элемента body.

Чем меньше звеньев, тем надежнее цепь

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

Иногда в CSS-коде вполне реальных проектов можно встретить конструкции наподобие таких:

body ul.navigation li a {…}

Возможно, их авторы считают, что максимально точное описание «области действия» селектора увеличит скорость обработки соответствующего CSS-правила браузером. Полагаю, что уже не требуется объяснять, что такая точка зрения ошибочна. Селектор из вышеприведенного примера вполне можно сократить до такого:

.navigation a {…}

Упоминание body не нужно по той причине, что, как мы уже говорили, браузер сразу же, вне зависимости от наличия или отсутствия в коде разметки обрабатываемого HTML-документа соответствующих тегов, создает внутри DOM элементы html и body, и все элементы, использующиеся для структурирования контента, в любом случае считает вложенными внутрь body. Так что упоминание этого элемента в составе комбинированного селектора в лучшем случае никак не повлияет ни на что, включая производительность.

(С точки зрения буквы спецификаций HTML 4.01 и HTML5 пара тегов <body> и </body> не является обязательной для построения минимального валидного документа. Ну, это так, к слову — лучше забудьте… Элемент уровня body имеет смысл упоминать в составе CSS-селекторов разве что только в том случае, если ему назначен какой-либо идентификатор или класс с целью обеспечения различий в «глобальных» стилях для разных страниц сайта — но, как вы понимаете, достаточно только указания имени этого самого идентификатора или класса, тогда как упоминание всуе типа элемента является откровенно излишним.)

Упоминание ul, кажется, уже успели обсудить выше.

Упоминание li не нужно, поскольку в приличном HTML-коде элементы ul всегда содержат внутри себя элементы li в качестве непосредственных потомков, и все то, что вложено внутрь li, автоматически вложено и внутрь ul.

Целевые объекты селектора

Крайняя правая часть комбинированного селектора имеет особое значение. Она определяет целевые объекты селектора — the subjects of a selector. (Ссылка указывает на соответствующий фрагмент черновика спецификации Selectors Level 4, в которой этим самым целевым объектам выделен обособленный раздел — но сам по себе термин впервые определен еще в CSS2, разве что более сжато.)

Для лучшего понимания обсуждаемой сущности приведем такой пример.

.content pre code {…}

Целевыми объектами данного селектора являются элементы code (что определяется крайней справа составляющей комбинированного селектора, ага).

Это означает, что применительно к любому HTML-документу наше CSS-правило может повлиять только на элементы типа code. Да, в предельном случае это будут вообще все имеющиеся в структуре конкретного документа элементы code. Но, в то же самое время, данное CSS-правило при любом возможном раскладе никак не сможет затронуть никакие другие элементы какого бы то ни было документа помимо элементов типа code.

Авторами черновика Selectors Level 4 предложен механизм, который позволил бы веб-разработчикам переопределять целевые объекты селекторов. Непосредственно в спецификации приводится следующий пример. Вот такое CSS-правило предсказуемо будет применяться к элементам li, являющимся единственными прямыми потомками некоторых элементов ol:

ol > li:only-child {…}

Если же слегка модифицировать наш селектор, относящееся к нему CSS-правило будет применяться к элементам ol, имеющим по одному дочернему элементу li:

ol! > li:only-child {…}

(Авторы спецификации, если верить самой свежей на момент написания статьи версии ее редакторского черновика, остановились на восклицательном знаке, но прежде на его роль претендовали также символ доллара и вопросительный знак.)

Два селектора, приведенные выше, связаны с одним и тем же подмножеством структуры DOM-дерева, но имеют различные целевые объекты. Во втором случае целевыми объектами селектора являются все элементы ol.

Описанный механизм призван реализовать функциональность давно запрошенного профессиональным сообществом веб-разработчиков селектора родительского элемента (аналогичного селектору has(…), имеющемуся в jQuery). Наверное, нам тут нет смысла описывать, почему это будет работать медленно в случае честной реализации в браузерах — на эту тему есть исчерпывающая статья Джонатана Снука, переведенная на русский язык. Отметим лишь то, что предложенная авторами черновика Selectors Level 4 концепция, несомненно, отличается большей гибкостью, и ее применимость выходит далеко за рамки предназначения селектора родителя.

Напоследок рассмотрим пару насущных вопросов, напрямую связанных с целевыми объектами селекторов.

Равнение направо!

В рамках разобранного нами классического сценария обработки CSS-селекторов крайняя справа часть комбинированного селектора весьма существенно влияет на производительность. Скажем, вот такое будет работать очень медленно:

#intro * {…}

Тут не следует обольщаться наличием простого селектора по уникальному идентификатору где-то в составе комбинированного селектора (слева или в серединке). Браузеры, обрабатывающие селекторы по сценариям, близким к классическому, будут для каждого элемента HTML-документа осуществлять проверку вложенности данного элемента внутрь некоего элемента с идентификатором intro.

Заметьте, «каждый элемент» в данном случае — это как раз и суть целевые объекты нашего селектора.

Конечно, в ряде браузеров осуществляется дополнительная оптимизация производительности обработки селекторов, выходящая за рамки эталонного сценария. Согласно наблюдениям и тестам, можно сделать вывод о том, что некоторые браузеры, столкнувшись с простым селектором по идентификатору или классу где-либо в составе комбинированного селектора при анализе последнего, изменяют направление обхода DOM-дерева или же добавляют соответствующие правила в гипотетический «индекс». Но все же следует учитывать, что далеко не все браузеры ведут себя столь «интеллектуально». В ходе реальной практики веб-разработки имеет смысл всегда ориентироваться на наихудший вариант — обработку браузерами селекторов по классическому сценарию без дополнительной внутренней оптимизации.

Крайняя справа часть комбинированного селектора сразу же жестко ограничивает подмножество элементов HTML-документа, к которым в принципе может быть применено соответствующее CSS-правило — и, как следствие, подмножество элементов, в отношении которых будут осуществляться дополнительные ресурсоемкие проверки в процессе установления применимости правила. Воистину, грех этим не пользоваться. По крайней мере, грех ставить «звездочку» на столь почетное правое место. :-)

Часто задают вопрос: как влияют на производительность селекторы, в составе которых упоминаются элементы, не использующиеся в данном конкретном HTML-документе? Являют ли собой соответствующие CSS-правила нечто большее, чем просто лишние килобайты данных, передаваемых по каналу связи?

В рамках пристально рассмотренного нами классического сценария все будет зависеть от того, в каком контексте в составе селектора упоминается неиспользуемый элемент. Предположим, в структуре текущего обрабатываемого HTML-документа нет элементов типа aside, но есть элементы section и div. Тогда, очевидно, CSS-правила

aside {…}

и даже

div section aside {…}

практически не скажутся на производительности обработки CSS-кода. Установить, что текущий обрабатываемый элемент не является элементом типа aside — дело одного мгновения, каковым вполне можно пренебречь.

А вот временем обработки правила наподобие такого:

section aside div {…}

в общем случае пренебречь уже нельзя, поскольку в рамках классического сценария тут надлежит проверить вложенность каждого встретившегося элемента div внутрь некоторого элемента aside.

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

[title*="foo"] * {…}

даже при гарантированном отсутствии элементов с какими угодно значениями атрибута title в коде разметки будет обрабатываться во всех наличествующих в реальной жизни браузерах едва ли быстрее, чем бесконечно долго…

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

Продолжение следует…