Примечание к переводу: Оригинальная статья была написана до того как стало известно о MV3. Тем не менее она полностью актуальна и для MV3 расширений (по крайней мере на данный момент). Поэтому я решил немного изменить ее название, добавив упоминание "MV3", что нисколько не противоречит содержанию. Если кто не в курсе: MV3 — новый формат/стандарт расширений Chrome, должен быть введен в январе 2021 года.


В этой статье, предназначенной для опытных веб-разработчиков, рассматривается (и решается) проблема использования Redux в т.н. событийно-ориентированных (event-driven) расширениях Chrome.


Специфика событийно-ориентированных расширений


Событийно-ориентированная модель расширения впервые появилась в Chrome 22 в 2012 г. В этой модели фоновый скрипт расширения (если есть) загружается/выполняется только когда это нужно (в основном в ответ на события) и выгружается из памяти когда он ничего не делает.


Документация Chrome разработчика настоятельно советует использовать событийно-ориентированную модель для всех новых расширений, а для уже существующих расширений, использующих постоянную (persistent) модель — делать миграцию. Есть правда одно исключение (в MV3 уже не актуально, в т.ч. поэтому переход на новую модель в MV3 обязателен). Но похоже что многие расширения до сих пор используют постоянную модель, даже если могут быть событийно-ориентированными. Конечно, многие из них были впервые выпущены до того, как стало известно о событийно-ориентированной модели. И теперь у их авторов просто нет стимула переходить на новую модель. С одной стороны это (пока) необязательно, а с другой — означает необходимость множества изменений, не только в фоновом скрипте, но и в других компонентах расширения. К тому же многие при разработке используют кроссбраузерный подход, собирая готовые расширения для разных браузеров из одного и того же исходного кода. Событийно-ориентированная модель на данный момент поддерживается только в Chrome и она существенно отличается от постоянной модели, поддерживаемой остальными браузерами. Естественно, это усложняет кроссбраузерную разработку.


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


Проблема с Redux


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


Redux — популярная библиотека, помогающая управлять состоянием приложения единым, последовательным и достаточно простым способом. Но беда в том что Redux не предназначен для использования в расширениях браузера.


Прежде всего, в любом браузерном расширении может всплыть проблема с правилом "единого источника" — это один из фундаментальных принципов Redux, по которому все состояние приложения должно храниться в одном месте. Расширения браузера часто состоят из нескольких отдельных компонентов (таких как фоновые и внедряемые скрипты), что затрудняет соблюдение данного принципа. К счастью эта проблема решаема и есть библиотеки, такие как Webext Redux, которые решают ее с помощью передачи сообщений. К несчастью такое решение неприменимо к событийно-ориентированным расширениям из-за уже упоминавшихся различий между событийно-ориентированной и постоянной моделями. Теперь самое время рассмотреть подробно эти различия.


В постоянной модели расширения его состояние обычно хранится в локальной переменной внутри постоянного (persistent) фонового скрипта, живущего в течение всей сессии браузера, до самого его закрытия. Так что такое состояние всегда доступно остальным компонентам расширения (например, внедряемым скриптам) через фоновый скрипт, который таким образом выполняет роль сервера. Это стандартный постоянный подход к управлению состоянием, который используется в библиотеках, таких как Webext Redux.



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


Решение


Решение заключается в том, чтобы использовать chrome.storage как непосредственное место/способ хранения/изменения состояния. Этот подход (между прочим он явно предлагается в руководстве по миграции) предполагает, что состояние хранится непосредственно в chrome.storage, чье API вызывается всякий раз когда нужно изменить состояние, либо отследить такие изменения.



Как часть стандартного API расширений chrome.storage обладает рядом преимуществ, самое важное из которых — то что любой компонент расширения может иметь прямой доступ к нему. Еще одно преимущество — автоматическое сохранение состояния между сессиями браузера, что является встроенной фичей, работающей "из коробки".


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


Остается только одна проблема — API chrome.storage отличается от Redux, что делает невозможным его использование в качестве замены Redux. Конечно, можно использовать chrome.storage как есть, либо написать к нему кастомную обертку. Однако, Redux уже успел стать чем-то вроде стандарта в управлении состоянием. Так что было бы неплохо как-нибудь адаптировать chrome.storage к принципам Redux, или другими словами, сделать Redux из chrome.storage).


