С ростом популярности использования RxJS в службах для доставки данных компонентам пользовательского интерфейса все больше и больше используется RxJS только вместо более специализированных пакетов управления состоянием приложений, таких как NgRx, Redux, NGXS и т. Д. особенно с Angular.

Обычно используется шаблон BehaviorSubject, который содержит данные, и вставку данных с использованием .next() как части процедуры обработки ответа Http. Давайте использовать это как отправную точку и постепенно улучшать, обсуждая проблемы и решения на каждом этапе.

ВАЖНЫЙ! Перед тем как начать, прочтите следующее!

Обратите внимание, что многие методы, описанные ниже, такие как глубокое замораживание и сравнение простых объектов, не будут работать правильно для определенных типов данных, например не только Map, Set, WeakMap и WeakSet, но также function . Надеюсь, вы не храните ни один из этих типов в своей базе BehaviorSubject в службе, но если да, имейте в виду, что вам придется вручную обходить любые проблемы, связанные с ними.

Шаг 1. Магазин открыт для изменений

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

Здесь переменная USER_DATA_INIT - это объект инициации, соответствующий типу UserData. В остальном это довольно просто. В качестве первого шага давайте обсудим и исправим две очевидные проблемы с этой реализацией.

public userData $

Первой большой проблемой является тот факт, что userData$ является public и, следовательно, доступен вне службы. Это означает, что любой компонент или другая служба, потребляющая UserService, сможет вызвать .next() метод для перезаписи любого содержимого внутри сохраненной службы. Другими словами, определение того, какие данные могут храниться в userData$, не ограничено этим классом и может быть произвольно изменено из любого места в кодовой базе.

Самый простой способ исправить это - объявить userData$ как частный и предоставить доступ к общедоступным функциям селектора, которые потребители могут использовать. Эти селекторные функции являются Observables, которые либо возвращают весь объект, либо только определенные поля. Примером может быть public userName$, который показывает только свойство userName из userData$.

(Если вы хотите раскрыть весь BehaviorSubject, вы также можете предоставить userData$.asObservable() как общедоступную переменную.)

Изменяемые данные пользователя

Хотя это зависит от структуры типа UserData, объект, хранящийся в dataStore$, на данный момент полностью изменяемый. В то время как селекторы будут следить за тем, чтобы первый уровень свойств больше не изменялся, вложенные объекты все еще могут быть изменены любым потребителем.

Например, предположим, что у нас есть свойство с именем messages, которое содержит массив типа Message. Если какой-либо потребитель подписывается на эту службу и обращается к этой опоре, этот потребитель может использовать push или pop или любую другую функцию Array, которая изменяет массив, и данные, хранящиеся в UserDataService, конечно, изменятся, что означает, что все другие потребители услуги будут использовать мутированный массив в соответствующих процедурах.

Эта проблема должна быть слишком знакома большинству разработчиков Javascript. Хотя существует бесчисленное количество статей о том, как бороться с изменчивостью и зависанием, я обычно использую рекурсивную deepFreeze функцию, которая занимается этой проблемой. Реализация, которую я использую, выглядит так:

Шаг 2. Шумный магазин

Внедряя описанные выше изменения, мы получаем следующее:

Выглядит намного лучше! И для большинства приложений этого может быть достаточно. Но на этом этапе давайте посмотрим, как сократить ненужные выбросы из-за неадекватной обработки дублированных вводимых данных. У нас есть две конкретные проблемы, которые мы можем исправить:

Повторяющиеся выбросы магазина

Предположим, пользователь просматривает ваш сайт и посещает две отдельные страницы, каждая из которых запускает получение userData. В этом есть смысл: в конце концов, данные, хранящиеся в API, вполне могли измениться во время посещения пользователем. Однако предположим, что данные, которые получает пользователь, точно такие же, как и при предыдущей выборке. Что тогда происходит?

Данные извлекаются из API, и функция подписки вызывает userData$.next() с повторяющимися данными. userData$ затем передает эти данные, и все подписки и селекторы запускаются снова.

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

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

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

В зависимости от того, насколько прост или сложен ваш объект, было бы разумно написать собственный валидатор. Мне нравится использовать общий метод, который я называю naiveObjectComparison, но, конечно, это не то, что я придумал сам, поскольку он должен быть знаком большинству разработчиков Javascript. Это выглядит так:

