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


Существуют стандартные решения вроде 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 и вкладками браузера. Хочется, чтобы писать адаптеры, реализующие протокол для любых других нужд, было максимально просто.


Если у вас есть желание присоединиться к разработке или идеи по функционалу библиотеки — милости прошу в репозиторий.

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


  1. printf
    13.06.2019 13:54

    Симпатично! Поставил звездочку в гитхабе.

    Мне нужна была почти такая же штука для коммуникации промеж открытыми табами, да еще и с поддержкой старых браузеров, получилось вот что. Тоже EventEmitter, но на базе localStorage.


    1. TsDaniil Автор
      13.06.2019 14:14

      Спасибо!

      Есть в планах добавить адаптер для разных вкладок, и вынести адаптеры в отдельные репозитории.
      Кроме того в планах дописать типизацию для запросов.


    1. shushu
      13.06.2019 15:07

      BroadcastChannel здесь подошел бы лучше


      1. printf
        13.06.2019 17:28

        Подошел бы, но у нас корпоративные клиенты с осликом IE вместо браузера.


  1. den4kox
    13.06.2019 14:14

    Вкратце:
    title: Протокол для общения между iframe и основным окном браузера
    content: postMessage


    1. Zenitchik
      13.06.2019 14:44

      Угу. Я подобный велосипед как-то раз писал. Но конкретная реализация кажется интересной, будет нечем заняться — гляну в код.


  1. torf
    13.06.2019 15:58

    Есть же вот такая штука https://easyxdm.net/


    1. TsDaniil Автор
      13.06.2019 16:24

      Смысл данной библиотеки в том, чтобы отделить транспорт библиотеки от АПИ, таким образом мы можем писать реализацию протокола к любым задачам. Мы получаем возможность работать с любыми АПИ которые поддерживают обмен текстом. Например в nodejs общение с базами и прочее. Любая задача где требуется доставить сообщение на любой платформе может быть реализована на базе этой библиотеки, нужно только написать свой адаптер, который реализует отправку и получение сообщений.