Кастомные Emitter’ы и Subject’ы в Angular: инкапсулируем логику Toggle и MultiSelect

от автора

В крупных проектах на 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(({checked}) => checked).map(({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 } : v));     }  		setCheckedForAll(checked: boolean) {       		this.list = this.list.map(v => ({...v, checked}));					     }  		next(v: T[]) {       	this.eventEmitter.emit(v); 				super.next(v);     } } 

юзаем в компоненте:

export class SampleComponent { 		@Input() set data(v: SampleDto[]) {     		this.multiSelector = new EntityMultiSelector<SampleDto>(v);         this.selectedSamples = this.multiSelector.eventEmitter;   }   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>                     

работающая версия кода:

https://stackblitz.com/edit/angular-ivy-kyaeac?file=src/app/app.component.html

Похожим способом можно инкапсулировать и множество иных кейсов.

Буду рад вашим идеям в комментариях. Конструктивная критика приветствуется.

ссылка на оригинал статьи https://habr.com/ru/post/533946/