
– Открывай dart.dev! – Обычная Димина напористость сегодня была приправлена доброй горстью нетерпеливости. – Открыл? Теперь листай вниз! Что ты видишь в правом нижнем углу?
– Ссылки всякие: на Github там, Twitter и т.п. – в растерянности я пытался угадать, на что именно нужно обратить внимание.
Кажется, Дима по ту сторону звонка схватился за голову.
– Видишь баннер “Built with Jaspr”?!
Я молча кивнул и кликнул по баннеру.
Уже несколько лет мы занимаемся развитием клиентского приложения на Flutter: начинали с мобильных платформ, когда веб и десктоп вышли в бету – портировали его и на эти платформы. Последние два года PWA крутится в проде и в некоторых регионах является самой популярной платформой для нашего приложения.
Недавно мы попробовали мигрировать наше приложение с Flutter Web на Jaspr – под катом расскажем о том, почему мы это сделали, с какими трудностями столкнулись и какие выводы сделали.
Что за Jaspr такой?
Jaspr — это современный веб-фреймворк с открытым исходным кодом, написанный на языке Dart и предназначенный для создания быстрых, производительных веб-сайтов и веб-приложений.
Главная идея Jaspr заключается в том, чтобы перенести компонентный подход и реактивную модель разработки из Flutter в нативный веб. В отличие от классического Flutter Web, который рендерит интерфейс на Canvas, Jaspr генерирует стандартный HTML и CSS, как это делают React, Vue или Angular.
Фреймворк поддерживает рендеринг на стороне сервера (SSR), генерацию статических сайтов (SSG) и классические одностраничные приложения (SPA).
Очень рекомендую посмотреть сайт проекта, там наглядно показано, чем Jaspr похож на Flutter, а в чём они отличаются.
Что не так с Flutter Web?
Для нас главная ахиллесова пята приложений на Flutter Web – долгий холодный старт. Дело в том, что PWA, написанное на Flutter Web, должно загрузить canvaskit.wasm (несколько МБ) и некоторые другие файлы прежде, чем сможет нарисовать первый кадр. При хорошем интернете это не проблема, но в регионах, где нестабильный 3G – это максимум, на который можно рассчитывать, становится больно. Первый запуск занимает несколько секунд, при плохом раскладе – несколько десятков.

Угнать за 6 секунд
Почти 6 секунд потребовалось, чтобы загрузить необходимые ресурсы до первого запроса к API. Да, скорость интернета в данном случае была паршивой, но ведь пользователям не интересны причины.
Аналитика показала, что пользователь не готов столько ждать – если загрузка длилась более 6 секунд, то мы теряем потенциального клиента (напомню, речь о первом старте приложения).
Ещё одно неудобство для конечных пользователей – приложение ведёт себя не как “обычный” сайт: нельзя воспользоваться поиском по странице, не работает выделение текста (без дополнительных приседаний со стороны разработчиков) и т.п. Особенно это становится заметно в десктопном варианте, где пользовательский опыт существенно отличается от мобильного.
Вот тут-то на сцену и выходит Jaspr: с его рендером в нативный DOM, в теории, можно избежать загрузки лишнего рантайма и бонусом получить привычный для пользователя “обычный сайт”.
Jaspr: Proof of Concept
Основное наше клиентское приложение имеет внушительную (по меркам Flutter проектов) кодовую базу и включает в себя работу с SignalR с кастомными протоколами, нетривиальную логику подписок на события и т.д. Объём передаваемых по сокетам данных может измеряться мегабайтами и вся эта красота должна оперативно обновлять ряд экранов в рантайме.
Для проверки концепции я выбрал один из самых нагруженных экранов, для которого как раз и требовалась корректная работа с SignalR – благо модульная структура нашего приложения позволяла.
Jaspr не может рисовать виджеты Flutter, поэтому план был таким:
Вынести в чисто дартовые пакеты общий код (работа с сетью, бизнес-логика и т.п.);
Перевести “старые” Flutter модули на использование этих дартовых пакетов;
Реализовать Jaspr версии “старых” модулей и собрать Jaspr приложение.

