Изначально Flutter был известен как фреймворк для создания кроссплатформенных мобильных приложений для Android и iOS. Но концепция Flutter не ограничивается мобильной разработкой, фреймворк позволяет создавать пользовательские интерфейсы для любого экрана с помощью кроссплатформенной разработки: разрабатывать web и desktop-приложения. Мы в Friflex работаем на Flutter с момента выхода первой версии и хорошо знаем особенности фреймворка. В этой статье Никита Улько, Flutter fullstack developer в Friflex, рассказывает об особенностях разработки Flutter для Web. Если вы хотите попробовать Flutter для web, этот гайд для вас.

В этой статье мы пройдем этапы от разработки простого web-приложения до его размещения на сервере. Определим, какие подводные камни могут встретиться при работе с Flutter Web и как их избежать без вреда для проекта. Рассмотрим плюсы и минусы фреймворка, определимся, какие web-приложения стоит создавать на Flutter, а какие нет.
Начнем с инициализации проекта. Так же, как и для всех остальных платформ, инициализируем проект командой:
bash flutter create test_web
flutter cli инструмент сгенерировал нам скелетон проекта, который по умолчанию имеет поддержку web-среды.
Вот так выглядит структура папок в только что инициализированном проекте.

Нас в данный момент интересуют только три папки:
-
/lib/ – здесь хранится платформонезависимый код нашего Flutter приложения;
-
/web/ – здесь хранится все, что относится к web-платформе (базовый шаблон index.html, например);
-
/build/ – здесь можно будет найти результат сборки приложения, который можно деплоить.
Уже сейчас с только что развернутым проектом у нас есть, на что посмотреть. Запустим наше приложение.
bash flutter run
Futter запустит браузер, в котором откроет приложение, работающее на dev-сервере.
Сразу обратим внимание на адресную строку.

По умолчанию flutter в web-среде использует hash роутинг. Похожую картину мы можем наблюдать в SPA фреймворках, например, Vue или React (при использовании HashRouter).
Это удобно, потому что позволяет нам практически не конфигурировать web-сервер. Достаточно раздать index.html и вся дальнейшая навигация будет происходить по хешу «в рамках этого документа».
Из минусов:
-
пользователи скорее привыкли видеть стандартные url без хеша;
-
это очень плохо сказывается на индексировании страниц, поскольку логически мы находимся на одной и той же странице.
Для более развернутой демонстрации попробуем написать простенькое приложение с навигацией. Например, онлайн-каталог.
Ниже представлен код, отвечающий за навигацию. При каждой навигации вызывается onGenerateRoute, в котором хранятся настройки роута (url, например). В данном случае мы пытаемся распарсить url по регулярному выражению, вытащив из него параметры для роута. Если удается, значит мы на роуте конкретного продукта, и должны вернуть этот роут. В противном случае смотрим, какой из именованных роутов нам может подойти.
class AppRoutes { static final _namedRoutes = <String, RouteFactory>{ CatalogueRoute.name: (settings) => CatalogueRoute(settings: settings), }; static Route<dynamic>? onGenerateRoute(RouteSettings settings) { final params = CatalogueItemRouteParams.parse(settings); if (params != null) { return CatalogueItemRoute( catalogueItemRouteParams: params, settings: settings, ); } else { print('unable to parse params'); } if (_namedRoutes.containsKey(settings.name)) { return _namedRoutes[settings.name]!(settings); } return null; } }
Для реактивного состояния в самом простом случае можно не использовать state-management библиотек, а просто воспользоваться Stream’ами. Stream – это абстракция, которая реализует паттерн Observer. То есть все, что она делает – позволяет клиентскому коду подписаться на свои обновления.
class CatalogueController { final StreamController<CatalogueState> _controller = StreamController<CatalogueState>.broadcast(); CatalogueState _state = CatalogueState(isLoading: false); final CatalogueRepository catalogueRepository; CatalogueController({ required this.catalogueRepository, }); Stream<CatalogueState> get stream => _controller.stream; CatalogueState get value => _state; void emit(CatalogueState newState) { _state = newState; _controller.add(newState); } Future<void> loadCatalogue() async { if (value.isLoading) { return; } emit( (CatalogueStateBuilder.fromInstance(value)..isLoading = true).build(), ); try { final catalogue = await catalogueRepository.getCatalogue(); emit( (CatalogueStateBuilder.fromInstance(value) ..isLoading = false ..catalogue = catalogue.toList()) .build(), ); } on AppError catch (err) { emit( (CatalogueStateBuilder.fromInstance(value) ..isLoading = false ..error = err) .build(), ); } } }
Внутри страницы используем StreamBuilder, чтобы считывать обновления со Stream’а и обновлять контент при обновлении состояния. При переходе на страницу будет вызван initState, который вызовет loadCatalogue для инициализации загрузки данных.
class _CataloguePageState extends State<CataloguePage> { @override void initState() { widget.catalogueController.loadCatalogue(); super.initState(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), body: StreamBuilder<CatalogueState>( stream: widget.catalogueController.stream, builder: (context, snapshot) { final state = snapshot.data ?? widget.catalogueController.value; if (state.error != null) { return Center( child: Text(state.error!.message), ); } if (state.catalogue != null) { int crossAxisCount = 2; if (MediaQuery.of(context).size.width > 1000) { crossAxisCount = 4; } return Container( color: Colors.blue, child: SingleChildScrollView( child: Column( children: [ Padding( padding: const EdgeInsets.all(8.0), child: GridView.count( physics: const NeverScrollableScrollPhysics(), shrinkWrap: true, crossAxisCount: crossAxisCount, crossAxisSpacing: 8, mainAxisSpacing: 8, children: state.catalogue! .map( (e) => CatalogueCard( catalogueItem: e, onTap: () { Navigator.of(context).pushNamed('/catalogue-item/${e.id}'); }, ), ) .toList(), ), ) ], ), )); } return const Center( child: CircularProgressIndicator(), ); }, ), ); } }
В приложении будет один репозиторий для доступа к данным, у которого будет моковая реализация.
abstract class CatalogueRepository { Future<CatalogueItem> getCatalogueItem(String id); Future<Iterable<CatalogueItem>> getCatalogue(); }
Вот так выглядит приложение.
Hidden text


