За 5 лет разработки расширений для браузера Google Chrome накопился некоторый опыт, которым хотелось бы поделиться в цикле статей и, по возможности, пояснить некоторые тонкости, подводные камни, а также описать как были удачно применены современные фронтенд-технологии.

Часть 1.


Введение


С расширениями для браузера Google Chrome я столкнулся в 2012-м году когда активно покупал товары с витрины Amazon и было там жутко неудобно искать продавцов, которые доставляли товар в РФ и у кого выгоднее всего покупать с учетом стоимости доставки. Вот тут я и решил облегчить жизнь себе, да и другим покупателям тоже, создав расширение «Amazon ships to you”, про которое была даже когда-то статья на Хабре. Со временем оно стало не актуально, т.к. на витрине Amazon сделали спустя пару лет нормальные фильтры и я его снял с публикации.

Далее наступило время пользования сервисом Я.Музыка и Я.Радио и очень уж мне не хватало управления плеером на сайте Я.Музыки когда он играет в фоновой вкладке, ну хотя бы горячими клавишами. В результате, я, как человек опытный, не найдя аналогов, решил сделать для этого расширение “Яндекс.Музыка — управление плеером», которое является моим хобби и по сей день, несмотря даже на выход официального расширения от Яндекса.

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

Ликбез по расширениям
Расширение — это программный пакет, расширяющий функционал браузера. Распространяется через официальный Chrome Web Store, также можно загрузить локальное расширение в режиме разработчика на странице chrome://extensions/, однако с ними Google борется всё больше закручивая гайки.

Раньше можно было локальными расширениями пользоваться без проблем, затем браузер начал оповещать уведомлением о наличии таких расширений, теперь же подобные расширения по умолчанию отключаются при запуске браузера и их надо активировать вручную (что немало насолило разработчикам расширений при активной разработке).


Все метаданные расширения описываются в манифесте, но о нем нечего особо сказать, в документации все описано.

Расширение состоит из фоновой страницы (background page или event page), всплывающего окна, страницы настроек, внедряемого на целевой сайт контент-скрипта и специфических override pages, которые переопределяют стандартные страницы браузера: менеджер истории, закладок, новая страница и т.п.

С точки зрения UI, расширение содержит элемент browserAction (или pageAction) — иконку на панели браузера справа от адресной строки, нажатие на которую может инициировать какое-либо действие или же открывает еще один UI элемент — всплывающее окно. Также на этой иконке можно выводить badge — значок, например, с количеством непрочитанных писем.

Браузер предоставляет расширениям множественные API для различных нужд, например через chrome.tabs API можно получить доступ ко всем вкладкам, закрывать, открывать новые, видеть метаданные вкладок и т.п.

Плюс к этому, между различными страницами расширения есть определенные механизмы передачи данных.

Песочницы


Типовая задача расширения — расширение функционала некоторого сайта, для чего необходимо внедрить код на целевую страницу. Возьмем для примера Я.Музыку (здесь и далее): для управления плеером на сайте через всплывающее окно расширения (см. скриншот №1), нам необходимо отследить открытие страницы, затем внедрить в нее наш код, который в дальнейшем будет взаимодействовать с js-кодом и DOM витрины и передавать все изменения в расширение, чтобы при открытии всплывающего окна всегда отображать актуальное состояние плеера на витрине. Вот тут и появляется то важное, о чем я хотел бы для начала рассказать и о чем гласит название раздела: песочницы.

image
(скриншот №1)

Фоновая страница расширения, являясь основным контроллером расширения, при наличии должных расширений (tabs, activeTab) может отследить открытие страницы music.yandex.ru используя chrome.tabs.onUpdated и внедрить наш код (контент-скрипт) на витрину через chrome.tabs.executeScript. Однако, внедренный код оказывается в своей песочнице (Б), из которой есть доступ к DOM-документу витрины, но не к js-коду витрины, который выполняется в своей песочнице (А). В теории можно было бы отслеживать изменения DOM-элементов на витрине чтобы транслировать текущее состояние в расширение (например, изменился трек — взять из DOM название трека, исполнителя), но это все работает не так как хотелось бы: с задержками на обновление витрины, не всегда присутствующими целевыми DOM-элементами в текущем отображении витрины и прочими вещами, которые в реальном использовании расширения дают рассинхронизацию данных между витриной и отображением в расширении. Как же нам получить доступ к js-коду витрины (песочнице А)?

image

Для внедрения js-кода в песочницу А из контент-скрипта нам может помочь следующий “хак” (здесь и далее нотация es6):

function injectCode(func, ...args) {
    let script = document.createElement('script');
    script.textContent = 'try {(' + func + ')(' + args + '); } catch(e) {console.error("injected error", e);};';
    (document.head || document.documentElement).appendChild(script);
    script.parentNode.removeChild(script);
}

В результате наш контент-скрипт может внедрить свой код в песочницу js-кода витрины для того, чтобы получить доступ к API витрины и на низком уровне иметь доступ к объектам и событиям плеера, плейлиста и прочих сущностей витрины. Но это еще не всё: доступ к API получен, к примеру, событие начала проигрывания трека наш внедренный код “поймал”, но как же его передать обратно в контент-скрипт, который, в свою очередь, имеет механизмы передачи данных на фоновую страницу расширения?

image

Увы, доступа из песочницы А в песочницу Б нет, во всяком случае, мне он неизвестен. Но это не страшно, потому что мы помним к чему имеет доступ контент-скрипт из своей песочницы Б: к DOM-документу витрины, отсюда выход: внедренный в песочницу А код может создавать произвольные события:

document.dispatchEvent(new CustomEvent(CUSTOM_EVENT_NAME, {detail: payload}));

