Изначально Flutter был известен как фреймворк для создания кроссплатформенных мобильных приложений для Android и iOS. Но концепция Flutter не ограничивается мобильной разработкой, фреймворк позволяет создавать пользовательские интерфейсы для любого экрана с помощью кроссплатформенной разработки: разрабатывать web и desktop-приложения. Мы в Friflex работаем на Flutter с момента выхода первой версии и хорошо знаем особенности фреймворка. В этой статье Никита Улько, Flutter fullstack developer в Friflex, рассказывает об особенностях разработки Flutter для Web. Если вы хотите попробовать Flutter для web, этот гайд для вас.
![](https://habrastorage.org/getpro/habr/upload_files/763/860/01d/76386001d22d01bccd073df5f91a2056.jpg)
В этой статье мы пройдем этапы от разработки простого web-приложения до его размещения на сервере. Определим, какие подводные камни могут встретиться при работе с Flutter Web и как их избежать без вреда для проекта. Рассмотрим плюсы и минусы фреймворка, определимся, какие web-приложения стоит создавать на Flutter, а какие нет.
Начнем с инициализации проекта. Так же, как и для всех остальных платформ, инициализируем проект командой:
bash
flutter create test_web
flutter cli инструмент сгенерировал нам скелетон проекта, который по умолчанию имеет поддержку web-среды.
Вот так выглядит структура папок в только что инициализированном проекте.
![](https://habrastorage.org/getpro/habr/upload_files/7b0/e51/f2a/7b0e51f2ac41f70cc0ad554b6d969ee6.png)
Нас в данный момент интересуют только три папки:
/lib/ – здесь хранится платформонезависимый код нашего Flutter приложения;
/web/ – здесь хранится все, что относится к web-платформе (базовый шаблон index.html, например);
/build/ – здесь можно будет найти результат сборки приложения, который можно деплоить.
Уже сейчас с только что развернутым проектом у нас есть, на что посмотреть. Запустим наше приложение.
bash
flutter run
Futter запустит браузер, в котором откроет приложение, работающее на dev-сервере.
Сразу обратим внимание на адресную строку.
![](https://habrastorage.org/getpro/habr/upload_files/6f3/b04/538/6f3b045389280e367bf7f45504c33770.png)
По умолчанию 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
![Каталог Каталог](https://habrastorage.org/getpro/habr/upload_files/efd/1da/01d/efd1da01d5c7bf07600e94623b8d313d.png)
![Страница одного товара Страница одного товара](https://habrastorage.org/getpro/habr/upload_files/cda/12d/4b1/cda12d4b1f7a5b803efcc1c04e876024.png)
Следует обратить внимание, что при навигации не происходит перезагрузок, как в классическом SPA. Url меняется через History API браузера, как, например, в случае с Vue Router.
В целом с тестовым приложением мы разобрались. Но как именно происходит отрисовка контента? Разберем подробнее!
Зайдем в инструменты разработчика в браузере и выключим выполнение JS, перезагрузим страницу. Мы увидим примерно следующую картину
![](https://habrastorage.org/getpro/habr/upload_files/bd0/46e/4db/bd046e4db8b589109bfec84f2445a7f9.png)
Это связано с тем, что нам отдается шаблон, который находится в /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
![](https://habrastorage.org/getpro/habr/upload_files/62f/26b/c4a/62f26bc4af87b6f90bc87a363ed5be02.png)
При использовании 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 напрямую.
Ссылка на репозиторий
Mitai
В закладки, вдруг пригодится в будущем)