Протокол для общения между iframe и основным окном браузера

в 8:42, , рубрики: iframe, javascript, promise, TypeScript, window.open, window.opener, Блог компании Waves, Разработка веб-сайтов

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

Существуют стандартные решения вроде BroadcastChannel, однако поддержка в браузерах сейчас оставляет желать лучшего, поэтому мы решили реализовать свою библиотеку. Когда библиотека была готова, выяснилось, что такая функциональность уже не нужна, зато появилась другая задача: нужно было общаться между iframe и основным окном.

При ближайшем рассмотрении выяснилось, что две трети библиотеки при этом можно не менять, необходимо только немного порефакторить код. Библиотека представляет из себя скорей ПРОТОКОЛ общения, который может работать с текстовыми данными. Его можно применять во всех случаях, если есть возможность передавать текст (iframe, window.open, worker, вкладки браузера, WebSocket).

Как это работает

На данный момент в протоколе есть две функциональности: отправка сообщения и подписка на события. Любое сообщение в протоколе — это объект с данными. Главное поле этого объекта — поле type, которое говорит нам, что это за сообщение. Поле type — это enum со значениями:

  • 0 — отправка сообщения
  • 1 — отправка запроса
  • 2 — получение ответа.

Отправка сообщения

Отправка сообщения не подразумевает ответа. Для отправки события мы конструируем объект с полями:

  • type — тип события 0
  • name — наименование события пользователя
  • data — данные пользователя (JSON-like).

При получении сообщения на другой стороне с полем type = 0 мы знаем, что это — событие и что есть имя события и данные. Остается лишь запустить событие (почти обычный паттерн EventEmitter).

Схема работы с событиями:

Протокол для общения между iframe и основным окном браузера - 1

Отправка запроса

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

Протокол для общения между iframe и основным окном браузера - 2

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

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

Автор: TsDaniil

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js