А контент-скрипт может их ловить:

document.addEventListener(CUSTOM_EVENT_NAME, e => {
    console.log(e.detail); //payload
});

Получается такая схема:

image

После этого, контент-скрипт передает это событие на фоновую страницу одним из механизмов: chrome.runtime.sendMessage или chrome.runtime.connect. Отличие их в том, что первый метод открывает канал, передает данные, закрывает канал, второй же создает постоянный канал, в рамках которого происходит передача данных. При проигрывании треков идет постоянная передача событий progress (текущее время проигрывания), посему для меня стал выбор очевиден: поднимать постоянный канал связи контент-скрипта с фоновой страницей, что дает еще один не очевидный бонус: контроль потери связи с витриной по различным причинам, от исключений в js-коде до закрытия вкладки (хотя именно закрытие вкладки легко отслеживается через chrome.tabs.onRemoved).

С данными песочницами закончили, но это еще не всё: есть еще песочница (В) фоновой страницы и песочница (Г) всплывающего окна. Тут все немного проще, данные песочницы имеют доступ друг к другу через API:


Но тут есть подводный камень: всплывающее окно не может, например, добавить свой слушатель событий на фоновую страницу, но к объекту window друг друга вышеописанные методы доступ дают (т.к. они запущены в одном потоке). Для единообразия интеркоммуникаций и для принудительной изоляции кода фоновой страницы от кода всплывающего окна (иначе popup может модифицировать объекты bg, что нарушит однонаправленный поток данных из контент-скрипта на фоновую страницу для хранения актуального состояния и оттуда во всплывающее окно), я перешел к такому же поднятию канала как и между контент-скриптом и фоновой страницей.

Кода и примеров мало, т.к. всё есть в документации, на которую я максимально ссылаюсь при любых упоминаниях методов и API, в части кода мне добавить тут нечего, т.к. на статус туториала (когда можно скопипастить весь код и что-то заработает) — не претендую. А вот саму концепцию разжевать стоило, т.к. неоднократно коллегам по программистскому цеху объяснял оную. Резюмировать можно раздел следующей иллюстрацией:

image

В следующей части я планирую рассказать об истории перевода расширения “Яндекс.Музыка — управление плеером" на нынче популярный React+Redux, который, как нельзя, кстати, подошел для отображения состояния плеера во всплывающем окне расширения.
Поделиться с друзьями
-->

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


  1. Methos
    11.10.2016 17:51

    Для внедрения js-кода в песочницу А из контент-скрипта нам может помочь следующий “хак” (здесь и далее нотация es6):


    Как же они могли допустить такую дыру? =)


    1. B_bird
      11.10.2016 17:55

      Ну закрыть эту дыру можно же только запретив доступ к DOM, а тогда пропадет весь смысл внедрения контент-скриптов расширениями. Иначе надо какие-то специальные механизмы придумывать, что, видимо, посчитали не оправданным.


      1. Methos
        12.10.2016 12:14

        Я просто не понимаю, зачем тогда запрещать content-скрипту доступ к переменным, если можно вот так просто это обойти?

        update — кажется, понял (вспомнил). Чтобы не было конфликтов имён, а не ради какой-то защиты.


        1. B_bird
          12.10.2016 12:36

          Вы правы, не ради безопасности, а ради устранения конфликтов, но не только лишь имен. Детальнее указано в документации.


  1. lxmarduk
    11.10.2016 18:24

    может отследить открытие страницы music.yandex.ru используя chrome.tabs.onUpdated и внедрить наш код (контент-скрипт) на витрину через chrome.tabs.executeScript


    Можно же через manifest.json это сделать, просто прописать в «content_scripts» необходимые matches и js, а дальше получение доступа к скриптам страницы уже по Вашей схеме. Или это критично для получения доступа к js страницы?

    Для связи компонентов расширения между собой лично я использую chrome.runtime.sendMessage или chrome.runtime.connect, не используя доступ к фоновой странице напрямую. При большом количестве запросов chrome.runtime.connect предпочтительнее, поскольку он меньше грузит процессор (при переходе на порты, нагрузка на процессор уменьшилась з 34% до 5%), но работа с ним в целом больше похожа на веб-сокеты — отсылаеш запрос на получение данных и отдельно слушаеш ответ.


    1. B_bird
      11.10.2016 18:32

      Через манифест, безусловно, можно, но у chrome.tabs.onUpdated есть еще одна важная задача: отследить переход на другой URL без закрытия вкладки, дав понять фоновой странице, что работа с плеером в этой вкладке закончена. Конечно и это можно иначе реализовать, но тут получается все-в-одном и инъекция контент-скрипта, и изменение URL'а.

      Ну использовать ли фоновую страницу зависит, конечно же, от решаемой задачи, в случае с отображением состояния плеера можно двумя способами действовать, либо всплывающее окно при открытии создает соединение с контент-скриптом и получает состояние, либо фоновая страница постоянно получает изменения, а при открытии всплывающего окна сразу получает текущее состояние. Первый вариант даст некоторый лаг при открытии окна, что отразится негативно на юзабилити.


  1. a-motion
    12.10.2016 09:30
    +1

    Сто?ит, наверное, упомянуть про https://github.com/yeoman/generator-chrome-extension


    1. B_bird
      12.10.2016 11:12

      Я данный генератор не использовал, весь мой опыт с ним связанный это предложение одного человека интегрировать мой автодеплой расширений в данный генератор.


      1. a-motion
        12.10.2016 11:38

        Ну он boilerplate неплохой генерирует, когда открываешь редактор с мыслью «а создам-ка я свое первое расширение» — очень ценная помощь.