Наша цель в этой статье — получить Redux-совместимый интерфейс к chrome.storage, который будет переводить функционал chrome.storage в термины Redux. В терминах API нам нужно реализовать в рамках chrome.storage интерфейс функционала Redux, имеющего непосредственное отношение к хранилищу (store) Redux. Он включает в себя функцию createStore и возвращаемый ею объект Store (хранилище Redux). Ниже их интерфейсы:


Спецификация интерфейса

Реализация


Итак, прежде всего нам нужно написать класс, реализующий интерфейс Store. Назовем его ReduxedStorage.


Реализовать методы getState и subscribe достаточно просто, т.к. у них есть близкие аналоги в chrome.storage: метод get и событие onChanged. Конечно, они не могут напрямую заменить указанные методы Store, но могут помочь в организации хранения локальной копии состояния в нашем классе. Мы можем инициализировать локальное состояние в нашем классе, вызвав метод get из chrome.storage во время создания экземпляра ReduxedStorage и затем, всякий раз когда появляется событие onChanged, изменять соответственно локальное состояние. Таким образом гарантируется актуальность локального состояния. Тогда getState в рамках нашего класса будет тривиальным геттером. Реализация метода subscribe немного сложнее: он должен добавлять аргумент-функцию к некоторому массиву слушателей, которые будут вызываться всякий раз когда появляется событие onChanged.


В отличие от getState и subscribe, в chrome.storage нет ничего похожего на метод Store.dispatch. Там есть метод set, но его прямое использование противоречит еще одному фундаментальному принципу Redux, по которому состояние Redux присваивается только один раз, во время создания хранилища, после чего оно может быть изменено только через вызов метода dispatch. Так что нам нужно как-то воспроизвести функционал dispatch в нашем классе ReduxedStorage. Есть два способа сделать это. Радикальный предполагает полное воспроизведение соответствующего функционала Redux в рамках нашего класса, короче говоря, тупо скопировать код Redux. Но есть также и компромисный вариант, который и будет рассмотрен ниже.


Идея состоит в том, чтобы создавать новый экземпяр хранилища всякий раз, когда отправляется какое-то действие. Да, это звучит немного странно, но это единственная альтернатива полному копированию выше. Говоря более конкретно, всякий раз когда в нашем классе вызывается метод dispatch, нам нужно создать новый экземпяр хранилища, вызвав "оригинальную" функцию createStore, инициализовать его состояние локальным состоянием из нашего класса и наконец вызвать "оригинальный" метод Store.dispatch, передав ему аргументы из нашего dispatch. Помимо этого, к созданному хранилищу нужно добавить одноразовый слушатель изменения состояния, чтобы когда данное действие дойдет до хранилища, обновить chrome.storage новым состоянием, получающимся в результате данного действия. Далее это обновление должно быть отслежено и обработано слушателем события chrome.storage.onChanged, описанным выше.


Несколько замечаний насчет инициализации состояния: Поскольку метод chrome.storage:get выполняется асинхронно, мы не можем вызывать его из конструктора нашего класса. Поэтому нам придется перенести код вызова chrome.storage:get в отдельный метод, который должен вызываться сразу после конструктора (создания экземпляра класса). Этот метод, назовем его init, будет возвращать промис, который должен быть разрешен, когда метод chrome.storage:get завершит выполнение. В методе init нам также нужно создать еще одно локальное хранилище Redux, чтобы получить дефолтное состояние, которое будет использоваться, если состояние в chrome.storage в данный момент пусто.


Ниже пример как может выглядеть наш класс ReduxedStorage в первом приближении:


Реализация в первом приближении

Замечание: Мы обращаемся к части данных в chrome.storage под определенным ключом (this.key), чтобы иметь возможность сразу получить новое (измененное) состояние в слушателе chrome.storage.onChanged, не вызывая дополнительно метод chrome.storage:get. Кроме того, это может быть полезно при хранении в состоянии непосредственно массивов, т.к. chrome.storage позволяет хранить на корневом уровне только объект.


