Привет, меня зовут Максим, я Flutter-разработчик в компании Surf.
Мы продолжаем рассказывать про Flutter Web. И это вторая статья.
Разработка собственных библиотек
Межъязыковое взаимодействие JS-Dart устроено достаточно просто. Но вписать большой функционал только на Dart не всегда удобно. Как минимум, из-за отсутствия подсказок IDE о доступных методах в JS-объектах, а как максимум — из-за многословного преобразования комплексных объектов.
Большие модули гораздо удобнее разрабатывать на нативном языке (JS) и оставлять несложный интерфейс для взаимодействия с Dart-частью.
Простой пример
Реализуем несколько простых функций. Для этого создадим файл simple.js
в директории web/js
.
/// web/js/simple.js
/// Простая функция с входным параметром
function customPrint(value) {
console.log(value);
}
/// Асинхронная функция,
/// возвращающая Promise,
/// аналог Future в Dart
async function future() {
await delay(1000);
return new Promise((resolve, reject) => {
// Здесь ваш асинхронный код
// Если всё прошло успешно, вызовите resolve(result)
// Если что-то пошло не так, вызовите reject(error)
resolve('success');
});
}
/// Вспомогательный метод, эмулирующий работу
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
Подключаем новую зависимость в файл web/index.html
.
<...>
<script src="./js/simple.js"></script>
</head>
Проверяем с помощью консоли, что методы успешно импортированы
Теперь описываем интерфейсы уже знакомым нам способом:
// lib/main.dart
@JS('customPrint')
external void customPrint(String message);
@JS('future')
external JSPromise<JSString> jsAsyncMethod();
Тут стоит обратить внимание, что JS Interop не умеет не явно преобразовывать Promise
-> Future
. По этой причине в описании интерфейса мы должны указать тип JSPromise
c соответствующим дженерик типом, если это необходимо. И не забыть о приведении к системе типов Dart с помощью геттера toDart
.
// lib/main.dart
final message = await jsAsyncMethod().toDart;
print(message);
Этих знаний будет достаточно, чтобы реализовать большую часть задач для Flutter Web.
Но иногда нам нужно прописать сложную бизнес-логику или использовать сторонние библиотеки. Для закрытия этого пула задач есть огромное количество технологий и инструментов для нативной веб-разработки. Наиболее близкими и приятными нам показались Node.js, npm, TypeScript и webpack
Вы спросите: зачем нам столько всего? Объясним:
Node.js — это не просто фреймворк, это среда выполнения JS-кода. Благодаря тому, что она не привязана к рантайму браузера, она позволяет использовать JS практически везде. Что, в свою очередь, очень популяризировало эту технологию за пределами веб-сайтов. У Node.js есть огромное комьюнити, которое реализовало множество библиотек. Они помогают не только взаимодействовать с браузером, но и, например, конвертировать видео, работать с документами, генерировать изображения и делать многое другое;
npm — пакетный менеджер для Node.js, в репозиториях которого хранятся библиотеки;
TypeScript — надстройка над языком JavaScript, которая позволяет удобно писать строго типизированный код и использовать подход ООП, что для нас более привычно;
webpack — из-за особенностей веб-стандартов мы должны разрабатывать один модуль, в рамках одного файла. А это не всегда удобно. Webpack позволяет собирать модуль разбитый на части в один или несколько бандлов.
А теперь разработаем первый модуль, и на этот раз, он будет прикладным.
Модульная библиотека
Отличие веб-приложений, от, например, мобильных, заключается в том, что в рамках одного браузера может быть запущено несколько вкладок с нашим приложением. А это может стать причиной не самых приятных последствий. Например, множественных оповещений для одного события.
Предлагаем реализовать простой плагин для обнаружения новых инстансов приложения.
Создадим новую директорию для исходников плагина {root_project}/packages/instanse_detector
Перейдём в эту директорию в терминале и инициализируем новый npm-модуль.
npm init -y
Подробную информацию об установке и настройке Node.js и npm в вашем окружении вы найдёте тут
В текущую директорию добавился файл package.json
, который служит для конфигурации нашего проекта, управления зависимостями и сборки. Эдакий pubspec.yaml
, только из мира Node.js.
Внесём правки:
// packages/instanse_detector/package.json
{
"name": "instance_detector",
"version": "0.0.1",
"scripts": {
"tsc": "tsc",
/// добавим алиас для запуска TS
"build": "npm run tsc"
},
"devDependencies": {
// указываем, что используем TypeScript
// для разработки
"typescript": "^5.1.6"
}
}
Теперь выполним команду npm install
для установки необходимых зависимостей.
Следующим этапом подготовим конфигурацию для компиляции TypeScript.
Добавим файл tsconfig.json
в корень нашего модуля.
packages/instanse_detector/tsconfig.json
{
"compilerOptions": {
/// указываем стандарты
/// ES5 - максимальный для взаимодействия с Flutter web
"target": "ES5",
"module": "system",
"moduleResolution": "node",
/// директория, куда будет сохранен скомпилированный JS
"outDir": "../../web/js/",
},
"exclude": [
"node_modules",
"build"
]
}
Более подробную информацию о
tsconfig
вы найдёте тут
Пора переходить к реализации нашего модуля. Для этого создадим поддиректорию src
и добавим туда файл с расширением ts
.
// packages/instanse_detector/src/instanse_detector.ts
/// Вспомогательное перечисление, для определения инстанса приложения
enum Message {
First,
Second,
}
/// Для удобства определим тип метода с булевым параметром
type OnInstanceEvent = (e: boolean) => void;
class InstanceDetector {
onEvent?: OnInstanceEvent;
channel: BroadcastChannel;
constructor() {
/// Вкладки браузера могут общаться между собой
/// при помощи каналов
this.channel = new BroadcastChannel("simpl-instanse-detector");
this.channel.onmessage = (event) => {
/// Если в канал поступило сообщение с типом First
/// значит запущен новый инстанс приложения
if (event.data === Message.First) {
/// сообщаем в ответ что есть другой инстанс
this.channel.postMessage(Message.Second);
/// оповещаем Dart
this.onEvent(true);
}
/// Если в получили событие Second
/// Это означает, что мы главный инстанс
if (event.data === Message.Second) {
/// оповещаем Dart
this.onEvent(false);
}
};
/// При инициализации сообщаем всем, что
/// мы главный инстанс
this.channel.postMessage(Message.First);
}
/// Метод установки коллбека на изменения состояния
init(onEvent: OnInstanceEvent) {
this.onEvent = onEvent;
onEvent(false);
}
}
Для регистрации коллбэка мы передаем его через метод init
, а не через конструктор, что кажется более удобным вариантом. Но по загадочным причинам передача колбэков через конструктор работает нестабильно и не всегда корректно.
Итоговая структура нашего проекта может выглядеть так:
?root_project_folder/
└──?packages/
├──?instance_detector/
│ └──?src/
│ └──?instance_detector.ts
├──?package.json
└──?tsconfig.json
Итак, мы запустили команду npm run build
в папке /web/js
. У нас появился скомпилированный JS-файл, который нужно подключить и использовать.
Можно обойтись этим методом и всё заработает. Но бывает, что приложение собирается только под веб и игнорирует другие платформы.
В текущем варианте, если мы попытаемся запустить приложение под платформами, отличными от web, то получим ошибку. Поэтому всю работу с JS стоит оборачивать в плагины.
Рассмотрим этот подход подробнее.
Обеспечение кросскомпиляции для разных платформ
В директории packages
генерируем плагин командой:
flutter create --template=package {name}
Разделим реализацию плагина для каждой из платформ. Объявим общий интерфейс.
class INativeInstanceDetector {}
Здесь мы обойдёмся пустым контрактом — нам не потребуются ни методы, ни параметры.
Создадим структуру для плагина:
?project_foler
└──?lib_folder
├──?lib
│ ├──?src
│ │ ├──?impl
│ │ │ ├──?io
│ │ │ │ └──?io.dart
│ │ │ └──?web
│ │ │ ├──?bindings.dart
│ │ │ └──?web.dart
│ │ └──?i_instance_detector.dart
│ └──?export.dart
└──?packages
└──?js_source
├──?src
│ └──?instanse_detector.ts
├──?package.json
└──?tsconfig.json
На этом этапе работа с io нам не очень важна — просто реализуем интерфейс, чтобы избежать ошибок сборки.
// lib_folder/lib/src/i_instance_detector.dart
class NativeInstanceDetector extends INativeInstanceDetector {
NativeInstanceDetector(Function(bool event) onInstanceAdded);
}
Теперь реализуем веб-часть.
Опишем интерфейсы для работы с JS в файле bindings.dart
.
// lib_folder/lib/src/impl/web/bindings.dart
@JS()
library instance_detect.js;
import 'dart:js_interop';
typedef OnInstanceAdded = void Function(bool event);
@JS('InstanceDetector')
extension type InstanceDetector._(JSObject _) implements JSObject {
external InstanceDetector();
external JSFunction? get onEvent;
external void init(JSFunction onEvent);
}
Обратим внимание на расширение типа JSObject
. До этого момента мы вызывали либо простые методы, либо статичные — в них достаточно просто добавить аннотации @JS
@staticInterop
.
Здесь нам необходимо создать расширение типа JSObject
. Такой подход применяется для описания JS-объекта на стороне Dart, когда необходимо создать инстанс этого объекта и обращаться к его полям или методам.
После — реализуем INativeInstanceDetector
для web в файле web.dart
.
// lib_folder/lib/src/impl/web/web.dart
class NativeInstanceDetector extends INativeInstanceDetector {
final InstanceDetector detector;
/// В конструкторе примим коллбэк,
/// который будет передан в JS-слой
NativeInstanceDetector(OnInstanceAdded onInstanceAdded) : detector = InstanceDetector() {
/// Заводим инстанс обертки над JS-объектом InstanceDetector
final detector = InstanceDetector();
/// Производим инициализацию, передавая коллбек,
/// который будет вызван при запуски нового инстанса
/// нашего приложения
detector.init(onInstanceAdded.toJS);
}
}
Завершающим этапом подготовки будет export.dart
файл, в котором мы экспортируем нужную реализацию в зависимости от окружения:
// lib_folder/lib/export.dart
library detector;
export 'src/impl/io/io.dart' // по умолчанию
if (dart.library.js) 'src/impl/web/web.dart' // web
if (dart.library.io) 'src/impl/io/io.dart'; // io
Обратим внимание, что для правильной работы такого подхода имя реализации должно быть одинаковым для обеих платформ.
Добавляем плагин в pubspeck.yaml
:
dependencies:
flutter:
sdk: flutter
detector:
path: './packages/detector'
Использование плагина ничем не отличается от того, что мы стягиваем с pub.dev.
// lib/main.dart
/// Заводим инстанс детектора и передаем туда коллбэк,
/// который сработает на изменение состояния вкладки
NativeInstanceDetector((event) {
setState(() {
if (event) {
_isSecondary = event;
}
});
});
Такое разделение реализаций обеспечивает беспроблемную сборку и для веб, и для мобильных, и для десктопных приложений.
Что в итоге
Мы рассмотрели основы взаимодействия Dart и JS. В целом, этого вполне достаточно для подготовки приложений к работе в браузере. С помощью наших статей вы сможете реализовать практически любой функционал веб-приложений и обеспечить взаимодействие с браузером.
Но это не всё. В следующих статьях мы расскажем о компиляции веб-приложения в wasm.
Этот метод стал доступен не так давно, общую информацию и наше мнение о нём вы найдёте в нашем материале.
Мы поговорим о разработке и использовании wasm-модулей, о публикации веб-приложений, настройке ci/cd, оптимизации загрузки и оценим прирост производительности. До встречи!
Больше полезного про Flutter — в Telegram-канале Surf Flutter Team.
Кейсы, лучшие практики, новости и вакансии в команду Flutter Surf в одном месте. Присоединяйтесь!