В крупных проектах на Angular часто можно встречать повторяющееся поведение в компонентах. Такое поведение желательно выносить из компонента в отдельные классы, которые можно переиспользовать. Рассмотрю два достаточно популярных кейса: переключатель и множественный выбор сущностей.
Кейс 1: Переключалка (Toggle)
Часто в исходниках приходится видеть примерно такой код:
export class SampleComponent {
@Output somethingSelected = new EventEmitter<boolean>()
...
private _selected = false;
toggleSelected() {
this._selected = !this._selected;
this.somethingSelected.emit(this._selected);
}
}
либо такой:
export class SampleComponent {
@Output somethingSelected = new EventEmitter<boolean>()
...
private _selected$ = new BehaviorSubject<boolean>(false);
toggleSelected() {
this._selected$.next(!this._selected$.value);
this.somethingSelected.emit(this._selected$.value);
}
}
Вроде бы ничего страшного, если проект небольшой, компоненты тоже. Но если таких переключалок добрый десяток, а то и добрая сотня, начинаешь вспоминать принцип DRY. Нужно какое то решение для уменьшения количества бойлерплейта в коде.
Попробуем унаследоваться от BehavoirSubject и добавить туда метод toggle()
export class ToggleSubject extends BehaviorSubject<boolean> {
toogle() {
this.next(!this.value);
}
}
Таким образом код компонента у нас приобретает вид:
export class SampleComponent {
@Output somethingSelected = new EventEmitter<boolean>()
...
private _selected$ = new ToggleSubject(false);
toggleSelected() {
this._selected$.toggle();
this.somethingSelected.emit(this._selected$.value);
}
}
уже получше, но кода стало меньше не намного. Попробуем вовсе избавиться от метода toggleSelected и приватного свойства _selected. Можно создать класс ToggleSwitcher и унаследовать его от EventEmitter
export class ToggleSwitcher extends EventEmitter<boolean> {
get value(): boolean {
return this._value
}
constructor(private _value = false) {
super();
}
toggle() {
this.emit(!this.value);
}
emit(v: boolean) {
this._value = v;
super.emit(v);
}
}
теперь наш компонент приобретает такой вид:
export class SampleComponent {
@Output somethingSelected = new ToggleSwitcher()
...
}
в шаблоне для переключения можем использовать somethingSelected.toggle() для получения текущего значения somethingSelected.value для задания значения somethingSelected.emit(true / false). Если нужно значение по умолчанию true, можем его передать в конструктор ToggleSwitcher. Поскольку мы унаследовались от EventEmitter, проблем с эмитом событий также не будет.
@Output somethingSelected = new ToggleSwitcher(true)
Плюс такого решения очевиден: минимум бойлерплейта, все просто и лаконично. Однако перфекционист может сказать, что тут нарушается SRP. Ведь EventEmitter у нас служит для эмита событий, а мы через наследование вешаем на него еще дополнительную логику по переключению. Что ж, есть еще один вариант. Можем не наследоваться от EventEmitter, а получать его из свитчера.
export class ToggleSwitcher extends BehaviorSubject<boolean> {
eventEmitter = new EventEmitter<boolean>;
next(v: boolean) {
this.eventEmitter.emit(v);
super.next(v);
}
toggle() {
this.next(!this.value)
}
}
Но тогда в компоненте будет на одну строчку больше кода, чем в предыдущем варианте
export class SampleComponent {
somethingSwitcher = new ToggleSwitcher(false);
@Output somethingSelected = this.somethingSwitcher.eventEmitter;
}
Кейс 2: множественный выбор
Также наиболее часто встречающийся кейс: на странице отображается список сущностей, должна быть возможность выбирать из списка нужные сущности, нужно показывать общее количество сущностей, количество выбранных сущностей, должна быть кнопка выбрать все и очистить выбор. В Output() нужно эмиттить массив выбранных сущностей.
Также должна быть возможность показывать в шаблоне через ngFor выбрана ли сущность или нет. Поэтому в *ngFor будем ложить не массив сущностей, а массив стейтов, содержащих сущность и состояние: выбран / не выбран
export class EntityCheckedState<T> {
entity: T;
checked: boolean
}
export class EntityMultiSelector<T> extends BehaviorSubject<T[]> {
private _list: EntityCheckedState<T>[];
eventEmitter = new EventEmitter<T[]>();
get list(): EntityCheckedState<T>[] {
return this._list;
}
set list(v: EntityCheckedState<T>[]) {
this._list = v;
this.next(this.list.filter(v => v.entity === entity);
}
constructor(v: T[]; defaultChecked = false) {
super(defaultChecked? v || []);
this.eventEmitter.emit(defaultChecked? v || []);
this._list = v.map(entity => ({entity, checked: defaultChecked})
}
setCheckedForEntity(entity: T, checked: boolean) {
this.list = this.list.map(v => (v.entity === entity ? { ...v, checked });
}
setCheckedForAll(checked: boolean) {
this.list = this.list.map(v => ({...v, checked}));
}
next(v: T[]) {
this.eventEmitter.emit(v);
suer.next(v);
}
}
юзаем в компоненте:
export class SampleComponent {
@Input set data(v: SampleDto) {
this.multiSelector = new EntityMultiSelector<SampleDto>;
this.selectedSamples = this.multiSelector.eventEmiter;
}
multiSelector: EntityMultiSelector<SampleDto>;
@Output selectedSamples: EventEmitter<SampleDto[]>
}
Как это будет выглядеть в шаблоне:
<app-sample-entity *ngFor = "let state of multiSelector.list"
[data] = "state.entity"
[checked] = "state.checked"
(checked) = "multiSelector.setCheckedForEntity(state.entity, $event)"
></app-sample-entity>
Всего: {{multiSelector.list.length}} Выбрано: {{multiSelector.value.lenght}}
<button (click) = "multiSelector.setSelectedForAll(false)">Очистить</button>