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

Поговорим о том, как наша команда пришла к такому подходу, какие плюсы принесло это решение и почему, если вы пишете на Angular, вам стоит хотя бы взглянуть на @ngrx/component-store.

Введение

Наш проект — большой монорепозиторий на Angular и NX, состоящий из нескольких независимых приложений и кучи общего кода. Нам приходится иметь дело с большим количеством разных данных: как-то их кэшировать, обрабатывать и передавать между разными слоями приложения. Без хорошего масштабируемого хранилища тут не обойтись.

Мы используем NGRX. Это хороший и довольно гибкий инструмент, о котором слышал каждый или почти каждый. Наши потребности он закрывает почти на 100%. 

Зачем же тогда нужна очередная статья, описывающая NGRX? 

Случился кейс, который попал в тот небольшой процент случаев, когда существующих возможностей NGRX нам не хватило. Задача казалась простой: внедрить в одном месте изолированное хранилище, которое не зависело бы от глобального стора, но могло бы с ним взаимодействовать и соблюдать общий флоу. Решение нашлось довольно быстро: библиотека @ngrx/component-store от создателей NGRX, которая позволяет создавать изолированные хранилища на уровне компонента. О ней я и расскажу.

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

Самый большой плюс глобального стора — это…

Его глобальность.

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

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

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

Казалось бы, все идеально, все проблемы решены. Но у такого подхода есть один большой минус.

Самый большой минус глобального стора — это…

Его глобальность.

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

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

К тому же глобальным стором очень неудобно решать задачу, когда какой-то компонент необходимо разместить на странице несколько раз или добавлять/удалять динамически. Вы можете сказать: «А зачем такую задачу решать с использованием NGRX? Описываете инпуты, аутпуты — и все, а само состояние храните уже в сторе». И будете правы, но лишь отчасти. Представьте, что у вас есть абстрактный компонент: он прост, не перегружен сложной бизнес-логикой, но главное — он может взаимодействовать со своим изолированным API. И мы хотим переиспользовать его в любом месте проекта, не импортируя дополнительно в текущем модуле ничего, кроме него самого. А еще он может повторяться на странице несколько раз, и управлять им вы хотите средствами NGRX. Представили?

Тот самый кейс

С такой ситуацией мне пришлось столкнуться. Стояла задача сделать загрузчик файлов — что может быть проще? Но это только на первый взгляд. Требования у него не совсем обычные:

  • пользователь может выбрать тип загружаемых файлов или сразу несколько типов;

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

  • пользователь должен видеть прогресс загрузки и предпросмотр файлов;

  • API для загрузки всегда один и тот же.

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

И тут мы задаемся вопросом: и как же это реализовать? Где и как в приложении хранить данные о файлах, их типах, состоянии загрузки и так далее? Пока компонент существует в единственном экземпляре, все можно хранить в глобальном сторе и никаких проблем нет: закрыли страницу — очистили стор. Но что делать, если компонентов на странице может быть два, три или десять? В голову приходят несколько вариантов:

  1. использовать конструкции с ключами по типу loader_1/loader_2/loader_3;

  2. использовать в сторе вместо простых объектов массивы объектов;

  3. отказаться от NGRX.

Все варианты из предложенных выше, если честно, так себе. Использовать ключи неудобно, как и массивы. В них легко запутаться: сегодня мы помним, какой индекс с чем соотносится, а завтра, скорее всего, уже нет. К тому же использование динамических объектов, которые невозможно строго типизировать, считается плохим паттерном в мире TypeScript. А от NGRX отказываться — ну тут без комментариев ????

И самый простой ответ — это…

Перейти на сервисы.

Да, сервисы отлично подходят для решения всех этих проблем: они позволяют создать изолированные объекты, которые инжектятся в нужный компонент и обладают ограниченной областью доступности. В RxJS есть Subject’ы, которые позволяют хранить данные и берут на себя всю логику по их асинхронному обмену.

Но есть нюанс.

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

interface Storage {
 user: User | null;
}

@Injectable()
export class StorageService {
 storage$: BehaviorSubject<Storage> = new BehaviorSubject<Storage>({user: null});

 setUser(user: User) {
   this.storage$.next({user});
 }
}

