Если у вас одна платформа, фронтенд работает стабильно и предсказуемо. Но стоит добавить десктоп, мобильное приложение, PWA, и простая логика превращается в гору if-ов.

Меня зовут Денис Кондратьев, я фронтенд-разработчик Точка Банк. В статье расскажу, как мы интегрировали наш мессенджер в браузер, Electron, Capacitor и PWA и сократили время адаптации новых фич в три раза.

С чего всё началось

У нас в Точка Банке есть корпоративный чат — типичный мессенджер с базовым набором функций. Там можно отправлять сообщения, пересылать файлы, получать уведомления, добавлять статусы пользователей. Первая версия работала в браузере и выглядела довольно просто.

Для создания уведомлений мы использовали классическое браузерное API. Так как платформа была одна, её поведение было абсолютно предсказуемым. Проблемы начались позже, когда продакт-менеджеры поставили задачу сделать десктопную версию приложения.

Что пошло не так

Чтобы создать приложение, мы решили завернуть фронтенд в Electron — тогда это было самое популярное решение. Оказалось, что:

  • уведомления в Electron работают не так, как в браузере;

  • жизненный цикл приложения другой;

  • работа с файлами устроена иначе.

Так в коде начали появляться первые ветвления.

if (isElectron) {
  // используем Electron Notification API
  new Notification(title, { body } ) ;
} else {
  // браузерный Notification API
  new window.Notification(title, { body } ) ;
}

Конечно, один if нельзя назвать архитектурным кризисом. Но когда следом мы решили сделать мобильную версию на Capacitor, количество if-ов стало расти в геометрической прогрессии.

if (isElectron) {
  electronAPI.showNotification(title, { body } ) ;
}

if (isMobile) {
  capacitorAPI.showNotification( { title, body } );
}

Больше всего пострадали уведомления. Если в браузере это был просто вызов API, то для мобильных пушей нам пришлось развернуть целый слой новой инфраструктуры:

  • токены устройства;

  • firebase/APNs;

  • подписка на сервере.

Последней каплей стал PWA, который пришлось делать в 2022 году, когда Точка Банк попал под санкции, и наш корпоративный аккаунт в Apple заблокировали.

Функции переписывались по много раз, их нужно было адаптировать для браузера, отдельно проверять в Electron, потом снова переписывать для мобильного webview и PWA с их собственным жизненным циклом и системой уведомлений. В какой-то момент стало очевидно, что проблема уже не в конкретной платформе, а в общем подходе.

К чему мы пришли

Так мы оказались в ситуации, где у нас есть:

  • четыре разные платформы;

  • n-ное количество фичей;

  • число платформенных проверок, которое фактически умножалось на количество этих фичей.

Я построил граф зависимостей проекта, и он выглядел как спагетти: каждая фича была обвешана условиями под каждую платформу.

Но ключевая проблема была не в количестве кода, а в его структуре:

  • Логика оказалась размазана по проекту: один и тот же функционал реализовывался по-разному в разных местах.

  • Дебаг превратился в квест: если что-то сломалось на одной платформе, нужно было проверить, не сломалось ли на других. Любое изменение тянуло за собой каскад проверок.

  • Было сложно выкатывать фичи: три дня из пяти уходили на адаптацию под разные платформы, два дня — непосредственно на реализацию бизнес-логики.

Мы поняли, что большая часть времени тратится не на создание продукта, а на борьбу с инфраструктурой.

Наше решение

Чтобы навести порядок, мы решили спрятать работу с платформами в одну коробку — чтобы снаружи был удобный интерфейс, а вся магия происходила внутри.

Шаг 1. Реализация браузера

Мы начали с создания IApp — это контракт, который предоставляет бизнес-логике работу с функционалом платформ: показ уведомлений, сохранение файлов и т. д. Теперь бизнес-логика оперировала только этим интерфейсом и не знала, как реализованы методы на конкретной платформе.

export interface IApp {
  showNotification(title: string, body: string): void;
  saveFiles(file: Blob, filename: string): void;
  onAppStateChange(callback: (isActive: boolean) => void): void;
}

Шаг 2. Наследование Electron

Дальше появились конкретные реализации: сначала для браузера, потом — для десктопа.

export class web implements IApp {
  showNotification(title: string, body: string) {
    new Notification(title, { body } ) ;
  }

  saveFileAs(file: Blob, filename: string) {
    const a = document.createElement("a");
    a.href = URL.createObjectURL(file);
    a.download = filename;
    a.click();
  }

  onAppStateChange(callback: (isActive: boolean) => void) {
    document.addEvenListener("visibilitychange", () => {
      callback(document.visibilityState === "visible") ;
    } ) ;
  }
}

Так как Electron — это не полностью отдельная платформа, а тот же браузер с нативной обвязкой, то часть логики решили наследовать от веба (WebApp). Переопределили только методы, которые отличаются: например, работа с файлами, уведомления.

export class web implements IApp {
  showNotification(title: string, body: string) {
    new Notification(title, { body } ) ;
  }

  saveFileAs(file: Blob, filename: string) {
    const a = document.createElement("a");
    a.href = URL.createObjectURL(file);
    a.download = filename;
    a.click();
  }

  onAppStateChange(callback: (isActive: boolean) => void) {
    document.addEvenListener("visibilitychange", () => {
      callback(document.visibilityState === "visible") ;
    } ) ;
  }
}

class DesktopApp extends Web {
  // только отличия

