Кастомные Subject'ы в Angular: выносим логику из компонентов для переиспользования

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

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

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

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

    Ads
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More

    Comments 4

      0

      У вас в коде опечатки, он не будет работать.)

      +1
      Я бы не стал наследоваться, оставил в интерфейсе только необходимое. Использую подобные фишки в своих проектах.

      И не обязательно использовать EventEmitter для Output событий. Это всего лишь враппер над Observable, который позволяет через флаг указать кидать события синхронно или асинхронно.

      class Switch {
      	static create({ enabled }: { enabled: boolean } = { enabled: false }): Switch {
      		return new Switch(new BehaviorSubject(enabled));
      	}
      
      	get enabled(): boolean {
      		return this.enabledSubject.value;
      	}
      
      	enabled$: Observable<boolean> = this.enabledSubject.asObservable();
      
      	private constructor(private enabledSubject: BehaviorSubject<boolean>) {}
      
      	toggle(): void {
      		this.enabledSubject.next(!this.enabled);
      	}
      }
      
      export class SampleComponent {
      	somethingSwitch = Switch.create();
      
      	@Output() somethingSelected = this.somethingSwitch.enabled$()
       ...
      }
      
      
        0
        Вообще да, EventEmitter наследуется от Subject, я раньше запихивал BehaviorSubject в Output и не парился. Потом прочитал где-то, что команда Angular рекомендует использовать в Output только эмиттеры, вроде как для совместимости с более новыми, еще не вышедшими версиями Angular, так как не исключают добавление какой-нибудь специфичной логики в эмиттеры.

      Only users with full accounts can post comments. Log in, please.