Многим разработчикам периодически требуется наладить общение между несколькими вкладками браузера: возможность посылать сообщения из одной в другую и получать ответ. Такая задача встала и перед нами.
Существуют стандартные решения вроде BroadcastChannel, однако поддержка в браузерах сейчас оставляет желать лучшего, поэтому мы решили реализовать свою библиотеку. Когда библиотека была готова, выяснилось, что такая функциональность уже не нужна, зато появилась другая задача: нужно было общаться между iframe и основным окном.
При ближайшем рассмотрении выяснилось, что две трети библиотеки при этом можно не менять, необходимо только немного порефакторить код. Библиотека представляет из себя скорей ПРОТОКОЛ общения, который может работать с текстовыми данными. Его можно применять во всех случаях, если есть возможность передавать текст (iframe, window.open, worker, вкладки браузера, WebSocket).
Как это работает
На данный момент в протоколе есть две функциональности: отправка сообщения и подписка на события. Любое сообщение в протоколе — это объект с данными. Главное поле этого объекта — поле type, которое говорит нам, что это за сообщение. Поле type — это enum со значениями:
- 0 — отправка сообщения
- 1 — отправка запроса
- 2 — получение ответа.
Отправка сообщения
Отправка сообщения не подразумевает ответа. Для отправки события мы конструируем объект с полями:
- type — тип события 0
- name — наименование события пользователя
- data — данные пользователя (JSON-like).
При получении сообщения на другой стороне с полем type = 0 мы знаем, что это — событие и что есть имя события и данные. Остается лишь запустить событие (почти обычный паттерн EventEmitter).
Схема работы с событиями:
Отправка запроса
Отправка запроса подразумевает, что внутри библиотеки формируется ID запроса, библиотека будет ожидать ответа с данным ID, и после успешного ответа из него будут удалены служебные поля, а ответ вернется пользователю. Кроме того, можно установить максимальное время ожидания ответа.
С запросом все обстоит несколько сложнее. Чтобы ответить на запрос, необходимо объявить методы, которые доступны в нашем протоколе. Это делается с помощью метода registerRequestHandler. Он принимает имя запроса, на который будет отвечать, и функцию, которая возвращает ответ. Для создания запроса нам нужен id, и в общем-то можно использовать timestamp, но это очень не удобно отлаживать. Поэтому это id класса который отправляет запрос + порядковый номер запроса + строковая константа. Далее мы конструируем объект с полями id, type — со значением 1, name — наименование запроса, data — данные пользователя (JSON-like).
При получении запроса мы проверяем, есть ли у нас API для ответа на данный запрос, если API нет — возвращаем ошибку. Если API есть — возвращаем результат выполнения функции из registerRequestHandler, с соответствующим именем запроса.
Для ответа формируется объект с полями type — со значением 2, id — id сообщения на которое отвечаем, status — поле, которое говорит, является ли данный ответ ошибкой (если нет API, или в обработчике пользователя произошла ошибка, или пользователь вернул Rejected Promise, другие ошибки (serialize)), content — данные ответа.
Таким образом мы описали работу самого протокола, который реализует класс Bus, но не описали, как собственно отправлять и получать сообщения. Для этого нужны адаптеры — класс с 3 методами:
- send — метод, который собственно отвечает за отправку сообщения
- addListener — метод для подписки на события
- destroy — для уничтожения подписок при уничтожении Bus.
Адаптеры. Реализация протокола.
Чтобы запустить все это, на данный момент готов только адаптер для работы с iframe/window. Работает он на postMessage и addEventListener. Тут все достаточно просто: нужно отправить сообщение в postMessage с правильным origin и слушать сообщения через addEventListener на событии "message".
Небольшие тонкости, с которыми мы столкнулись:
- Слушать ответы всегда стоит на СВОЕМ окне, а отправлять на ЧУЖОМ (iframe, opener, parent, worker, ...).
Дело в том, что при попытке слушать сообщение на ЧУЖОМ окне, если origin отличается от текущего, возникнет ошибка. - При получении сообщения убедитесь что оно отправлено вам (на окне срабатывает куча сообщений от аналитики,
WebStrom (если вы им пользуетесь), чужих iframe, поэтому следует убедиться, что событие — в нашем протоколе и для нас). - Нельзя возвращать Promise с экземпляром Window, так как Promise при возврате результата пытается проверить, есть ли у результата метод then, и, если у вас нет доступа к окну (окно с другим origin, например), возникнет ошибка (хоть и не во всех браузерах). Чтобы избежать этой проблемы, достаточно обернуть окно в объект и класть в Promise объект, в котором есть ссылка на нужное окно.
Примеры использования:
Библиотеку можно установить с помощью своего любимого пакетного менеджера — @waves/waves-browser-bus
Чтобы установить двустороннюю связь с iframe, достаточно написать код:
import { Bus, WindowAdapter } from '@waves/waves-browser-bus';
const url = 'https://some-iframe-content-url.com';
const iframe = document.createElement('iframe');
WindowAdapter.createSimpleWindowAdapter(iframe).then(adapter => {
const bus = new Bus(adapter);
bus.once('ready', () => {
// Получено сообщение от iframe
});
});
iframe.src = url; // Предпочтительно присваивать url после вызова WindowAdapter.createSimpleWindowAdapter
document.body.appendChild(iframe);
И внутри iframe:
import { Bus, WindowAdapter } from '@waves/waves-browser-bus';
WindowAdapter.createSimpleWindowAdapter().then(adapter => {
const bus = new Bus(adapter);
bus.dispatchEvent('ready', null); // Отправили сообщение в родительское окно
});
Что дальше?
Получился гибкий и универсальный протокол, который можно использовать в любой ситуации.
Теперь я планирую отделить адаптеры от протокола и вынести их в отдельные npm-пакеты, добавить адаптеры для работы с worker и вкладками браузера. Хочется, чтобы писать адаптеры, реализующие протокол для любых других нужд, было максимально просто.
Если у вас есть желание присоединиться к разработке или идеи по функционалу библиотеки — милости прошу в репозиторий.