Обратите внимание, что этот метод не будет работать правильно для определенных типов данных, таких как Set и Map.

Давай приступим к работе. Мы собираемся создать новый тип данных под названием Store, который расширяет BehaviorSubject. По большей части это будет работать как BehaviorSubject, но мы собираемся перезаписать следующую функцию, чтобы не пересылать дубликаты. Мы также собираемся включить deepFreeze входящих данных прямо в тип. В итоге мы получаем следующее определение:

Затем мы заменяем BehaviorSubject на Store в userDataService и удаляем вызов deepFreeze в подписке Http. Итак, в качестве нашего первого шага мы исправили дублирующиеся выбросы магазина.

Повторяющиеся выбросы селектора

Несмотря на то, что наш магазин больше не отправляет повторяющиеся данные, наши селекторы все равно могут это делать. Вы можете подумать: Эй, Ларс, мы только что исправили это на базовом уровне! Это больше не проблема, верно? Неправильно.

Если Http-выборка запускается снова и, скажем, возраст пользователя изменился (просматривается около полуночи незадолго до своего дня рождения!), То, очевидно, наша функция Store.next() будет рассматривать данные как измененные и вставлять новые данные. Это, в свою очередь, отправит значения ВСЕМ подписчикам, то есть ВСЕ наши селекторы теперь будут срабатывать, даже если было обновлено только одно поле.

На этот раз мы воспользуемся ранее упомянутым оператором distinctUntilChanged. distinctUntilChanged работает, сравнивая предыдущий выброс с текущим выбросом, отфильтровывая выбросы, которые идентичны последнему.

Для userName$ и age$ мы просто добавляем distinctUntilChanged() после оператора map.

Обратите внимание, что этот порядок очень важен; если вы поставите distinctUntilChanged() перед map, будет сравниваться ввод, а не вывод. Мы хотим остановить идентичный вывод, то есть name или age, а не идентичный ввод, то есть сам объект userData. Фильтрация повторяющихся userData входных данных уже решена на предыдущем шаге.

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

Для messages$ это не так просто. Обратите внимание, что messages$ не примитив, а скорее массив и, следовательно, объект. distinctUntilChanged() сравнивает с использованием === по умолчанию, что означает, что сравниваются только ссылки, но не фактические значения каких-либо свойств.

Однако distinctUntilChanged принимает функцию сравнения в качестве параметра, который позволяет пользователям вручную сравнивать предыдущие и текущие выбросы. В этом случае часто бывает целесообразно написать собственную функцию для свойств, структура которых известна, но также можно использовать naiveObjectComparison и в этом случае.

Шаг 3. Хранилище с утечкой памяти и занятыми селекторами

Внедряя вышеописанные изменения, наш сервис теперь выглядит так:

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

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

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

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

Кроме того, возникает проблема утечек памяти при увеличении количества подписок. Вы можете подумать, что это не проблема, поскольку объем памяти одного Observable довольно мал.

Однако по мере того, как приложение становится все более сложным, мы часто создаем селекторы, которые используют селекторы, и селекторы, которые используют эти селекторы, и так далее. По крайней мере, в стеках, над которыми я работал, были службы, которые объединяли до 20 (!) селекторов из различных частей приложения для создания нового селектора, и этот селектор затем подписывается еще один уровень до того, как он станет доступным для пользовательского интерфейса, а в компонентах пользовательского интерфейса может быть четыре или пять подписок на один и тот же селектор, которые делают разные вещи.

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

К счастью, RxJs предлагает пару операторов, которые помогают решить эти проблемы. Первый называется share, и он работает, проверяя, существует ли когда-либо только один экземпляр канала и используется всеми подписчиками. Другими словами, это невероятно мощный оператор.

Хотя это само по себе хорошо, при подключении anyBehaviorSubject есть одно предостережение. По умолчанию при создании подписки на BehaviorSubject подписка сначала выполняется синхронно, немедленно отправляя текущее значение, а затем асинхронно при отправке любых последующих выбросов.

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

К счастью, у RxJs есть еще один аналогичный оператор для этой точной задачи. Оператор shareReplay в основном действует как share, но с дополнительным поведением синхронного возврата последнего значения всем новым подписчикам. shareReplay принимает параметр, указывающий, сколько выбросов нужно сохранить и пересылать, но в нашем случае мы хотим имитировать поведение по умолчанию BehaviorSubject, поэтому мы используем 1.

