Введение

В наше время чуть ли не каждое приложение использует браузерный клиент. Это просто в написании, это кроссплатформенно, это легко в использовании. Браузерные решения уже активно используются и в промышленной сфере: аналитиками, операторами. WEB приложения для управления промышленными платформами могут быть настолько функциональны, что вся их мощь не укладывается в один монитор, а ведь на рабочем месте может быть ни один, и ни два монитора, а даже больше пяти. Но что же делать, если окна приложений ещё и должны являться частью одной системы и предоставлять возможности удобного взаимодействия между друг другом? Эту проблему я бы и хотел осветить.

Меня зовут Дмитрий Дербин – frontend-разработчик компании «Криптонит». В данном материале я поделюсь некоторой теоретической базой по теме и краткими наработками для реализации на Vue 3 на примере простого сайта с погодой.

Проект

Для начала разберёмся, как это должно работать. Приложение состоит из двух окон: первое выступает в качестве конфигуратора запросов, а второе запрашивает и отображает данные по собранным запросам.

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

Примерная схема работы приложения
Примерная схема работы приложения

Данное приложение будет открыто в отдельных окнах браузера на нескольких мониторах, подключённых к одной машине. Различные экраны реализованы посредством разных роутов во vue-router, но являются частями одного приложения.

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

Способы

Теперь дело за выбором используемой технологии. В этом мне помог обзор способов синхронизации вкладок браузера.

В данной статье приведено 3 возможных варианта:

  1. Web Workers;

  2. BroadcastChannel;

  3. LocalStorage.

Web Workers

Под этим названием объединены SharedWorker и ServiceWorker. И тут сразу же проблема: в Chrome стоит политика безопасности – при отсутствии валидного SSL-сертификата, браузер не позволит нам без танцев с бубном запустить приложение, что создаст сложности при локальном тестировании и реализации демо. Поэтому этот вариант пропустим.

BroadcastChannel

Звучит классно, для нашей задачи уже есть браузерный API BroadcastChannelAPI.
Но есть проблема в подходе – данная технология предназначена скорее для передачи событий, нежели для создания общего состояния. Конечно, можно написать обёртку, которая будет реализовывать общее состояние и синхронизировать его посредством событий, но это потребует дополнительного времени на реализацию и тестирование.

LocalStorage

Всем давно известный способ для поддержания состояния приложения при перезагрузке страницы. Однако его можно использовать и для синхронизации окон. Единственным его недостатком является ограничение по размеру (тестирование максимального размера LocalStorage).

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

Выбор

Мы рассмотрели некоторые из возможных вариантов реализации общего хранилища данных. С точки зрения производительности каждый из способов требует двусторонней сериализации, так что явного фаворита нет, да и требования к передаче большого объёма данных у нас не стоит.

В итоге я выбрал LocalStorage, и на то есть ряд причин:

  1. Наиболее широкая поддержка браузерами.

  2. Простота реализации благодаря тому, что во VueUse уже есть хук с поддержкой обработки storage события (документация).

  3. LocalStorage уже реализует необходимый приложению подход общего хранилища, а не шины событий.

Остальные подходы также хороши, но наиболее простым в понимании и интеграции является LocalStorage.

Построение инфраструктуры на основе LocalStorage

Теперь рассмотрим, как можно интегрировать LocalStorage в структуру приложения.

На первом этапе реализации мы напишем store, которым может пользоваться каждый экран, данные которого будут синхронизироваться между страницами.

Схема взаимодействия экранов посредством LocalStorage
Схема взаимодействия экранов посредством LocalStorage

Каждая страница создает свой инстанс хранилища, но данные этих инстансов синхронизируются посредством хука useLocalStorage.

Рассмотрим пример кода такого стора:

export const useWeatherStore = defineStore('weather', () => {
   const requests = useLocalStorage('weather:requests', []);

   function add(city: string) {
       requests.value.push(city);
   }

   const weatherList = asyncComputed(
       () => Promise.all(requests.value.map((city) => weatherApi.getWeather(city))), 
       []
   );

   return {
       requests,
       weatherList,

       add,
   };
});

