Эта статья - перевод поста в блоге Alex, Baton Corporation Team Lead, вышедшего 20 февраля 2026 г.

Я публикую этот перевод, потому что сам много экспериментировал с переносом на этап сборки применение Tailwind стилей, убрав их из runtime. Хоть мне и не удалось добиться таких впечатляющих результатов, как автору статьи, опыт был сугубо положительный, скорость загрузки и многие другие метрики, связанные с отзывчивостью интерфейса, ощутимо выросли.

Далее следует перевод


Когда пришло время представить миру приложение Pump.fun (торговля мем-коинами), React Native оказался наиболее удачным решеним, учитывая небольшую команду и уже имеющийся опыт с JavaScript/React. Он полностью оправдал наши ожидания, позволив быстро разработать легко и поддерживать обе основные платформы, при этом значительно снизив порог входа для команды, привыкшей к вебу и практически не имевшей опыта в мобильной разработке.

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

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

Проблемы производительности

Когда токен торгуется очень активно, он может получать около ~1000 сделок в секунду. Наш сервис индексирования транслирует эти данные через NATS-топик по WebSocket. На некоторых экранах мы отображаем 10 и более токенов одновременно, и все они могут находиться в таком «горячем» состоянии, что в худшем случае приводит к 10 000 событий в секунду.

Разумеется, человеческий глаз не способен воспринимать 1000 обновлений цены в секунду, как и React Native не может корректно отрисовать такое количество обновлений одновременно. Поэтому мы ограничиваем частоту до 5 Гц на монету, что всё равно может приводить к 50 апдейтам в секунду на некоторых экранах. Эти обновления отображаются в виде мигающего индикатора изменения цены, реализованного с помощью Reanimated (пример можно увидеть в правой колонке главного экрана), а также используются для построения мини-графиков (тоже видно на скриншоте).

Когда экран получает большое количество обновлений цен, наша телеметрия показывает, что поток JS нач��нает значительно проседать (до 20 FPS даже на мощных устройствах), а также устройство ощутимо греется. Мы собираем эту телеметрию посредством регулярного сэмплирования метрик и отправляем их в DataDog вместе с другими полезными метаданными — текущий маршрут, устройство и т.д., чтобы быстро определять наиболее проблемные экраны.

Нам нужно было найти решение, и первым шагом стало выяснить, на что именно тратится время в JS-потоке.

Решение

Коллега, который недавно занимался обновлением приложения до Expo 54, также добавил библиотеку React Native Release Profiler, позволяющую профилировать релизную сборку — ранее это было сложно сделать. Мы запустили экран «movers» примерно на 30 секунд, затем открыли heavy left view в Speedscope. Одним из первых наблюдений стало то, что около 3,5% CPU-времени тратится на применение стилей через cssInterop, благодаря Nativewind.

Одним из первых решений при создании приложения было использование Nativewind, так как Tailwind уже применялся в нашем веб-коде, что ещё больше снижало порог входа. Библиотека отлично работала, и до этого момента мы не замечали проблем. Тот же коллега предложил попробовать новый проект React Native Tailwind (RNT), который преобразует проп className в вызовы StyleSheet.create() на этапе сборки.

Это выглядело как простое решение: всё должно было «просто заработать» на этапе сборки, а применение стилей стало бы значительно быстрее (многие бенчмарки показывают, что этот метод — самый быстрый способ задания стилей).

Однако в реальности всё оказалось не так просто. После добавления библиотеки многие стили просто сломались. Тем не менее, мы решили продолжить, поскольку оптимизации, влияющие на runtime по всему приложению, по нашему мнению, стоит того.

Ограничения RNT

Одной из первых проблем стало то, что библиотека (на тот момент) не поддерживала метод size-n для одновременного задания ширины и высоты в одном классе. Я решил, что самым быстрым способом исправить это будет добавление Babel-плагина-препроцессора, который преобразует size-n в w-n и h-n, после чего RNT сможет обработать их как обычно. Это решило конкретную проблему, но оставалось ещё немало сломанных стилей.