Немного о структуре проекта
На самом деле у нас не одно приложение, а несколько. И собираются они из нескольких десятков пакетов – такой подход позволяет, с одной стороны, изолировать код, а с другой – использовать его повторно. Наши пакеты, за редким исключением имеют одинаковую структуру:
my_package/ ├── lib/ │ ├── src/ │ │ ├── api/ # работа с сетью, DTO │ │ ├── domain/ # бизнес-логика -- репозитории, сервисы │ │ ├── models/ # доменные модели данных │ │ └── presentation/ # экраны, виджеты, утилиты для представления │ └── my_package.dart └── pubspec.yaml
api, domain и models, как правило, не требуют зависимости от Flutter SDK. Их можно безболезненно вынести в отдельный Dart пакет. Мы в команде договорились к таким пакетам добавлять суффикс shared.
Вечером я расчехлил Cursor и взялся за работу. Довольно быстро появились первые инсайты:
Наш код имел гораздо большую связанность, чем я предполагал;
Зависимость на Flutter SDK имели даже те пакеты, которым, казалось бы, никакой Flutter не нужен;
Web платформа имеет ряд своих особенностей (ладно, это я знал и раньше, но никогда не сталкивался в таких масштабах);
Я совершенно не разбираюсь в современной веб вёрстке.
Стало очевидно, что требуемый объём работ сильно больше, чем я ожидал первоначально. Для proof of concept пришлось сузить вводные: пока плюём на работоспособность Flutter версии, заменяем моками или упрощёнными реализациями ряд компонентов. Оставляем только основной сценарий: инициализация -> подключение к SignalR -> подписка на события -> отрисовка данных на экране с real time обновлениями.
Спустя несколько часов и полностью выжженные лимиты 20-долларовой подписки Cursor, мой прототип был готов. Визуально экран был очень далёк от flutter оригинала, линтер ругался как ненормальный на полученный код, тесты были сломаны, а diff включал в себя больше тысячи изменённых файлов – Bitbucket был просто не в состоянии показать их все.
Но этот прототип работал. И работал хорошо: за вычетом внешнего вида, экран вёл себя ровно так, как и требовалось.
И как же быстро он загружался! После Flutter PWA это был как глоток свежего воздуха – холодный старт занимал 1-1,5 секунды, а после включения экспериментальной компиляции в WASM так и вовсе измерялся в миллисекундах.
Да, конечно, прототип был сильно меньше оригинального Flutter приложения, но он показал очень хороший потенциал.
Мы обсудили с ребятами результаты, прикинули возможные подводные камни, подготовили демо и пошли защищаться перед бизнесом.
Jaspr: MVP
Бизнес очертил перечень модулей, которые необходимы в MVP, чтобы эксперимент считался успешным и дал вводные: команда из двух с половиной флаттеристов (с учётом отпусков), срок – квартал.
К этим вводным мы добавили свои:
пишем максимально приближенный к Flutter реализации код, чтобы уменьшить сложность перехода флаттеристов;
не используем условные импорты (у Jaspr не может быть io платформ);
стили компонентов описываем в отдельных CSS/SCSS файлах, не вмешивая сырые строки со стилями в dart коде.
Про стили
Jaspr из коробки предоставляет несколько подходов к стилизации компонентов. Но мы пошли своим путём не из-за их фатального недостатка: на встрече с бизнесом прозвучало пожелание сделать так, чтобы что-то из проделанной работы можно было использовать с условным React – если результаты будут признаны неудовлетворительными.
С паршивой овцы – хоть шерсти клок, ага.
Портировать вёрстку показалось делом неблагодарным, а вот CSS файлы вполне могли бы использоваться как есть.
Поэтому нам пришлось портировать некоторые используемые пакеты на Jaspr: например, с опорой на используемый нами в Flutter приложении пакет elementary мы запилили свой jaspr_elementary. Также потребовалось создать несколько локальных утилит, вроде генератора CSS стилей с watch режимом.
Про генерацию стилей
Допустим мы хотим создать компонент-контейнер ListGroup. Создаём файл стилей list_group.scss:
.list-group { display: flex; flex-direction: column; align-items: stretch; width: 100%; box-sizing: border-box; border-radius: var(--radius-m, 16px); background-color: var(--component-base-card-background); overflow: hidden; & > :not(:last-child) { border-bottom: 1px solid var(--component-divider-low); } }
Выходные псевдоклассы будут иметь вид:
._72db67ef_list_group__list-group { display: flex; flex-direction: column; align-items: stretch; width: 100%; box-sizing: border-box; border-radius: var(--radius-m, 16px); background-color: var(--component-base-card-background); overflow: hidden; } ._72db67ef_list_group__list-group > :not(:last-child) { border-bottom: 1px solid var(--component-divider-low); }
Хэш-префикс в начале зависит только от пути к исходному файлу, за счёт чего гарантируется уникальность имён псевдоклассов. Это также позволяет обойтись без шума в диффах – хэш будет одинаковым на любой платформе у всех разработчиков.
Помимо выходных *.css генерируется ещё файл *.styles.dart с константами псевдоклассов:
abstract class ListGroupStyles { static const listGroup = '_72db67ef_list_group__list-group'; }
Он нужен, чтобы в вёрстке использовать более удобные поля класса вместо строковых констант. Кроме того, при перемещении файлов в другой каталог изменится только значение константы, а вёрстка останется неизменной.
Теперь можно собрать всё вместе и использовать в Jaspr компоненте:
import 'package:jaspr/dom.dart'; import 'package:jaspr/jaspr.dart'; import 'list_group.styles.dart'; class ListGroup extends StatelessComponent { final List<Component> children; const ListGroup({ super.key, required this.children, }); @override Component build(BuildContext context) => div( children, classes: ListGroupStyles.listGroup, ); }
И остался последний элемент системы: watch процесс, который отслеживает изменения в *.scss файлах, вызывает для них инкрементальную компиляцию и генерацию новых *.css и *.styles.dart файлов. А затем копирует выходные .css файлы в каталог web: jaspr serve подхватит изменение веб-ассетов и обновит страницу.
Благодаря этому инструменту вёрстка компонентов стала почти такой же удобной, как и во Flutter.
В целом план реализации MVP практически совпадал с оным для прототипа:
Выносим общий код в Dart пакеты;
Мигрируем на них Flutter приложение;
Реализуем Jaspr версию приложения.
Только теперь без срезанных углов. Мы составили примерную диаграмму Ганта и, засучив рукава, взялись за работу.
Первые месяц-полтора мы сосредоточились на распутывании наших зависимостей, выделении общего кода и подготовки инструментов. Показывать бизнесу в тот период было практически нечего, но зато улучшили качество кода в целом, плюс получили новые инсайты, улучшающие и Flutter приложение. Пожалуй, именно в это время мы сполна ощутили профиты от использования модульности и Clean Architecture подхода.
И да – оказывается, мы очень недурно проектируем и пишем Dart код! Большая часть написанного нами без сучка, без задоринки было готово работать с Jaspr. Пользуясь случаем, передаю ребятам привет – вы очень крутые разработчики!
Затем мы приступили к портированию экранов. И опять же, значительная часть логики UI (которая в Flutter версии у нас хранилась в WidgetModel) прямо кусками переносилась в ComponentModel компонентов Jaspr – тут хорошо сыграло решение с портированием Elementary.
Кстати, хозяйке на заметку: если Cursor сдобрить несколькими специфичными для Jaspr правилами и скиллами, то он очень неплохо готовит Jaspr компоненты на основе референса Flutter виджетов. Это сильно сократило время реализации.
И если к концу первого месяца даже у разработчиков были сомнения, что мы уложимся в дедлайн, то к концу второго месяца стало очевидно – MVP будет готов. Возможно с некоторыми отличиями от макетов, но в функциональном смысле готов на 100%.
Результаты и выводы
Результат полностью оправдал наши ожидания: несмотря на то, что MVP был сильно больше прототипа, холодный старт сохранился на уровне 1-1,5 секунд (без компиляции в WASM).

