Привет, меня зовут Максим, я 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 в одном месте. Присоединяйтесь!

Комментарии (0)