В современных фронтенд-приложениях технология CSS-in-JS пользуется определённой популярностью. Всё дело в том, что она даёт разработчикам механизм работы со стилями, который удобнее обычного CSS. Не поймите меня неправильно. Мне очень нравится CSS, но создание хорошей CSS-архитектуры — задача не из простых. Технология CSS-in-JS может похвастаться некоторыми серьёзными преимуществами перед обычными CSS-стилями. Но, к сожалению, применение CSS-in-JS способно, в определённых приложениях, привести к проблемам с производительностью. В этом материале я попытаюсь разобрать высокоуровневые особенности наиболее популярных CSS-in-JS-библиотек, расскажу о некоторых проблемах, которые иногда возникают при их использовании, и предложу способы смягчения этих проблем.
Обзор ситуации
В моей компании решено было создать UI-библиотеку. Это принесло бы нам немалую пользу, позволило бы многократно использовать стандартные фрагменты интерфейсов в различных проектах. Я был одним из добровольцев, взявшихся за решение этой задачи. Я решил использовать технологию CSS-in-JS, так как уже был знаком с API стилизации большинства популярных CSS-in-JS-библиотек. В ходе работы я стремился к тому, чтобы поступать разумно. Я проектировал логику, подходящую для многократного использования, и применял в компонентах свойства, используемые совместно. Поэтому я занялся композицией компонентов. Например, компонент <IconButton />
расширял компонент <BaseButton />
, который, в свою очередь, представлял собой реализацию простой сущности styled.button
. К сожалению, оказалось, что IconButton
нуждается в собственной стилизации, поэтому я преобразовал этот компонент в стилизованный компонент:
const IconButton = styled(BaseButton)`
border-radius: 3px;
`;
По мере того, как в нашей библиотеке появлялось все больше и больше компонентов, мы использовали всё больше и больше операций композиции. Подобное не казалось чем-то противоестественным. Ведь композиция — это, по сути, основа React. Всё было хорошо до тех пор, пока я не создал компонент Table
. У меня начало возникать такое ощущение, что этот компонент рендерился медленно. Особенно — в ситуациях, когда число строк таблицы превышало 50. Это было неправильно. Поэтому я начал разбираться в проблеме, прибегнув к инструментам разработчика.
Кстати, если вы задавались когда-нибудь вопросом о том, почему CSS-правила не удаётся редактировать с помощью инспектора инструментов разработчика, знайте, что это из-за того, что они используют CSSStyleSheet.insertRule(). Это — очень быстрый способ модификации таблиц стилей. Но одним из его недостатков является тот факт, что соответствующие таблицы стилей больше нельзя редактировать средствами инспектора.
Не стоит и говорить о том, что дерево, генерируемое React, было прямо-таки огромным. Количество компонентов Context.Consumer
было так велико, что это могло бы лишить меня сна. Дело в том, что каждый раз, когда единственный стилизованный компонент рендерится с использованием styled-components или emotion, то, помимо создания обычного компонента React, создаётся ещё и дополнительный компонент Context.Consumer
. Это нужно для того, чтобы позволить соответствующему скрипту (большинство CSS-in-JS-библиотек зависят от скриптов, выполняющихся во время работы страницы) правильно обрабатывать сгенерированные правила стилизации. Обычно это особых проблем не вызывает, но нельзя забывать о том, что компоненты должны иметь доступ к теме. Это выливается в необходимость рендеринга дополнительных Context.Consumer
для каждого стилизованного элемента, что позволяет «читать» тему из компонента ThemeProvider
. В итоге, при создании стилизованного компонента в приложении с темой, создаются 3 компонента. Это — один компонент StyledXXX
и ещё два компонента Context.Consumer
.
Правда, ничего особенно страшного тут нет, так как React делает свою работу быстро, а значит — в большинстве случаев беспокоиться нам не о чем. Но что если несколько стилизованных компонентов собирают для того, чтобы создать более сложный компонент? Что если этот сложный компонент является частью длинного списка или большой таблицы, где рендерится как минимум 100 подобных компонентов? Вот в подобных ситуациях мы и сталкиваемся с проблемами…
Профилирование
Для того чтобы протестировать разные CSS-in-JS-решения, я создал простейшее приложение. Оно 50 раз выводит текст Hello World
. В первом варианте этого приложения я обернул этот текст в обычный элемент div
. Во втором — использовал компонент styled.div
. Кроме того, я добавил в приложение кнопку, которая вызывает повторный рендеринг всех этих 50 элементов.
После рендеринга компонента <App />
выводились два разных дерева React. На следующих рисунках представлены деревья элементов, выведенные React.
Дерево, выведенное в приложении, в котором используется обычный элемент div
Дерево, выведенное в приложении, в котором используется styled.div
Затем я, с помощью кнопки, отрендерил <App />
10 раз для того, чтобы собрать данные, касающиеся нагрузки на систему, которую создают дополнительные компоненты Context.Consumer
. Вот сведения о многократном повторном рендеринге приложения с обычными элементами div
в режиме разработки.
Повторный рендеринг приложения с обычными элементами div в режиме разработки. Среднее значение — 2.54 мс.
Повторный рендеринг приложения с элементами styled.div в режиме разработки. Среднее значение — 3.98 мс.
Очень интересно то, что, в среднем, CSS-in-JS-приложение оказывается на 56.6% медленнее обычного. Но это был режим разработки. А как насчёт продакшн-режима?
Повторный рендеринг приложения с обычными элементами div в продакшн-режиме. Среднее значение — 1.06 мс.
Повторный рендеринг приложения с элементами styled.div в продакшн-режиме. Среднее значение — 2.27 мс.
Когда включён продакшн-режим, div-реализация приложения, похоже, оказывается быстрее более чем на 50%, в сравнении с такой же версией в режиме разработки. А styled.div-приложение оказывается быстрее лишь на 43%. И тут, как и прежде, видно, что CSS-in-JS-решение практически в два раза медленнее обычного решения. Что же его замедляет?
Анализ приложения во время его выполнения
Очевидным ответом на вопрос о том, что замедляет CSS-in-JS-приложение, может быть следующий: «Было ведь сказано, сто CSS-in-JS-библиотеки рендерят по два Context.Consumer
на каждый компонент». Но если как следует над всем этим подумать, то Context.Consumer
— это просто механизм для доступа к JS-переменной. Конечно, React нужно проделать определённую работу для того, чтобы выяснить то, откуда нужно читать соответствующее значение, но одно только это не объясняет вышеприведённых результатов измерений. Реальный ответ на этот вопрос можно найти, проанализировав причину использования Context.Consumer
. Дело в том, что большинство CSS-in-JS-библиотек полагаются на скрипты, выполняющиеся во время вывода страницы в браузере, которые помогают библиотекам динамически обновлять стили компонентов. Эти библиотеки не создают CSS-классы во время сборки страниц. Вместо этого они динамически генерируют и обновляют теги <style>
в документе. Делается это тогда, когда компоненты монтируются, или тогда, когда меняются их свойства. Эти теги обычно содержат единственный CSS-класс, хэшированное имя которого сопоставляется с единственным компонентом React. Когда меняются свойства этого компонента, должен измениться и соответствующий ему тег <style>
. Вот как можно описать то, что делается в ходе этого процесса:
- Выполняется повторное формирование CSS-правил, которыми должен обладать тег
<style>
. - Создаётся новое хэшированное имя класса, используемого для хранения вышеупомянутых CSS-правил.
- Производится обновление свойства
classname
соответствующего React-компонента на новое, указывающее на только что созданный класс.
Рассмотрим, например, библиотеку styled-components
. При создании компонента styled.div
библиотека назначает этому компоненту внутренний идентификатор (ID) и добавляет в HTML-тег <head>
новый тег <style>
. Этот тег содержит единственный комментарий, который ссылается на внутренний идентификатор компонента React, к которому относится соответствующий стиль:
<style data-styled-components>
/* sc-component-id: sc-bdVaJa */
</style>
А вот какие действия выполняет библиотека styled-components при выводе соответствующего React-компонента:
- Производит парсинг CSS-правил из шаблонной строки styled-components.
- Генерирует новое имя класса CSS (или выясняет — следует ли оставить прежнее имя).
- Выполняет препроцессинг стилей с помощью stylis.
- Внедряет CSS, получившийся в результате препроцессинга, в соответствующий тег
<style>
HTML-тега<head>
.
Для того чтобы получить возможность использования темы на шаге №1 этого процесса, необходим Context.Consumer. Благодаря этому компоненту в шаблонной строке выполняется чтение значений из темы. Для того чтобы получить возможность модифицировать из компонента связанный с этим компонентом тег <style>
, нужен ещё один Context.Consumer
. Он позволяет получить доступ к экземпляру таблицы стилей. Именно поэтому в большинстве CSS-in-JS-библиотек мы и сталкиваемся с двумя экземплярами Context.Consumer
.
В дополнение к этому, так как все эти вычисления воздействуют на пользовательский интерфейс, надо отметить, что они должны быть выполнены в ходе фазы рендеринга компонента. Их нельзя выполнять в коде обработчиков событий жизненного цикла React-компонентов (так их выполнение может быть отложено и будет выглядеть для пользователя как медленное формирование страницы). Именно поэтому рендеринг styled.div-приложения оказывается медленнее, чем рендеринг обычного приложения.
Всё это было замечено разработчиками styled-components. Они оптимизировали библиотеку для того, чтобы снизить время повторного рендеринга компонентов. В частности, библиотека выясняет, является ли стилизованный компонент «статическим». То есть — зависят ли стили компонента от темы или от свойств, которые ему передаются. Например, ниже показан статический компонент:
const StaticStyledDiv = styled.div`
color:red
`;
А этот компонент статическим не является:
const DynamicStyledDiv = styled.div`
color: ${props => props.color}
`;
Если библиотека выясняет, что компонент является статическим, она пропускает вышеописанные 4 шага, понимая, что сгенерированное имя класса ей никогда менять не придётся (так как тут отсутствует динамический элемент, для которого может понадобиться менять связанные с ним CSS-правила). Кроме того, библиотека в такой ситуации не выводит ThemeContext.Consumer
вокруг стилизованного компонента, так как зависимость от темы уже не позволила бы считать компонент «статическим».
Если вы были достаточно внимательны, анализируя ранее представленные копии экрана, то вы могли заметить, что даже в продакшн-режиме для каждого styled.div
выводятся два компонента Context.Consumer
. Довольно интересно то, что компонент, который рендерился, был «статическим», так как с ним не связано никаких динамических CSS-правил. В такой ситуации можно было бы ожидать, что, будь этот пример написан с использованием библиотеки styled-components, она не вывела бы Context.Consumer
, нужный для работы с темой. Причина, по которой тут выводятся именно два Context.Consumer
, заключается в том, что эксперимент, данные которого приведены выше, проводился с использованием emotion — ещё одной CSS-in-JS-библиотеки. Данная библиотека применяет практически такой же подход, что и styled-components. Различия между ними невелики. Итак, библиотека emotion парсит шаблонную строку, выполняет препроцессинг стилей с помощью stylis и обновляет содержимое соответствующего тега <style>
. Тут, однако, надо отметить одно ключевое различие между styled-components и emotion. Оно заключается в том, что библиотека emotion всегда оборачивает все компоненты в ThemeContext.Consumer
— вне зависимости от того, используют они тему или нет (это и объясняет внешний вид вышеприведённого скриншота). Довольно интересно то, что даже хотя emotion рендерит больше Consumer-компонентов, чем styled-components, emotions выигрывает у styled-components в плане производительности. Это указывает на то, что количество компонентов Context.Consumer
— это не главный фактор замедления рендеринга. Стоит отметить, что во время написания этого материала вышла бета-версия styled-components v5.x.x, которая, по данным разработчиков библиотеки, обходит emotion в плане производительности.
Обобщим то, о чём мы тут говорили. Оказывается, что комбинация множества элементов Context.Consumer
(а это означает, что React приходится координировать работу дополнительных элементов) и внутренних механизмов динамической стилизации может замедлить приложение. Надо сказать и о том, что все теги <style>
, добавляемые в <head>
для каждого компонента, никогда не удаляются. Это так из-за того, что операции по удалению элементов создают большую нагрузку на DOM (например, браузеру приходится из-за этого выполнять перекомпоновку страницы). Эта нагрузка выше дополнительной нагрузки на систему, вызываемой наличием на странице ненужных элементов <style>
. Если честно, то я не могу с уверенностью говорить о том, что ненужные теги <style>
могут вызывать проблемы с производительностью. Они ведь просто хранят неиспользуемые классы, сгенерированные во время работы страницы (то есть эти данные не передавались по сети). Но об этой особенности использования технологии CSS-in-JS стоит знать.
Надо сказать, что теги <style>
создают не все CSS-in-JS-библиотеки, так как не все они основаны на механизмах, действующих во время работы страниц в браузерах. Например, библиотека linaria вообще ничего не делает во время работы страницы в браузере.
Она определяет набор фиксированных CSS-классов в процессе сборки проекта и настраивает соответствия всех динамических правил в шаблонной строке (то есть — CSS-правил, зависящих от свойств компонента) с кастомными CSS-свойствами. В результате при изменении свойства компонента изменяется CSS-свойство и меняется внешний вид интерфейса. Благодаря этому linaria гораздо быстрее библиотек, полагающихся на механизмы, действующие во время работы страниц. Всё дело в том, что при применении этой библиотеки системе приходится выполнять гораздо меньше вычислений во время рендеринга компонентов. Единственное, что при использовании linaria нужно делать во время рендеринга — это не забывать обновлять кастомное CSS-свойство. В то же время, однако, такой подход несовместим с IE11, он отличается ограниченной поддержкой популярных CSS-свойств и, без дополнительной настройки, не позволяет пользоваться темами. Как и в случае с другими сферами веб-разработки, среди CSS-in-JS-библиотек нет идеальной, подходящей на все случаи жизни.
Итоги
Технология CSS-in-JS в своё время выглядела как революция в сфере стилизации. Она облегчила жизнь множеству разработчиков, а также позволила, без дополнительных усилий, разрешить массу проблем, таких, как коллизии имён и использование префиксов производителей браузеров. Эта статья написана для того, чтобы пролить немного света на вопрос о том, как популярные CSS-in-JS-библиотеки (те, которые управляют стилями во время работы страницы) способны повлиять на производительность веб-проектов. Мне хотелось бы обратить особое внимание на то, что влияние этих библиотек на производительность далеко не всегда приводит к появлению заметных проблем. На самом деле, в большинстве приложений это влияние и вовсе незаметно. Проблемы могут появиться в приложениях, имеющих страницы, содержащие сотни сложных компонентов.
Преимущества CSS-in-JS обычно перевешивают возможные негативные последствия применения этой технологии. Однако недостатки CSS-in-JS стоит учитывать тем разработчикам, в приложениях которых выполняется визуализация огромных объёмов данных, тем, проекты которых содержат множество постоянно изменяющихся элементов интерфейса. Если вы подозреваете, что ваше приложение подвержено негативному влиянию CSS-in-JS, то, прежде чем заняться рефакторингом, стоит всё как следует оценить и измерить.
Вот несколько советов по поводу повышения производительности приложений, в которых используются популярные CSS-in-JS-библиотеки, которые делают своё дело во время работы страниц в браузере:
- Не стоит чрезмерно увлекаться композицией стилизованных компонентов. Постарайтесь не повторять той ошибки, о которой я рассказывал в начале, и не пытаться, ради создания несчастной кнопки, строить композицию из трёх стилизованных компонентов. Если вы хотите «многократно» использовать код — задействуйте CSS-свойство и занимайтесь композицией шаблонных строк. Это позволит вам обойтись без множества ненужных компонентов
Context.Consumer
. В результате React придётся управлять меньшим количеством компонентов, что приведёт к росту производительности проекта. - Стремитесь использовать «статические» компоненты. Некоторые CSS-in-JS-библиотеки оптимизируют генерируемый код в том случае, если стили компонента не зависят от темы или от свойств. Чем больше «статики» имеется в шаблонных строках, тем выше вероятность того, что скрипты CSS-in-JS-библиотек будут выполняться быстрее.
- Постарайтесь избежать ненужных операций повторного рендеринга React-приложений. Стремитесь к тому, чтобы рендеринг выполнялся бы только тогда, когда он действительно нужен. Благодаря этому систему не будут нагружать ни действия React, ни действия CSS-in-JS-библиотек. Повторный рендеринг — это та операция, которая должна выполняться только в исключительных случаях. Например — при одновременном выводе большого количества «тяжёлых» компонентов.
- Выясните, подойдёт ли для вашего проекта CSS-in-JS-библиотека, которая не задействует скрипты, выполняемые во время работы страницы в браузере. Иногда мы выбираем технологию CSS-in-JS из-за того, что разработчику удобнее пользоваться ей, а не различными JavaScript-API. Но если вашему приложению не нужна поддержка тем, если в нём не наблюдается интенсивного использования CSS-свойств, тогда вполне возможно то, что вам подойдёт CSS-in-JS-библиотека, вроде linaria, не использующая скрипты, выполняемые во время работы страницы. Такой подход, кроме того, позволит снизить размер бандла приложения примерно на 12 Кб. Дело в том, что объём кода большинства CSS-in-JS-библиотек укладывается в 12-15 Кб, а код той же linaria меньше 1 Кб.
Уважаемые читатели! Пользуетесь ли вы CSS-in-JS-библиотеками?
Автор: ru_vds