Привет, я Григорий Зароченцев, ведущий фронтенд-разработчик Тинькофф в команде интернет-эквайринга. Сегодня хочу рассказать, что такое компонентный стор, как изолированные хранилища помогают сэкономить кучу кода при разработке и почему глобальный стор — это одновременно и хорошо и плохо.
Поговорим о том, как наша команда пришла к такому подходу, какие плюсы принесло это решение и почему, если вы пишете на 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 поможет соблюсти единство общей архитектуры.
И тут мы задаемся вопросом: и как же это реализовать? Где и как в приложении хранить данные о файлах, их типах, состоянии загрузки и так далее? Пока компонент существует в единственном экземпляре, все можно хранить в глобальном сторе и никаких проблем нет: закрыли страницу — очистили стор. Но что делать, если компонентов на странице может быть два, три или десять? В голову приходят несколько вариантов:
использовать конструкции с ключами по типу loader_1/loader_2/loader_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.
yuriy-bezrukov
По чему не elf, а ngrx? (использовал и то и другое для компонент-стор а, elf больше лежит к сердцу. Но почему у Вас именно такой выбор? Из-за существующего глобального стора на ngrx не хотелось компонентный стор менять?)
xztv Автор
До этого NGRX закрывал потребности практически на 100%, а в данном случае потребовалось более гибкое решение. NGRX не идеален, но выпиливать его из всего проекта и заменять на что-то другое ради этого кейса - кажется перебором. Это решение показалось наиболее оптимальным в нашем случае.