  override showNotification(title: string, body: string)
    electronAPI.showNotification(title, { body } ) ;
  }

  override onAppStateChange(callback) {
    electronTransport.onAppStateChange?.(callback);
  }
}

Шаг 3. Сокрытие платформы через AppFabric

Чтобы бизнес-логика не зависела от конкретной платформы, мы применили паттерн Фабрика. Так появилась функция AppFabric: она возвращает готовый объект, соответствующий интерфейсу IApp. Это и есть наша «волшебная коробочка», где происходит работа с платформами.

export const appFabric = (): IApp =>  {
  if (isDesktop( ) )  {
    return new DesktopApp();
  }

  // Фронт запущен в браузере
  return new Web();
};

Шаг 4. Мобильные пуш-уведомления

С мобильной версией (MobileApp) поступили аналогично — унаследовались от WebApp, переопределив только нужные методы.

class MobileApp extends Web {
  // уведомления – in-app
  override showNotification(title: string, body: string) {
    CapacitorAPI.showNotification( { title, body } ) ;
  }

  // жизненный цикл – мобильный
  override onAppStateChange(callback) {
    CapacitorAPI.onAppStateChange( (state) => {
      callback(state.isActive) ;
    } ) ;
  }
}

Главная проблема — push-уведомления, которых нет в вебе. Для них нужны уникальные методы:

  • инициализация push;

  • отправка токена на сервер.

Если добавить их только в MobileApp, бизнес-логика их не увидит. Если добавить в IApp, то TypeScript заставит реализовать их и в WebApp. В итоге мы добавили их в WebApp и столкнулись с протечкой: теперь веб знал о методах, которых у него нет, но которые есть в MobileApp.

class web implements IApp {
  // Браузер знает про мобильные пуши?
  initPushNotifications() {}
  sendTokenToServer(_: string) {}

  // Настоящие методы Web
  showWebNotification(notification) {
    this.notificationsService.showNotification(notification) ;
  }

  saveFileAs(file: Blob, filename: string) {
    const a = document.createElement("a");
    a.href = URL.createObjectURL(file);
    a.download = filename;
    a.click();
  }
}

Шаг 5. Спасительный полиморфизм

Чтобы изолировать платформы друг от друга, использовали абстрактный класс. Между интерфейсом IApp и WebApp создали прослойку в виде abstract class App и в нём реализовали два типа методов:

  • Абстрактные (обязательные): должны быть реализованы в каждой платформе.

  • Неабстрактные (опциональные): существуют только там, где нужны.

Теперь WebApp наследовался непосредственно от абстрактного класса, а не IApp, и реализовывал только нужные методы.

export abstract class App implements IApp {
  // Abstract – обязательные
  // Не реализуешь – не скомпилируется
  abstract showNotification(title: string, body: string): void;
  abstract saveFileAs(file: Blob, filename: string): void;

  // Noop – опциональные
  // не нужны – ничего не делает
  initPushNotifications() {}
  sendTokenToServer(token: string) {}
}

Что в итоге получилось

Финальная архитектура выглядит так:

Интерфейс (IApp)Абстрактный класс (AbstractApp) ↓ **Классы реализаций конкретных платформ **(WebApp→DesktopApp→MobileApp→PwApp) ↑ **AppFabric **

Всего мы использовали три паттерна:

  • Фабрика — скрывает от бизнес-логики, какой именно класс создаётся, и просто возвращает готовый интерфейс.

  • Полиморфизм — позволяет каждой платформе иметь свою реализацию, при этом общий контракт остаётся единым.

  • Адаптер — приводит специфичное API каждой платформы к единому интерфейсу, с которым работает бизнес-логика.

Теперь платформы стали изолированы друг от друга, а бизнес-логика независимой.

Что не так в нашем решении

На первый взгляд, подход выглядит почти идеально, но без компромиссов не обошлось. Мы выделили три основных ограничения.

  • Онбординг: основная сложность — в непривычной модели разработки. Большинство фронтендеров используют функциональный подход. Для них архитектура с классами, наследованием и абстракциями может показаться перегруженной и неочевидной.

  • Методы-пустышки: в абстрактном классе есть опциональные методы, которые на некоторых платформах не делают ничего. Это может сбивать разработчиков с толку. Мы сознательно выбрали сделать их пустыми, чтобы не портить пользовательский опыт уведомлениями об ошибках.

  • «Матрёшка»: Electron, Capacitor и PWA — это по сути WebView с дополнительной обёрткой. Из-за этого появляются методы-дублеры, например doUpdateWeb и doUpdateApp. Приложение работает месяцами без перезапуска, и нам нужно уметь принудительно обновлять фронтенд и нативную часть.

В итоге решение, которое мы создали, не избавляет от всех сложностей, но переводит их в управляемую зону.

Немного итогов

Благодаря этому подходу мы сократили время адаптации новых фич с трёх дней до одного, а общее время разработки — примерно в два раза.

Из этого кейса можно сделать несколько простых выводов:

  • Если в проекте начинает расти количество if-ов — это почти всегда сигнал к изменению архитектуры, а не локальная проблема.

  • Классические паттерны вроде Фабрики, Адаптера и Полиморфизма никуда не делись и по-прежнему работают, просто мы отвыкли их применять.

  • Сам подход оказался шире, чем задача с платформами. Его также можно использовать для SSR, IAuth, IAnalytics и IStorage.