В крупных проектах на 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>