Также не поддерж��вались классы gap, например gap-n, которые преобразуются в стили column-gap и row-gap. Мы добавили поддержку таких классов. Они создаются как отдельный объект StyleSheet, а затем объединяются с результатом RNT в нашем постпроцессоре Babel.

Ещё одной проблемой оказался класс pointer-events-none, который не поддерживался RNT, но фактически работал с Nativewind. Эти классы не превращаются в стили как таковые, а добавляют проп pointerEvents к компонентам View/Text.

К счастью, на этапе сборки RNT логирует классы, которые он не может преобразовать, поэтому было довольно просто пройтись по каждому случаю и понять, что идет не так. К нашему удивлению, у нас оказалось около десятка классов, которые вообще не существовали, а также ещё куча классов, которые на самом деле не применимы к React Native — они были унаследованы из Tailwind и помечены как «только для web» в документации Nativewind.

Формирование baseline

Чтобы устранить проблему, я добавил пакет с eslint-правилом no-custom-classname, которое помечает несуществующие классы Tailwind. К сожалению, оно ориентировано только на веб, поэтому ловило лишь полностью вымышленные классы, но не случаи использования web-only классов.

Чтобы обнаружить такие случаи, я расширил конфигурацию нашего существующего eslint-правила «forbidden classes», добавив блок-лист на основе документации Nativewind. Затем я прошёлся по каждому новому предупреждению eslint с небольшой тестовой утлитой, чтобы убедиться, что класс действительно не используется в RN. Это дало нам хорошую базу — добавляться должны только полезные классы. Тем не менее, ост��валось много классов, которые RNT не поддерживал. Я добавил поддержку и для них.

Runtime-классы всё ещё необходимы

Некоторые классы должны были оставаться доступными во время выполнения для Nativewind, например классы с префиксом safe, которые определяют безопасную область рендеринга с учётом safe insets. Я рассматривал вариант расширить трансформер, чтобы импортировать useSafeInsets и вручную добавлять значения в style, но пока решил этого не делать и просто пометил их как runtime-классы, не обрабатываемые на этапе сборки.

Другие примеры — префиксы disabled и active. Они поддерживаются RNT, но только при использовании специальных обёрток библиотеки, что я пока не хотел внедрять (у нас сотни таких использований, которые пришлось бы вручную проверять). Также это касается классов transition и animation.

Чтобы всё это работало, препроцессор переписывает runtime-классы в expando-подобный проп компонента, а затем постпроцессор возвращает их обратно в className, чтобы Nativewind мог обработать их во время выполнения.

Динамические классы не конвертируются

Во многих компонентах мы используем Class Variance Authority, и RNT пока не поддерживает этот случай. Пока такие классы продолжают применяться во время выполнения. На момент написания существует открытый PR для их поддержки.

Проблемы конкатенации className

Многие компоненты нашей дизайн-системы принимают проп className, который объединяется с внутренним className обёртки для кастомизации компонента. Это выглядело как антипаттерн, так как мы стремимся к консистентности дизайн-системы, а подобный «escape hatch» всё ломает.

Но главная проблема в том, что из-за runtime-конкатенации className мы не могли безопасно перенести это на этап сборки. Ситуация осложнялась тем, что некоторые компоненты принимали и className, и style, но применяли их к разным внутренним компонентам.

Например, если компонент принимает динамический className, объединяет его с внутренними классами, а мы преобразуем их в style через tw template tag, то они получают приоритет над runtime-className. В результате стили ломаются (className больше не переопределяет существующие классы, так как они теперь заданы в style).

В приведённом ниже примере невозможно статически определить className (в самом примере можно, но представьте, что поисходит в глубине стека вызовов). Если отрендерить оба компонента с одинаковыми пропсами, первый (со стилями на этапе сборки) будет иметь жёлтый фон (нежелательно), а второй (runtime-стили) — розовый (как задумано).