Шаг 4. Подробный магазин

Наша текущая реализация теперь выглядит так:

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

  • Потребители перезаписывают сохраненные данные
  • Потребители изменяют сохраненные данные
  • Повторяющиеся HTTP-ответы, вызывающие реакцию
  • Повторяющиеся значения данных, вызывающие реакцию
  • Утечки памяти и избыточная обработка

Однако наше решение становится немного многословным. Каждому селектору нужен как минимум оператор map, distinctUntilChanged и shareReplay в его конвейере. Поскольку мы хотим, чтобы все селекторы имели хотя бы эти три оператора, мы можем добиться большего успеха, чем определять их снова и снова.

Также существует проблема, что distinctUntilChanged должен принимать naiveObjectComparison в качестве аргумента, если тип является объектом или массивом, и без аргумента, если это примитив, и это различие должно быть абстрагировано от разработчика. В дополнение к этому, пользовательские функции сравнения также должны быть разрешены по усмотрению разработчика, когда это необходимо.

Другими словами, хотя текущая реализация является функционально правильной и значительно повысила удобство работы пользователей, опыт разработчиков пострадал. Давай исправим это.

выберите $

Мы представляем новую функцию под названием select$. Эта функция вернет Observable, который включает в себя все операторы, которые мы в настоящее время используем в наших каналах выбора. Он принимает исходный поток и функцию сопоставления как обязательные параметры и принимает memoizationFunction как необязательный параметр.

source$ - это исходный поток, обычно это наш объект Store; в данном конкретном случае userData$.

Функция mapping будет параметром оператора map. Другими словами, это функция, которая описывает, как мы извлекаем опору, которую хотим испустить, из исходной эмиссии. Пример для age$ будет userData => userData.age.

Наконец, memoizationFunction - это функция для определения того, равны ли наши предыдущие и текущие выбросы. Это необязательно, и если не указано, мы используем функцию мемоизации по умолчанию, которая запускает naiveObjectComparison для объектов и === сравнение в противном случае.

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

В этом случае мы решаем эту проблему, проверяя типы предыдущих и текущих значений, и если они являются объектами, мы используем naiveObjectComparison, в противном случае мы используем сравнение ===.

Возможно, не слишком эффективно проверять тип для каждого выброса, но это предпочтительнее альтернативных решений:

  • Решение этой проблемы с использованием Typescript и ограничений на R. Это означало бы создание двух функций (selectPrimitive$ и selectObject$), поскольку у нас нет возможности использовать среду выполнения с ограничением типов, и реализация будет отличаться. Это как усложнит разработчика, так и приведет к ошибкам времени выполнения, если предупреждения типа игнорируются.
  • Решаем это, проверяя тип только один раз. Мы разрешаем картам возвращать null или undefined, если, скажем, свойство объекта данных не установлено, и только по этому факту мы не можем определить, возвращает ли функция отображения обычно объект или примитив во время выполнения. Другими словами, нам приходилось каждый раз проверять тип.

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

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

Окончательное решение: идеальный магазин

Когда функция select$ завершена, мы меняем нашу реализацию dataUserService на следующее:

Конечно, select$ также позволяет без проблем выполнять вложение. Просто убедитесь, что при использовании вложенного select$ порядок объявления соответствует глубине дерева. Другими словами, если вы используете select$ на this.messages$, чтобы получить опору от dataUser.messages, убедитесь, что this.messages$ объявлен первым.

Помимо этого, мы закончили с нашим прекрасным базовым сервисом магазина. Теперь вы готовы хранить данные в своих сервисах и распределять их по компонентам и другим сервисам! Отлично!

Уф! Мастерство RxJ требует не только понимания нескольких операторов, но иногда также понимания того, как RxJ ведет себя на базовом уровне. Когда я впервые попробовал использовать share() на BehaviorSubjects, я довольно долго пытался понять, почему это работает неправильно, поскольку мне не удалось понять несколько фундаментальных концепций того, как работают BehaviorSubject и трубопроводы.

Держитесь, и в конце концов это произойдет, и, как всегда ... продолжайте идти к успеху!