Но вот нам потребовалось расширить хранилище, добавив туда какие-то данные. Как корректно обновить только их часть? В каком-то месте нам важна асинхронность, а где-то мы на 100% знаем, что все хорошо, и на это вроде как можно забить. И легко прийти к ситуации, где каждый разработчик использует свой стиль для работы с таким самописным хранилищем.

@Injectable()
export class StorageService {
 storage$: BehaviorSubject<Storage> = new BehaviorSubject<Storage>({user: null, company: null});

 // корректно ли работать с Subject синхронно? 
 setUser(user: User) {
   const storage = this.storage$.value;
   storage.user = user;
   this.storage$.next(storage);
 }
 
 // почему pipe(first()), а не pipe(take(1))?
 setCompany(company: Company) {
   this.storage$.pipe(first()).subscribe(storage => {
     storage.company = company;
     this.storage$.next(storage);
   });
 }
}

Можно, конечно, заводить свою переменную для каждого объекта. Выглядит вроде бы логично, но если таких переменных окажется штук 10 или 20? А если мы захотим их каким-то образом комбинировать? Copy/paste — наше все, работать будет, но выглядит это как-то не очень, согласитесь.

@Injectable()
export class StorageService {
 user$: BehaviorSubject<User | null> = new BehaviorSubject<User | null>(null);
 company$: BehaviorSubject<Company | null> = new BehaviorSubject<Company | null>(null);
 roles$: BehaviorSubject<Roles | null> = new BehaviorSubject<Roles | null>(null);
 // Здесь еще 20 таких же переменных //
 permissions$: BehaviorSubject<Permissions | null> = new BehaviorSubject<Permissions | null>(null);
 connections$: BehaviorSubject<Connections | null> = new BehaviorSubject<Connections | null>(null);

 getUserHash(): Observable<string> {
   return combineLatest([this.user$, this.company$]).pipe(
     filter(([user, company]) => !!user && !!company),
     map(([user, company]) => {
       return user.id.toString() + company?.id.toString();
     }),
   );
 }

 setUser(user: User) {
   this.user$.next(user);
 }

 setCompany(company: Company) {
   this.company$.next(company);
 }

 setRoles(roles: Roles) {
   this.roles$.next(roles);
 }

 setPermissions(permissions: Permissions) {
   this.permissions$.next(permissions);
 }

 setConnections(connections: Connections) {
   this.connections$.next(connections);
 }
}

А если нам захочется соединить изолированный стор с глобальным? Например, для получения очень важных данных или обмена ими? Конечно, можно что-нибудь придумать. Это нетрудно, но мы потратим время и силы на изобретение и отладку собственного велосипеда, который решит все вышеперечисленные проблемы, — или на проверку того самого решения со Stack Overflow.

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

И это решение — компонентный стор от команды NGRX.

Что такое @ngrx/component-store

Это библиотека, которая соединяет в себе удобство сервисов и большинство функций классического NGRX. Компонентный стор — не замена глобальному: NGRX и @ngrx/component-store дополняют друг друга и могут существовать в приложении независимо.

Компонентный стор реализует лучшие практики классического NGRX: однонаправленные потоки, взаимодействие и получение данных через наблюдаемые объекты (observables), иммутабельный объект состояния, изменяемый простыми функциями. И все это реализуется через классические сервисы Angular.

Однако существует и ряд ограничений в сравнении с классическим NGRX. Во-первых, отсутствуют экшены, поэтому вызвать несколько параллельных эффектов или редюсеров не получится. На их место пришли апдейтеры, которые заменяют редюсеры и вызываются для обновления состояния напрямую. А вот эффекты — параллельные или второстепенные действия, например, для вызова API — никуда не делись. Главное отличие в том, что эффекты, как и апдейтеры, можно вызывать прямо, но об этом чуть позже. Также невозможно воспользоваться инструментами разработчика Redux для отладки состояния и мониторинга его изменений во времени. 

В чем же плюс использования @ngrx/component-store? Во-первых, это лучшая альтернатива сервисам на Subject’ах в качестве хранилища данных: он разрешает все конфликты, описанные выше. Во-вторых, мы можем инжектить его непосредственно в компонент, получая изолированное хранилище, которое при этом будет работать по флоу NGRX. В-третьих, если вы еще не используете классический NGRX и не знаете, с чего начать, компонентный стор позволит понять и изучить его основные паттерны, ведь по факту это NGRX на минималках.

