Библиотека для обмена событиями, данными и задачами между вкладками браузера

в 2:20, , рубрики: javascript

Приветствую, уважаемое читатели!

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

Сейчас библиотека выложена с парой примеров на GitHub, а под хабракатом хотелось бы осветить некоторые тонкости её применения и часть внутренней логики. Буду рад, если моя библиотека поможет кому-то сэкономить n-ое количество времени и позволит избежать изобретения собственного велосипеда.

Кому интересно — добро пожаловать под кат.

Для чего это нужно и где это может быть полезно

Например, в интернет-магазинах, когда пользователь открыл десяток вкладок и в одной из них добавил товар в корзину — мы сможем сообщить всем другим вкладкам, что нужно изменить состояние в соответствии с действиями пользователя (обновить количество товаров в корзине). Или, например, если мы хотим создать у себя на сайте плеер, аля, ВКонтакте, который бы глушил воспроизведение во всех других вкладках, если пользователь решил включить что-то другое.

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

Что умеет библиотека

— Отслеживание текущего количество открытых вкладок, реакция на события открытия новых/закрытия старых вкладок.
— Возможность задания callback функций на те или иные события (с возможностью привязки их только к определенным вкладкам).
— Возможность передачи данных в объекте Event, если какая-либо вкладка сообщила о произошедшем событии.
— Возможность сообщить о событии только какой-то определенной вкладке (или группе вкладок).
— Выполнять код в контексте нужной вкладки, инициируя данное действо из другой вкладки.

Пара слов о внутреннем устройстве

В первую очередь стоит сказать, что для своего функционирования библиотека требует поддержу Blob, Blob.URL, Worker и localStorage со стороны браузера.

По интернету гуляет множество всевозможных идей о реализации обмена сообщений между вкладками, большинство из которых упирается либо в невозможность отследить закрытие вкладки, либо не позволяет из одной вкладки послать на выполнение код в другую вкладку, либо в особенность некоторых современных браузеров выполнять SetTimeout неактивной вкладки лишь раз в секунду (что при 5-10 открытых вкладках выливается с серьезную задержку), либо в отсутствие вменяемой поддержки на события localStorage, либо реализуется через общение с сервером. И ещё с десяток причин «против».

В качестве решения был выбран следующий путь:
1. Использовать localStorage для хранения объекта (в запакованном виде), содержащего расписание внутреннего планировщика заданий, список текущих событий, данные об активных вкладках, конфигурацию и некоторые другие служебные данные.
2. Для отслеживания появления/закрытия вкладок использовать Worker (здесь как раз и требуется Blob и Blob.URL), задачи которого сводятся к фоновому пингу вкладки (т.к. Worker игнорирует ограничения браузеров на частоту таймаутов неактивных вкладок).
3. Использовать внутренний планировщик заданий для последовательного исполнения вкладками необходимых задач (по пингу от воркера).

Описание API

У библиотеки нет каких-то внешних зависимостей. При её подключении в глобальной области видимости становится доступен объект __SE__.

Системные события

По умолчанию в библиотеке реализованы три глобальных события.
tabOpened — вызывается, когда открывается новая вкладка.
tabClosed — вызывается, когда одна из вкладок закрывается (вылетает по таймауту).
tabNameChanged — вызывается при изменении имени вкладки.

Опции конфигурации

__SE__.Name — имя текущей вкладки (строка по маске «a-zA-Z_-d»). Используется для того, чтобы сообщать о событиях определенным вкладкам. Если несколько вкладок обладают одинаковым именем, то этот параметр приобретает свойства группировщика, когда событие улетает сразу группе одноименных вкладок.
Значение по-умолчанию: «Default»

__SE__.SelfExecution — флаг, отвечающий на вопрос «Исполнять ли в текущей вкладке события, которые были инициированы ей самой?». Проще говоря, если у нас есть две вкладки с именем «MyTabName» и одна из них сообщает о каком-то событии вкладкам с именем «MyTabName», то в зависимости от установленного флага SelfExecution будет принято решение, уведомлять ли саму вкладку-инициатор о произошедшем событии.
Значение по-умолчанию: false (не уведомлять о собственных событиях).
Примечание 1: данный флаг актуален только при работе с общими обработчиками событий, о них ниже.
Примечание 2: если событие глобальное (инициировано без передачи третьего аргумента в методе __SE__.dispatchEvent(), либо с передачей в качестве третьего аргумента константы __SE__.GID), то данный флаг будет проигнорирован.