function getClassName() {
 return "bg-pink-500";
 }

// Создаем стили
 function ClassNameExample({
 className,
 style
 }: {
 isActive: boolean;
 className: string;
 style: TwStyle;
 }) {
 const backgroundColor = twbg-yellow-500;
 return (
 <View className={className} style={[backgroundColor.style, style]}>
 
 
 );
 }

// Исполняем стили
 function ClassNameExample2({
 className,
 style
 }: {
 isActive: boolean;
 className: string;
 style: TwStyle;
 }) {
 const backgroundColor = "bg-yellow-500";
 return (
 <View className={cn(backgroundColor, className)} style={style}>
 
 
 );
 }

// Желтый бекграунд ❌
 

// Розовый бекграунд ✅

Таких примеров тысячи, поэтому переписывать и проверять каждый - нереально. План состоит в том, чтобы использовать более специализированные пропсы в базовых компонентах дизайн-системы, чтобы применять нужные стили внутри — это также повысит консистентность. Сейчас мы как раз перерабатываем эти компоненты.

Кроме того, у нас есть функция cn(), которая отбрасывает falsy-значения, позволяя писать что-то вроде cn("p-2", isRed && "bg-red-500"). RNT это не поддерживает, так как это наша кастомная реализация. Чтобы сохранить этот паттерн, я преобразовал его в строковые шаблоны в препроцессоре — их RNT уже поддерживает.

Инструменты

В процессе было очень полезно видеть исходный и измененный файлы рядом, чтобы вручную проверять корректность преобразования. Я написал небольшой скрипт, который отображал исходник слева и результат справа. Это оказалось намного быстрее, чем искать код в JS-бандле через девтулзы RN, хотя для финальной проверки я использовал и их (у нас несколько Babel-трансформаций, работающих совместно, например React Compiler).

Деплой

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

Для повышения уверенности я привлёк внутреннюю QA-команду с инструкцией: «протестировать каждый экран в каждом состоянии».

QA-команда выполнила эту ��олоссальную задачу и обнаружила несколько проблем с обрезанием текста. Я исправил это, добавив line-height в препроцессоре для размеров текста. Некоторые сложные экраны с Reanimated демонстрировали странное поведение — я ограничил время на исправления, а в ряде случаев использовал ignore-функциональность Babel, чтобы пропустить конвертацию отдельных файлов. В итоге таких файлов оказалось всего несколько.

Результат

Профилируя тот же сценарий в нативной сборке, я увидел, что 3,5% времени CPU, ранее уходившие на cssInterop, сократились до 0,01%. Это было очень воодушевляюще.

Помимо освобождения runtime-ресурсов, переходы между роутами ускорились примерно на 10%.

Самым значительным изменением, хотя мы и не ожидали такого масштаба, стало время запуска приложения. На iOS среднее время запуска по телеметрии составляло около 1,5 секунды, а после релиза изменений сократилось примерно до 110 мс. На Android запуск также стал значительно быстрее.

Мы не только улучшили общую производительность приложения, но и существенно сократили TTI (time to interactive) для пользователей. Поскольку изменение происходило на этапе сборки, мы не могли провести классическое A/B-тестирование, но предыдущие эксперименты показывали корреляцию между объёмом торгов (одной из наших ключевых метрик) и улучшениями производительности. Другими словами, чем тяжелее интерфейс, тем значительнее было изменение в производительности.

Что дальше?

Одной из целей RNT было полностью отказаться от Nativewind и сократить размер бандла. К сожалению, из-за упомянутых runtime-классов и особенностей их объединения это пока невозможно.

Вскоре после релиза RNT добавил поддержку классов size-n, и я смог удалить соответствующую часть нашего препроцессора.

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

Огромная благодарность Olivier Louvignes за саму библиотеку RNT. Было бы здорово внести недостающую функциональность обратно в библиотеку, чтобы это принесло пользу всем.


Обратная связь по переводу