От слов к делу

Для демонстрации основных возможностей @ngrx/component-store давайте реализуем тот самый изолированный загрузчик файлов, который и был ключом к написанию этой статьи. Для демонстрационных целей мы сильно упростим его, плюс NDA никто не отменял ????

Однако основные требования к нему будут похожими:

  • пользователь может загрузить один или несколько файлов;

  • во время загрузки должен отображаться ее индикатор;

  • по ее завершении или в случае ошибки будем показывать пользователю алерт;

  • API будет возвращать только флаг true, если файлы загружены успешно. Иначе — ошибка;

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

Сперва нужно добавить библиотеку в проект. Сделать это очень просто:

ng add @ngrx/component-store@latest

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

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

Приступим. 

В сторе будем хранить только список файлов, а также глобальный флаг isLoading, который определяет общее состояние формы. Опишем объект хранилища, инициализируем сам стор и реализуем селекторы.

import {Injectable} from '@angular/core';
import {ComponentStore} from '@ngrx/component-store';

interface Storage {
 files: File[];
 isLoading: boolean;
}

const initialState: Storage = {
 files: [],
 isLoading: false,
};

@Injectable()
export class StorageService extends ComponentStore<Storage> {
 readonly isLoading$ = this.select(storage => storage.isLoading);
 readonly files$ = this.select(storage => storage.files);

 constructor(private readonly apiService: ApiService) {
   super(initialState);
 }
}

Объявленные селекторы структурно схожи с селекторами в NGRX. Они взаимодействуют с объектом состояния и возвращают необходимое значение в виде Observable, которые эмиттят событие только при изменении состояния. В конструктор мы передаем некий абстрактный ApiService, который реализует в себе логику загрузки файлов на сервер. Начальное состояние стора задано в отдельной переменной и также присваивается в конструкторе. Это помогает избежать ряда проблем с инициализацией стора, проверки на null и undefined и так далее.

Также у сервиса отсутствует параметр providedIn, поскольку он будет инжектиться непосредственно в компонент для получения изолированного хранилища. Однако можно указать @Injectable({providedIn: 'root'}), и тогда получится глобальный компонентный стор. Но в таком случае лучше использовать классический NGRX.

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

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

readonly setIsLoading = this.updater((storage, isLoading: boolean) => ({
 ...storage,
 isLoading: isLoading,
}));

readonly setFiles = this.updater((storage, files: File[]) => ({
 ...storage,
 files: files,
}));

Все дополнительные действия, которые напрямую не влияют на состояние стора, лучше выносить в эффекты. В @ngrx/component-store они логически схожи с эффектами классического NGRX, но в компонентном сторе мы можем вызывать их напрямую. Есть и ограничение: вызвать несколько эффектов параллельно не получится. Для этого необходимо описывать их вызов последовательно, например через оператор tap.

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

readonly uploadFiles = this.effect((files$: Observable<File[]>) =>
 files$.pipe(
  tap(() => this.setIsLoading(true)),
  tap(files => this.setFiles(files)),
  switchMap(files =>
    this.apiService.uploadFiles(files).pipe(
      map(() => {
        this.showAlert('Файлы загружены успешно');
      }),
      catchError(() => {
        return of(this.showAlert('Ошибка загрузки'));
      }),
      finalize(() => this.setIsLoading(false)),
    )
  ),
 ),
);

readonly showAlert = this.effect((message$: Observable<string>) =>
 message$.pipe(
   tap<string>(message => {
     alert(message);
   }),
 ),
);

Не замечаете ничего странного? Кажется, в коде допущена ошибка. Ведь эффект showAlert явно принимает на вход параметр типа Observable, однако в методе uploadFiles эффект вызывается как простая функция с обычной строкой в качестве аргумента.