Есть только миг
Чуть больше секунды на холодный старт до первого запроса к API. При тех же условиях версия на Flutter Web давала 6 секунд.
Бонусом мы неплохо отрефакторили код, а ряд идей, появившихся в процессе работы над Jaspr версией, были портированы и во Flutter приложение.
Мы сознательно упростили себе жизнь и использовали client mode, но Jaspr умеет и в SSR. А значит потенциально можно добиться ещё более лучших результатов.
Почему не SSR?
Как-то так получилось, что среди наших флаттеристов не было никого с опытом в веб-разработке. С учётом сжатых сроков и новой для нас технологии, мы решили, что погружаться в дебри SSR было бы нерационально. Но в дальнейшем мы всё-таки планируем его использовать.
При этом важно отметить сроки реализации: по нашим оценкам разработка аналогичного React приложения заняла бы кратно больше времени – это как раз тот случай, когда кросс-платформенность и переиспользование кода даёт огромный прирост.
Несколько выводов, которые мы сделали по результатам этого эксперимента:
Чистая архитектура и модульность – это реально очень важно! Не срезайте углы, потом может стать больно;
ИИ-инструменты хорошо помогают тем, кто их настраивает под проект. У остальных – жрёт токены;
Jaspr – действительно неплохая альтернатива Flutter Web для ряда сценариев;
Если вы работаете с вебом, вам не помешает иметь веб-разработчика в команде.
В целом Jaspr очень крутой проект, который позволяет Flutter разработчикам создавать веб-приложения с использованием имеющегося опыта. Но нужно трезво подходить к выбору технологий – Jaspr (как и Flutter Web) хорошо подходит, если у вас есть сильная Dart команда и вы планируете переиспользовать большую часть кода.
