Недавно Бхарат Рави опубликовал статью о директиве самосохраняющегося select-элемента на InDepth. Это интересная концепция изолирования логики в директиве, что в целом идея хорошая.
Однако в этом случае у меня есть сомнения, которые я хочу подсветить. Я предлагаю свою версию компонента, исправляющую эти моменты. Начнем с того, что назовем проблемы текущего решения.
Директивы работают со своим элементом
Когда вы задумываетесь о создании директивы, обратите внимание на работу с шаблоном. Если для функционирования нужно несколько элементов, то для этой цели лучше подойдет компонент.
«Ваши директивы не вольны модифицировать DOM за рамками своего элемента»
Вот фрагмент кода из оригинальной статьи:
handleErrorCase(element) {
this.removeBackground(element);
const child = this.document.createElement('img');
child.src = ERROR_ICON;
const parent = this.renderer.parentNode(this.elRef.nativeElement);
this.renderer.appendChild(parent, child);
setTimeout(() => {
this.renderer.removeChild(parent, child);
}, 1000);
}
Здесь директива добавляет сиблинга своему элементу, проходя через родителя. У директив не может быть своих стилей, поэтому позиционировать новый элемент будет сложно.
Мы также не знаем о стилях родителя, возможно, еще один ребенок сломает верстку. И, наконец, мы даже не знаем, существует ли родитель. Если ваша директива лежит в контенте другого компонента, есть шанс, что она отрендерится до прикрепления к DOM. Поэтому золотое правило гласит: в директивах оставайтесь в рамках их элемента.
Избегайте ручной работы с DOM
Мне понятно желание минимизировать вложенность. Вместо прикрепления иконок как картинок мы могли бы менять фон элемента через CSS и помещать иконки в нужное место. Это полезно для UX, так как картинки не блокируют клики. Но ручная работа с DOM — плохая затея по ряду причин:
Это угроза кросс-платформенности.
Это потенциально небезопасно, так как мы минуем санитайзер.
Оптимизация становится нашей ответственностью.
Это не по-Ангуляровски.
Мы могли бы применить @HostBinding
для задания стилей. Но их непросто использовать с Observable
(см. мою статью по теме) и OnPush-проверкой изменений. Кроме того, все будет гораздо прозрачнее, если у нас будет шаблон со всеми нужными элементами. Так что вместо запихивания всего в один элемент давайте сделаем небольшую обертку:
<autosave-select>
<select>...</select>
</autosave-select>
Пишите асинхронные действия декларативно
Обычно мы прибегаем к RxJS для асинхронных операций. Директива из статьи тоже начинает с RxJS, но быстро переходит к императивным манипуляциям и setTimeout
. Все это можно решить, не покидая реактивный мир. Я очень рекомендую всем вкладываться в изучение RxJS. Это один из самых мощных инструментов в экосистеме Angular.
Подборка задачек для тренировки RxJS от нас с Ромой
В данном случае мы можем сделать нехитрую цепочку операторов, которая обработает все за нас. Еще мы избавимся от необходимости ручной подписки. С async
-пайпом в конце мы сможем подключить OnPush и забыть про отписки.
Передача методов через инпут — сомнительная затея
Я не против чистых функций в инпутах. Мы часто используем их в Taiga UI, например методы преобразования <T>
в строку или проверку на состояние disabled
. Но в этом случае она выглядит странно.
Обычно инпуты используют для переменных значений. Для статичных данных я предпочитаю Dependency Injection. Директива задумана самодостаточной, но работа с сервером все равно скинута на родительский компонент, когда мы передаем метод через инпут. С помощью DI можно добавить уровень абстракции:
export abstract class SaveService<T> {
abstract save(value: T): Observable<unknown>;
}
Теперь реально передавать реализации через сервисы или директивы на случай нескольких таких компонентов на странице.
Рефактор
Теперь давайте сделаем компонент с той же задачей. Для начала мы запустим поток при выборе. Это может быть fromEvent
или Subject
+ @HostListener
:
export class AutosaveSelectComponent<T> {
private readonly change$ = new Subject<T>();
@HostListener('change', ['$event.target.value'])
onChange(value: T) {
this.change$.next(value);
}
}
Затем создадим Observable
состояния, отвечающий за индикацию:
readonly state$ = this.change$.pipe(
switchMap(value =>
this.service.save(value).pipe(
switchMapTo(
timer(3000).pipe(
mapTo(null),
startWith(State.Success)
)
),
startWith(State.Loading),
catchError(() => of(State.Error))
)
),
);
На каждое изменение он стартует в состоянии загрузки. При ошибке он сбрасывается на состояние ошибки, которое остается висеть. При успешном значении из нашего метода сохранения мы переключимся на таймер, чтобы показать галочку на 3 секунды.
Осталось слегка приправить CSS’ом:
<ng-content></ng-content>
<ng-container [ngSwitch]="state$ | async">
<img
*ngSwitchCase="state.Loading"
class="icon icon_loading"
alt=""
src="loading.png"
/>
<img
*ngSwitchCase="state.Success"
class="icon"
alt=""
src="success.png"
/>
<img
*ngSwitchCase="state.Error"
class="icon"
alt=""
src="error.png"
/>
</ng-container>
Рабочий пример с использованием нескольких сервисов смотрите на StackBlitz.