__SE__.Sync — параметр в миллисекундах, указывающий Worker'у с какой частотой пинговать вкладку.
Значение по-умолчанию: 100 (внутренняя константа DEFAULT_WORKER_INTERVAL).

__SE__.TabTimeoutMult — множитель, указывающий, сколько циклов ожидать вкладку, прежде чем посчитать, что она закрыта.
Значение по-умолчанию: 2 (внутренняя константа DEFAULT_TAB_TIMEOUT_MULTIPLIER).

__SE__.SLockTimeoutMult — множитель, указывающий, сколько «тиков» ожидать снятия блокировки с объекта в localStorage.
Значение по умолчанию: 2 (внутренняя константа DEFAULT_STORAGE_LOCK_MULTIPLIER).

При изменении любого из трёх параметров (__SE__.Sync, __SE__.TabTimeoutMult и __SE__.SLockTimeoutMult) новые значения автоматически синхронизируются с другими вкладками и вступают в силу только после полной синхронизации. Данные три параметра влияют на внутреннюю механику работы синхронизатора вкладок, в частности:
1) Доступ к объекту, хранящему конфигурацию библиотеки в localStorage имеет механизм внутренней блокировки (чтобы вкладки не попортили хранимые данные и выполнение задач происходило строго по очереди от вкладки к вкладке). У встроенного «замка» есть срок давности, который разблокирует хранилище по таймауту, если активная вкладка (работающая с хранилищем и установившая замок) была закрыта. Этот таймаут вычисляется по формуле:
__SE__.Sync * __SE__.SLockTimeoutMult
2) Индикация закрытия вкладки определяется по формуле:
__SE__.Sync * __SE__.ActiveTabsCount * __SE__.TabTimeoutMult

Константы

__SE__.GID — идентификатор глобального события или глобального обработчика (по-умолчанию соответствует "__Global__"): если указать эту константу в качестве указателя имени вкладки, которой требуется передать событие, то событие получат все открытые вкладки. Этот идентификатор передаётся по-умолчанию, если не указать целевую вкладку в методе __SE__.dispatchEvent(). Если же передать этот идентификатор в качестве третьего аргумента метода __SE__.addEventListener(), то обработчик на соответствующее событие станет глобальным и будет отрабатывать сразу во всех вкладках.

__SE__.ID — уникальный идентификатор текущей вкладки. Генерируется при инициализации библиотеки.

Переменные

__SE__.ActiveTabsCount — хранит значение текущего количества открытых вкладок. Обновляется с каждым циклом внутреннего планировщика заданий и частота обновлений (в общем случае) равна произведению __SE__.Sync на количество открытых вкладок.

Методы

__SE__.getActiveTabs( void )
Возвращает массив объектов, описывающих текущие открытые вкладки:

// Репрезентация объекта вкладки.
var TabObjectExample =
    {
        'Checked'     : true ,          // Служебное поле планировщика очереди исполнения заданий вкладок.
        'ConfigCache' : Object ,        // Локальный кеш конфигуации для вкладки.
        'ID'          : "152644ab-7138-297c-60a4-efd8d8a8380c" , // Внутренний уникальный ID вкладки.
        'Name'        : "Default" ,     // Назначенное имя.
        'Ping'        : 1398107406052   // TimeStamp последнего пинга вкладки.
    };

__SE__.addEventListener( Type, Listener [, TabName ] )
Добавляет обработчик события.
Обработчики событий бывают локальные и общие.
Локальные обработчики событий: хранятся в дебрях объекта __SE__ и работают в рамках текущей вкладки. Чтобы создать локальный обработчик события достаточно просто не передавать третий аргумент в этот метод.
Общие обработчики событий: хранятся в объекте конфигурации библиотеки в localStorage, как SharedEventListener (общий обработчик, доступный всем вкладкам). Данный тип обработчиков создаётся при передаче аргумента TabName.
Если в качестве аргумента TabName использовать константу __SE__.GID, то обработчик станет глобальным и будет исполняться во всех вкладках, при возникновении в любой из них соответствующего события.
Type — тип события, на которое следует реагировать. Строка по маске «a-zA-Z». Обязательный параметр.
Listener — функция обработчик, которая будет исполнена при возникновении соответствующего события. Обязательный параметр.
При исполнении функции в неё передаётся объект, содержащий данные о событии и пользовательские данные:

// Репрезентация объекта события.
var EventObjectExample = 
    {
        'Data'      : false ,       // Пользовательский набор данных (второй аргумент метода __SE__.dispatchEvent()).
        'Sender'    :               // Информация о вкладке инициировавшей событие.
            {
                ID      : "81e0eaf0-3a02-15e1-b28c-7aa1629801c0" ,  // Уникальный идентификатор вкладки.
                Name    : "Default"                                 // Название.
            } ,
        'Timestamp' : 1398113182091 // TimeStamp возникновения события.
    };

TabName — название вкладки, которой назначается обработчик события. Строка по маске «a-zA-Z_-d». Если в качестве имени вкладки передать константу __SE__.GID, то обработчик станет глобальным и будет отрабатывать во всех вкладках.
Примечание: следует обратить внимание на то, что Listener будет исполняться в контексте той вкладки, в которой он был запущен событием, поэтому все необходимые для его функционирования данные должны быть заданы в нём явно, либо переданы в объекте события.

__SE__.hasEventListener( Type [, Listener [, TabName, Callback ] ] )
Проверяет наличие обработчиков событий. Принимает один, два, либо четыре аргумента (но не три).
Type — тип проверяемого события. Обязательный параметр.
Listener — функция, на наличие которой в качестве обработчика события осуществляется проверка.
Примечание: аргумент Listener может принимать значение false, если используется вкупе с третьим и четвертым аргументами, а целью является определить наличие не какого-то конкретного обработчика, а лишь факт наличия обработчика, как такового.
Если передан только один или первые два аргумента, то проверка происходит по локальным обработчикам событий и результат проверки возвращается сразу. Пример:

/*
* Код для первой вкладки.
*/    
var tabOpenedCallback = function(){
    console.log( 'Local __SE__ event called on tabOpened.' );
};
// Вешаем обработчик на системное событие открытия вкладки.
__SE__.addEventListener( 'tabOpened' , tabOpenedCallback );

__SE__.hasEventListener( 'tabOpened' , tabOpenedCallback ); //=> true
__SE__.hasEventListener( 'tabOpened' ); //=> true
__SE__.hasEventListener( 'someOtherEvent' ); //=> false

/*
* Открываем вторую вкладку и в консоле первой наблюдаем:
* => Local __SE__ event called on tabOpened.
*/

TabName — имя вкладки, для которой осуществляется проверка на наличие общего обработчика события. Если передать в качестве этого аргумента false, то будет произведён поиск по принципиальному наличию хоть какого-то общего обработчика на искомое событие. Если же в качестве этого аргумента передать константу __SE__.GID, то поиск будет осуществляться только по глобальным обработчикам.
Callback — функция, принимающая в качестве аргумента результат проверки.
При передаче в метод __SE__.hasEventListener() всех четырёх аргументов, проверка происходит по общим обработчикам событий и результаты проверки возвращаются в Callback-функцию.
Примечание 1: при проверке общих обработчиков событий, аргументы Listener и TabName можно передать, как false — в этом случае будет произведена проверка существования в принципе какого-либо общего обработчика на данное событие.
Примечание 2: если необходимо проверить существование локальных обработчиков у другой вкладки, то сделать это можно назначив требуемой вкладке общий обработчик (возвращающий событие с результатами проверки) и сразу же инициировать вызывающее событие.
Пример:

/*
* Код для первой вкладки:
*/    
var tabOpenedCallback = function(){
    document.write( 'Shared __SE__ event called on tabOpened.' );
};
// Вешаем обработчик на вкладку с именем "TestTab" на системное событие открытия новой вкладки.
__SE__.addEventListener( 'tabOpened' , tabOpenedCallback , 'TestTab' );

/*
* Открываем вторую вкладку. Код для неё:
*/    
__SE__.Name = 'TestTab';