Следует обратить внимание, что при навигации не происходит перезагрузок, как в классическом SPA. Url меняется через History API браузера, как, например, в случае с Vue Router.
В целом с тестовым приложением мы разобрались. Но как именно происходит отрисовка контента? Разберем подробнее!
Зайдем в инструменты разработчика в браузере и выключим выполнение JS, перезагрузим страницу. Мы увидим примерно следующую картину

Это связано с тем, что нам отдается шаблон, который находится в /web/index.html. По умолчанию этот шаблон очень простой: есть несколько метатегов, и один script тег, который подгружает наше Flutter-приложение, транслированное в javascript с помощью dart2js. Значит с сервера нам всегда будет приходить пустая страница, что плохо с точки зрения SEO. Также это влияет на time to first contentful paint, поскольку чтобы отрисовать контент, браузеру нужно сначала загрузить весь JS, распарсить его и выполнить. Оптимизировать загрузку можно использовав deferred loading. По сути, это аналог split chunks в webpack. При загрузке приложения обязательно будет выгружено ядро фреймворка и необходимые для роута компоненты. Загрузку всех остальных компонентов можно отложить.
Также при сборке production билдов Flutter заботится о размере бандла, используя минификацию. Приятно, что минификация работает из коробки, и ее даже не нужно конфигурировать.
Вернемся к скрипту, который упоминался выше. Что же происходит после его выполнения? Включаем выполнение js, перезагружаем страницу. Если мы попробуем найти в DOM какой-то элемент, то обнаружим, что весь наш сайт целиком отрисован одним canvas тегом.
У Flutter есть два способа отрисовать страницу: используя один canvas (способ по умолчанию для десктопа) и используя html теги, стили и canvas теги (способ по умолчанию для мобильных устройств).
Теперь давайте рассмотрим пример, где нам нужно логически отделить страницы друг от друга. В таком случае нам придется отказаться от hash навигации в пользу PathUrlStrategy. Для этого добавляем необходимую зависимость в pubspec.yaml согласно документации
yaml dependencies: flutter_web_plugins: sdk: flutter
и вызываем setUrlStrategy. В целом в режиме разработки для нас ничего не поменяется.
dart void main() { setupServices(); setUrlStrategy(PathUrlStrategy()); runApp(const MyApp()); }
Теперь в приложении привычный url без хеша.
Hidden text