Переменная requests будет общей для всех экранов. На первом экране мы настраиваем запрос и вызываем метод add, второй экран отреагирует на изменение переменной requests и выполнит запрос за данными о погоде и сможет их визуализировать из переменной weatherList.

Такая реализация уже имеет право на жизнь и будет выполнять свою задачу, но существует две проблемы:

  1. Запрос за данными о погоде отправят обе страницы приложения, так как на обеих страницах был инициирован стор с наблюдателем. Это создаст лишнюю нагрузку и на клиентскую и серверную часть.

  2. Слишком большая зона ответственности стора useWeatherStore. Он предоставляет возможности, которые будут излишни и для экрана с погодой (создание запросов), и для экрана-конфигуратора (получение данных о погоде). Это сделает стор сложно поддерживаемым при его масштабировании.

Исправим эти проблемы вынесением логики получения данных в отдельный стор.

export const useWeatherRequestsStore = defineStore('weather-requests', () => {
   const requests = useLocalStorage('weather-requests:requests', []);

   function add(city: string) {
       requests.value.push(city);
   }
  
   return {
       requests,

       add,
   };
});


export const useWeatherStore = defineStore('weather', () => {
   const { requests } = storeToRefs(useWeatherRequestsStore());

   
   const weatherList = asyncComputed(
       () => Promise.all(requests.value.map((city) => weatherApi.getWeather(city))), 
       []
   );

   return {
       weatherList,
   };
});
Улучшенная схема взаимодействия
Улучшенная схема взаимодействия

Превосходно, теперь каждый стор отвечает лишь за конкретную задачу, соответственно поддерживать его станет проще. Кроме того, запрос будет отправлен только при запуске useWeatherStore.

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

Если нет возможности использовать хук из vue use, можно написать свою реализацию данного хука. Вот простой пример:

export function useLocalStorage<T>(key: string, initial: T): Ref<T> {
   function getLS(): T | undefined {
       const value = localStorage.getItem(key);
       return value != null ? (JSON.parse(value) as T) : undefined;
   }

   function setLS(value: typeof initial): void {
       localStorage.setItem(key, JSON.stringify(value));
   }

   if (getLS() === undefined) setLS(initial);
   const state = shallowRef(getLS());

   window.addEventListener('storage', (event: StorageEvent) => {
       if (event.key === key) {
           state.value = getLS();
       }
   });

   return computed({
       get: () => state.value,
       set: (value) => {
           state.value = value;
           setLS(value);
       },
   });
}

Можно даже поменять его название на useSharedState и при необходимости изменить реализацию на SharadWorker или BroadcastChannel.

Итог

Среди возможных подходов к реализации межоконного взаимодействия нами был выбран LocalStorage и небольшая обёртка useLocalStorage из библиотеки VueUse.

Для интеграции с приложением мы написали два стора: первый отвечает за хранение данных о запросах и синхронизирует своё состояние между окнами, а второй получает данные от сервера, используя параметры запроса из первого.

Важный момент по поводу хука useLocalStorage. Он привязан к life cycle компонентов Vue, поэтому, чтобы он корректно работал, необходимо совершить простой вызов использующего его стора в App.vue или main.ts.

Данная реализация – это скорее пример реализации архитектуры, при необходимости, можно переложить похожий подход на другую технологию, написав обёртку под нужный API браузера.


Используемые материалы:

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


  1. YuriyBakutin
    26.04.2024 21:39
    +1

    На связке indexedDB и Dexie.js можно реализовать полноценный реактивный store, правда, асинхронный. Но вряд ли асинхронность будет проблемой.


    1. T1HABR Автор
      26.04.2024 21:39
      +2

      Благодарю за отличное предложение. Рассматривая дальнейшие перспективы масштабирования (в случае появления требования к хранению большого объема данных) смотрел в сторону IndexedDB. Однако мне не понравилось, что нет нативной возможности получать событие об изменении на других вкладках, нашел только возможность с поллингом, что не очень понравилось.
      Не задумывался про параллельный канал для нотификации о мутации, так что спасибо за идею!