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

Особенности расширений, управляемых событиями

Модель расширения на основе событий была впервые представлена ​​в Chrome 22, выпущенном в 2012 году. Она предполагает, что фоновый скрипт (если есть) загружается / запускается только тогда, когда это необходимо (в основном в ответ на события) и выгружается, когда он простаивает.

Документы разработчика Chrome настоятельно рекомендуют перенести существующие постоянные расширения на модель, основанную на событиях, и использовать ее для новых расширений всегда с единственным исключением. Но кажется, что многие расширения в настоящее время остаются постоянными, даже если они могут быть управляемыми событиями. Конечно, многие из них были впервые выпущены до того, как модель, основанная на событиях, стала известна разработчикам. И теперь у их авторов просто нет стимула переходить на новую модель. Это еще хуже, поскольку это означает, что нужно вносить множество изменений практически везде, а не только в фоновый скрипт.

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

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

Проблема с Redux

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

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

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

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

Решение

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

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

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

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

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

Примечание: существует также Store.replaceReducer метод, который в нашем случае не нужен из-за встроенной функции сохранения состояния. Дополнительные функции, такие как combineReducers или applyMiddleware, также не нужны, поскольку они не сразу работают с хранилищем Redux.

Реализация

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

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

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

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

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

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

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

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

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

Как часто бывает, устранение одной проблемы может вызвать другую. В нашем случае использование буферизованного многоразового хранилища вместо локального одноразового может нарушить асинхронную логику в Redux. Асинхронная логика, которая не поддерживается в Redux по умолчанию, может быть введена с использованием промежуточного программного обеспечения, такого как 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` отлично работает с такими создателями действий async (а также с создателями одиночной синхронизации), потому что она использует локальное хранилище, которое всегда доступно для соответствующего обратного вызова subscribe.

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

Кроме того, было бы неплохо внести некоторые улучшения / рефакторинг в общий код, например:

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

Итак, ниже представлена ​​реализация результата:

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

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

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

Вот исходный код только что созданной библиотеки. Вы можете использовать его для своих нужд.

Он также доступен как пакет NPM:

npm install reduxed-chrome-storage