[Перевод] Скрытая цена CSS-in-JS-библиотек в React-приложениях

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

u6f3crp6pcjspyohewwxptq84ve.png

Обзор ситуации


В моей компании решено было создать UI-библиотеку. Это принесло бы нам немалую пользу, позволило бы многократно использовать стандартные фрагменты интерфейсов в различных проектах. Я был одним из добровольцев, взявшихся за решение этой задачи. Я решил использовать технологию CSS-in-JS, так как уже был знаком с API стилизации большинства популярных CSS-in-JS-библиотек. В ходе работы я стремился к тому, чтобы поступать разумно. Я проектировал логику, подходящую для многократного использования, и применял в компонентах свойства, используемые совместно. Поэтому я занялся композицией компонентов. Например, компонент расширял компонент , который, в свою очередь, представлял собой реализацию простой сущности 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 элементов.

После рендеринга компонента выводились два разных дерева React. На следующих рисунках представлены деревья элементов, выведенные React.

5b74c11614f8364a88d3cf204c12c7f3.png


Дерево, выведенное в приложении, в котором используется обычный элемент div

370d8b51371c91ac2c3106c636522711.png


Дерево, выведенное в приложении, в котором используется styled.div

Затем я, с помощью кнопки, отрендерил 10 раз для того, чтобы собрать данные, касающиеся нагрузки на систему, которую создают дополнительные компоненты Context.Consumer. Вот сведения о многократном повторном рендеринге приложения с обычными элементами div в режиме разработки.

c255efc205d63352d9e966a8aefbbff7.png


Повторный рендеринг приложения с обычными элементами div в режиме разработки. Среднее значение — 2.54 мс.

ddc5b114a6e93cc4dbd7fe52ddf8aa26.png


Повторный рендеринг приложения с элементами styled.div в режиме разработки. Среднее значение — 3.98 мс.

Очень интересно то, что, в среднем, CSS-in-JS-приложение оказывается на 56.6% медленнее обычного. Но это был режим разработки. А как насчёт продакшн-режима?

93707d34db954762fda1fb82f41fa4ec.png


Повторный рендеринг приложения с обычными элементами div в продакшн-режиме. Среднее значение — 1.06 мс.

d66b66400375542ed0644cfd7c962e11.png


Повторный рендеринг приложения с элементами 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-классы во время сборки страниц. Вместо этого они динамически генерируют и обновляют теги


А вот какие действия выполняет библиотека styled-components при выводе соответствующего React-компонента:

  1. Производит парсинг CSS-правил из шаблонной строки styled-components.
  2. Генерирует новое имя класса CSS (или выясняет — следует ли оставить прежнее имя).
  3. Выполняет препроцессинг стилей с помощью stylis.
  4. Внедряет CSS, получившийся в результате препроцессинга, в соответствующий тег