К сожалению, в реализации выше есть скрытый недостаток, который возникает из-за того, что мы обновляем свойство this.state не напрямую, а через метод chrome.storage:set, выполняющийся асинхронно. Само по себе это не проблема. Но при создании локального хранилища Redux внутри метода dispatch используется значение свойства this.state, что может представлять проблему, т.к. this.state не всегда может содержать актуальное состояние. Так может быть, если несколько действий отправляются синхронно сразу друг за другом. В этом случае 2-й и все последующие вызовы dispatch имеют дело с устаревшими данными в свойстве this.state, которое еще не успевает обновиться из-за асинхронного выполнения метода chrome.storage:set. Таким образом, синхронное отправление нескольких действий друг за другом может приводить к нежелательным результатам.


Чтобы решить указанную проблему, можно изменить код dispatch так, чтобы использовать для таких синхронных действий одно и то же хранилище Redux. Такое буферизированное хранилище должно быть сброшено по истечении небольшого периода времени (допустим 100 мсек), чтобы для следующих действий использовалось уже новое хранилище. Для этого решения нам потребуется добавить в наш класс дополнительные свойства — для буферизированного хранилища и соответствующего состояния. Ниже пример как может выглядеть такая буферизированная версия метода dispatch:


Буферизированная версия dispatch

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


Пример отложенного создателя действия

delayAddTodo откладывает отправку действия 'ADD_TODO' на 1 сек.


Если мы попытаемся использовать такой создатель действия с буферизированным вариантом dispatch выше, мы получим ошибку во время вызова this.buffStore.getState внутри колбека this.buffStore.subscribe. Причина в том что колбек this.buffStore.subscribe вызывается как минимум через 1 сек после вызова нашего метода dispatch, когда this.buffStore уже сброшен в null (через 100 мсек после вызова dispatch). При этом предыдущий вариант dispatch без проблем работает с такими асинхронными создателями действий, т.к. использует локальное хранилище, которое всегда доступно соответствующему колбеку subscribe.


Таким образом, нам нужно совместить оба варианта, т.е. использовать, как буферизированный, так и локальный вариант хранилища Redux. Первый будет использоваться для синхронных действий, а последний — для асинхронных, занимающих какое-то время, таких как delayAddTodo. Однако, это не значит, что нам нужны два отдельных экземпляра хранилища Redux в одном вызове dispatch. Можно создать экземпляр хранилища один раз, сначала сохранив его в свойстве this.buffStore, а затем скопировать ссылку на него в локальной переменной, назовем ее lastStore. Тогда, когда свойство this.buffStore будет сброшено, lastStore все еще будет указывать на тот же самый экземпляр хранилища и будет доступен соответствующему колбеку subscribe. Следовательно, внутри колбека subscribe можно использовать переменную lastStore как запасную ссылку на хранилище на тот случай, если свойство this.buffStore недоступно, что означает асинхронное действие "в действии"). Когда изменение состояния будет обработано внутренним колбеком subscribe, было бы полезно отписать данный колбек/слушатель от хранилища и сбросить переменную lastStore, чтобы высвободить соответствующие ресурсы.


Кроме того, было бы неплохо провести рефакторинг в коде класса, в т.ч.:


  • сделать свойства this.areaName, this.key изменяемыми/настраиваемыми через параметры конструктора.
  • переместить код, непосредственно вызывающий API chrome.storage, в отдельный класс, назовем его WrappedStorage.

Итак, ниже окончательная реализация нашего интерфейса:


Окончательная реализация

Его использование аналогично использованию оригинального Redux, за исключением того, что в нашем интерфейсе создатель хранилища (store creator) обернут в функцию и выполняется асинхронно, возвращая промис вместо созданного хранилища.


Стандартное использование интерфейса выглядит так:


Стандартное использование

Кроме того, с синтаксисом async/await, доступным начиная ES 2017, этот интерфейс может использоваться так:


Продвинутое использование

Исходный код доступен на Github.


Также этот интерфейс доступен как пакет в NPM:


npm install reduxed-chrome-storage