Но никакой ошибки тут нет, это и есть главное преимущество @ngrx/component-store по сравнению с использованием подхода обычных сервисов на Subject’ах. Компонентный стор предоставляет удобный интерфейс, реализуя вызов функций синхронно с простыми параметрами, как в обычных сервисах. При этом внутри он преобразует все данные в Observable, позволяя использовать всю мощь огромного числа функций для работы с потоками RxJS и забирая на себя всю логику по мониторингу подписок, их преобразованию, уничтожению и так далее.

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

@Component({
 selector: 'file-uploader',
 templateUrl: './file-uploader.component.html',
})
export class FileUploaderComponent {
 constructor(private readonly storage$: StorageService) {}

 readonly isLoading$: Observable<boolean> = this.storage$.isLoading$;
 readonly files$: Observable<File[]> = this.storage$.files$;

 uploadFiles(files: File[]): void {
   this.storage$.uploadFiles(files);
 }
}

Сам сервис инициализируется в конструкторе. Для получения данных из селекторов объявим две Observable-переменные isLoading$ и files$, а также простую uploadFiles, которая будет вызывать соответствующий экшен.

Код разметки компонента тоже довольно простой. Но вместо нативных элементов давайте возьмем красивый и удобный UI-кит, в котором есть все необходимые компоненты для работы с файлами, поддержка drag-and-drop и многое другое. Здесь идеально впишется Taiga UI: большое число различных полезных утилит и хорошая документация очень выручают при разработке.

Для получения данных из селекторов в шаблоне воспользуемся async-пайпом, который всегда возвращает актуальное значение и отпишется от всех активных Observable при уничтожении компонента. Чтобы не плодить активные подписки, лучше воспользоваться директивой *tuiLet, которая позволяет использовать текущее значение Observable и внутри заданного блока обращаться к нему как к обычной переменной.

<ng-container *tuiLet="isLoading$ | async as isLoading">
 <tui-input-files
   accept="image/*"
   [disabled]="isLoading"
   [ngModel]="[]"
   (ngModelChange)="uploadFiles($event)"
 ></tui-input-files>

 <tui-loader [showLoader]="isLoading">
   <tui-files>
     <tui-file *ngFor="let file of files$ | async" [file]="file"></tui-file>
   </tui-files>
 </tui-loader>
</ng-container>

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

@Output() onUpload = new EventEmitter<File[]>();

ngOnInit() {
 this.files$.pipe(takeUntil(this.destroy$)).subscribe(files => {
   this.onUpload.emit(files);
 });
}

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

<file-uploader (onUpload)="yourHandler($event)"></file-uploader>

Заключение

Как видите, компонентный стор — не замена глобальному, а дополнение к нему, которое дает нам удобный инструмент для создания изолированных хранилищ. Этот подход — достойная альтернатива классическим сервисам на Subject’ах. Он помогает стандартизировать процессы и снимает с нас ответственность по инициализации и мониторингу потоков, отслеживанию изменений и так далее.

Получившийся компонент можно классифицировать как smart-ui: он полностью независим, работает со своим UI, может взаимодействовать с API и имеет собственное хранилище, которым сам же и управляет. Можно ничего не знать о его реализации, однако он предоставляет публичные методы для получения уже обработанных данных. Этот компонент можно вынести в отдельную библиотеку и шарить между всеми проектами.

Конечно, некоторые моменты в статье мы сильно упростили, но этот пример структурно показывает, для каких целей можно применять изолированные хранилища. Надеюсь, подход найдет место и в ваших проектах. Ознакомиться с рабочим демо вы можете в Stackblitz, а код проекта доступен в репозитории на Github.

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


  1. yuriy-bezrukov
    16.05.2023 17:11

    По чему не elf, а ngrx? (использовал и то и другое для компонент-стор а, elf больше лежит к сердцу. Но почему у Вас именно такой выбор? Из-за существующего глобального стора на ngrx не хотелось компонентный стор менять?)


    1. xztv Автор
      16.05.2023 17:11

      До этого NGRX закрывал потребности практически на 100%, а в данном случае потребовалось более гибкое решение. NGRX не идеален, но выпиливать его из всего проекта и заменять на что-то другое ради этого кейса - кажется перебором. Это решение показалось наиболее оптимальным в нашем случае.


  1. Fidget
    16.05.2023 17:11

    Расскажите лучше как боретесь с кучей boilerplate при использовании NGRX?