/*
* Открываем третью вкладку. В этот момент на второй вкладке отрабатывает общий обработчик и появляется надпись:
* => Shared __SE__ event called on tabOpened.
* Код для третьей вкладки:
*/
var CheckCallback = function( CheckResult ){
    console.log( CheckResult );
};
__SE__.hasEventListener( 'tabOpened' , false , 'TestTab' , CheckCallback ); //=> передаст в функцию true
__SE__.hasEventListener( 'tabOpened' , false , false , CheckCallback ); //=> передаст в функцию true, т.к. общий обработчик на это событие в принципе существует
__SE__.hasEventListener( 'tabOpened' , false , __SE__.GID , CheckCallback ); //=> передаст в функцию false, т.к. обработчик не глобальный
__SE__.hasEventListener( 'tabOpened' , false , 'NotExistingTab' , CheckCallback ); //=> передаст в функцию false

__SE__.removeEventListener( Type [, Listener [, TabName, Callback ] ] )
Удаляет обработчик события. Принимает один, два или четыре (но не три) аргумента.
По общей механике полностью совпадает с принципами работы метода __SE__.hasEventListener(): при одном/двух аргументах работает с локальными обработчиками событий, при четырёх — с общими обработчиками.
Примечание 1: всегда возвращает true.
Примечание 2: если вместо Listener и TabName в обоих случаях передать false, то будут удалены все общие обработчики, привязанные к определенному событию. Если задан TabName, но вместо Listener передано false, то будут удалены все общие обработчики у выбранной вкладки. Если же передан Listener, но TabName == false, то у всех вкладок будет удалён искомый общий обработчик.
Примечание 3: для удаления локального обработчика события у другой вкладки, необходимо исполнить код удаления в контексте этой вкладки. Для этого нужно назначить искомой вкладке дополнительный общий обработчик на определенное событие и сообщить об этом событии. Главное, не забыть потом подчистить концы.
Пример к примечанию 3:

/*
* Код для первой вкладки:
*/
__SE__.Name = 'MainTab'; // зададим текущей вкладке имя
var someUserEventCallback = function( Event ){
    document.write( 'Local __SE__ event called by tab "' + Event.Sender.Name + '" on ' + Event.Timestamp + '<br>' );
};
// Вешаем локальный обработчик на текущую вкладку на какое-то кастомное событие.
__SE__.addEventListener( 'someUserEvent' , someUserEventCallback );

/*
* Открываем вторую вкладку. Код для неё:
*/    
__SE__.dispatchEvent( 'someUserEvent' ); // сообщим глобальное событие, после которого в первой вкладке увидим надпись

// Функция для удаления локальных обработчиков будет исполнена в контексте требуемой вкладки.
var TabCallbackRemover = function(){
    __SE__.removeEventListener( 'someUserEvent' ); // удаляем локальный обработчик события
    __SE__.removeEventListener( 'removeListener' , false , 'MainTab' , function(){} ); // подчищаем концы
};
__SE__.addEventListener( 'removeListener' , TabCallbackRemover , 'MainTab' ); // вешаем обработчик на первую вкладку
__SE__.dispatchEvent( 'removeListener' ); // запускаем на исполнение

__SE__.dispatchEvent( 'someUserEvent' ); // теперь это сообщение ни к чему не приведёт, т.к. локальный обработчик с первой вкладки был удалён

__SE__.dispatchEvent( Type [, Data [, TabName ] ] )
Сообщает о событии. Может принимать от одного до трёх аргументов (в зависимости от требуемого поведения).
Type — тип сообщаемого события. Строка по маске «a-zA-Z». Обязательный параметр.
Data — данные, которые будут переданы в поле Data объекта события, передаваемого в функцию-обработчик. Формат передаваемых данных произвольный (строка, массив, объект). Значение, передающееся по-умолчанию: false.
TabName — имя вкладки или вкладок (если используется группировка по имени), которым следует сообщить о произошедшем событии. Значением по-умолчанию выступает константа __SE__.GID — т.е. сообщение о событии улетает всем без исключения вкладкам.
Пара примеров:

// Глобальное событие без передачи данных.
__SE__.dispatchEvent( 'MyGlobalEvent' );

// Глобальное событие с передачей объекта.
__SE__.dispatchEvent( 'MyGlobalEvent' , { SomeField : 'SomeValue' } );

// Передать событие определенной вкладке или группе вкладок.
__SE__.dispatchEvent( 'MyTargetedEvent' , false , 'TargetTabName' );

В качестве заключения

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

Если кому-то интересно, что собой представляет объект конфигурации в localStorage, то пожалуйста:

JSON.parse( localStorage[ '__SE__' ] );

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

За сим спешу откланяться. :-)

Автор: Sombressoul

Источник

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


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