0,0
рейтинг
22 апреля 2014 в 06:20

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

Приветствую, уважаемое Хабрасообщество!

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

Сейчас библиотека выложена с парой примеров на 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, то обработчик станет глобальным и будет отрабатывать во всех вкладках.
Примечание 1: следует обратить внимание на то, что Listener будет исполняться в контексте той вкладки, в которой он был запущен событием, поэтому все необходимые для его функционирования данные должны быть заданы в нём явно, либо переданы в объекте события.
Примечание 2: общие обработчики прекращают своё существование при закрытии вкладки, инициировавшей их появление.

__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.

За сим спешу откланяться. :-)
Часто ли перед Вами встаёт задача по организации коммуникаций между вкладками браузера без использования коммуникаций с сервером?

Проголосовало 293 человека. Воздержалось 60 человек.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

Константин Башинский @Sombressoul
карма
32,7
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

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

  • –8
    Сокеты? Не, не слышал об этом =)
    • +4
      WebSocket позволяет обмениваться данными между вкладками/окнами браузера без использования коммуникаций с сервером (т.е. полностью на клиенте, например, если у нас html-страничка сохранена где-то на харде)? =)
  • 0
    del
  • 0
    А зачем столько наворотов? Реально хватило бы просто реализации pubsub, без адресации. Дальше все можно было бы строить поверх этого.
    • +3
      А мне хотелось адресации и было время…
      image
  • +2
    чем postmessage не устроил?
    • 0
      Если мне память не изменяет, то postMessage требует указатель на объект окна, которому передается сообщение. Потому этот метод и используется, как правило, для коммуникации с iframe или окнами/вкладками, которые были открыты скриптами (когда получаем указатель из метода window.open()) — в этом случае всё гладко.

      А как нам получить указатель на вкладку/окно, если, например, пользователь руками дважды набрал адрес нужной страницы в разных окнах, но в одном браузере?
      • 0
        для коммуникации с iframe или окнами/вкладками
        С вкладками? Можно подробнее?
        • 0
          Кидаем тут в консоль:
          var WindowHandler = window.open( 'http://google.com' , '_blank' );
          


          В открывшемся окошке гугла кидаем в консоль:
          function listener( Event ) {
              console.log( Event.data );
          }
           
          if ( window.addEventListener ){
            window.addEventListener( "message" , listener , false );
          } else {
            window.attachEvent( "onmessage" , listener );
          }
          


          Снова возвращаемся сюда и кидаем в консоль:
          WindowHandler.postMessage( 'MyEvilMessage' , '*' );
          


          В консоли гуглового окошка наблюдаем надпись «MyEvilMessage».

          Про это подробнее написано вот тут.
      • +1
        На этот случай есть хак:
        При открытии вкладки (или по другому событию) она меняет свой window.name на какую-нибудь псевдо-уникальную строку и отсылает её через localStorage. Другие вкладки (если есть) ловят onstorage и запускают var hWnd = window.open("", "{{имя окна}}"), получая ссылку на её window.

        Тут есть, конечно, куча ограничений, и самое главное из них — мобильный сафари, он замораживает неактивную вкладку, так что onstorage (и, если мне не изменяет память, даже банальный setTimeout) не выполнится, пока она не станет активной. Выходом из этого могли бы быть Shared Workers, но они тоже были выпилены из этого чудесного браузера.

        • +1
          Shared Workers вообще бы решили всё и не пришлось бы городить огород. Но на тот момент, когда писалась библиотека, их поддержка была чуть менее, чем никакая. Сейчас положение, конечно, лучше, но всё ещё где-то на уровне «м-да»… :)

          И как раз для борьбы с заморозками неактивных вкладок (и отсутствием реакции на события хранилища) я и использовал обычный Worker, который, работая фоном, банально пингует вкладку изнутри. Такая беда не только в мобильном сафари.
          • 0
            От оно как! Надо с самопингующими вёркерами поэкспериментировать, спасибо. Видимо, у них какие-то особые привилегии.

            Согласен, ситуция с шаредами аховая. Но какой-нибудь метод вроде window.getSameDomainWindows(), желательно синхронный, и парочка событий были бы даже лучше. Как вы думаете, может стоит сделать proposal какой-нибудь Мозилле?
            • 0
              Предложение Mozilla сделать, думаю, стоит, но… что-то мне подсказывает, что это будет уже далеко не первое предложение о внедрении подобного функционала. Потому что судя по опросу в конце топика, почти половина программистов сталкивается с необходимостью внедрять подобную коммуникацию. Плюс на том же stackoverflow видел кучу вопросов по этой теме ещё с бородатых годов (когда народ ещё через Cookies и setInterval это всё пытался сделать).

              Стало быть вопрос стар, как мир. Почему не внедрили до сих пор? — Вероятно, есть какие-то причины.

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

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

              Так что, всё же, палка о двух концах, как мне кажется, и внедрять это на нативном уровне — опасно.
              • 0
                Ваша правда. При достаточно расхлябанной защите можно и сейчас увести админские куки или открыть админку в ифрейме, а такой метод давал бы слишком много удобства.

                Но с другой стороны можно заложить необходимость явного включения автообнаружения для каждой вкладки, скажем, не выполнил window.Domain.enableAutoDiscovery(true), ссылку из другого окна не получишь.

                Ещё один минус межвкладочного взаимодействия через ссылку на другое окно — возможность создать адскую утечку памяти, от которой даже F5 не спасёт. Но тут уже надо программисту просто знать, что он делает.
                • 0
                  А вот в таком виде смысл есть… Хмм…

                  Как там происходит процедура внесения предложений Мозилле? =)
  • +1
    Тоже такое делал: github.com/StreetStrider/XTab

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.