При использовании PathUrlStrategy нам нужно сконфигурировать web-сервер таким образом, чтобы при переходе на любой url он отдавал index.html. Попробуем запустить наше web-приложение в боевом режиме на реальном сервере.
bash flutter build web
На изображении ниже можно увидеть docker-compose конфигурацию. Мы собираемся поднять nginx в контейнере. Самое важное здесь – прокинутый volume /var/www/html в папку с web-версией собранного приложения.
version: '2' services: nginx: image: "nginx:latest" restart: always ports: - 80:80 volumes: - "./nginx/logs:/etc/logs/nginx" - "./nginx/conf.d:/etc/nginx/conf.d/" - "../build/web:/var/www/html"
Для nginx пропишем самую простую конфигурацию с одним location – отдаем файл по url, если файла нет – отдаем index.html
server { listen 80 default; root /var/www/html; location / { try_files $uri /index.html; } }
Поднимаем контейнеры:
bash cd docker docker-compose up -d --build
Переходим на http://localhost и видим как nginx отдает нам каталог.
Flutter for web: преимущества
Резюмируя, попробуем ответить на вопросы: какие могут быть преимущества у Flutter для web и в каких случаях использование Flutter может быть действительно хорошей идеей?
-
Flutter будет хорошим инструментом, если на этапе разработки неизвестна целевая платформа, под которой должно быть запущено приложение, либо если целевых платформ в дальнейшем может стать несколько.
-
Dart – хороший бонус, который вы получаете при использовании Flutter. Статическая типизация позволяет отсекать большое количество ошибок на этапе написания кода и проектировать более надежные программные модули. При этом можно также пользоваться и динамической типизацией. В таком случае разработка будет очень похожа на разработку под JS.
-
Flutter хорошо подойдет, если в результате разработки ожидается получить скорее динамическое приложение, которое будет отзывчивым для пользователя, работать без перезагрузок (в отличие от многостраничных приложений, полностью генерируемых шаблонизатором на стороне сервера).
-
Использование Flutter для web может быть оправдано, если у вас есть сформировавшаяся Flutter команда, или, например, команда мобильных разработчиков, которая готова мигрировать на Flutter.
-
Flutter хорошо подойдет, если вы хотите разработать PWA. С версии 1.20 скелетоны проекта, генерируемые Flutter’ом, сразу добавляют поддержку PWA, позволяя устанавливать web-приложение на устройство, и использовать его в офлайн режиме.
Flutter for web: минусы
Есть и такие случаи, когда Flutter может не подойти.
Давайте определимся, на что нужно обратить внимание перед тем, как перейти к использованию фреймворка для web-проекта.
-
Flutter не очень подходит для сайтов, в которых SEO является важной составляющей. Например, для онлайн-магазинов. В официальной документации Flutter есть упоминание об этом. Ситуацию в теории можно улучшить, добавив на серверную часть шлюз, который будет внедрять нужные нам метатеги в html страницы и отдавать корректные http коды в зависимости от статуса ресурса. Такой шлюз можно реализовать на PHP + Laravel. Важно понимать, что мы сможем управлять только метатегами. Под большим вопросом остается использование h1 и h2 тегов, поскольку их придется скрывать.
-
Прежде чем использовать Flutter в web-проектах, нужно точно определить, требуется ли поддерживать старые браузеры. В официальной документации указаны минимальные версии браузеров, с которыми Flutter работает стабильно.
Flutter может идеально подойти для SPA и PWA web-приложений. Хороший пример – админка взаимодействующая с бэкендом через JSON API, web-консоль, приложение для работы с документами (похожее на Google Docs) или информационный дашборд. Flutter позволяет откладывать принятие решения о целевой платформе для приложения. Но для сайтов, которые в основном ориентируются на текстовый контент, или которым требуется SEO, Flutter подходит не лучшим образом в силу того, что он не поддерживает Server Side Rendering (скомпилированный JS выполняется только в браузере). Более того, Flutter не позволяет нам работать с привычной DOM напрямую.
Ссылка на репозиторий
ссылка на оригинал статьи https://habr.com/ru/company/friflex/blog/666952/
Добавить комментарий