...

пятница, 26 февраля 2021 г.

Учим HostBinding работать с Observable

Как и многие другие Angular-разработчики, я мирился с одним ограничением. Если мы хотим использовать Observable в шаблоне, мы можем взять знакомый всем async пайп:

<button [disabled]=”isLoading$ | async”>

Но его нельзя применить к @HostBinding. Давным-давно это было возможно по ошибке, но это быстро исправили:

@Directive({
  selector: 'button[my-button]'
  host: {
    '[disabled]': '(isLoading$ | async)'
  }
})
export class MyButtonDirective {

Все потому, что хост байндинг относится к родительскому view и в нем этот пайп может быть не подключен. Это довольно желанная фича. Давайте посмотрим, как мы можем ее реализовать, пока нет официального решения.

Как работает асинхронный байндинг?

Байндинг работает с обычными данными. Когда у нас есть Observable — на этом этапе необходимо покинуть реактивный мир. Нам нужно подписаться на поток, запускать проверку изменения по каждому значению и отписаться, когда поток больше не нужен. Примерно это за нас и делает async пайп. И это то, что ложится на наши плечи, когда мы хотим забайндить какие-то реактивные данные на хост. 

Зачем это может понадобиться?

Мы часто работаем с RxJS в Angular. Большинство наших сервисов построены на Observable-модели. Вот пара примеров, где возможность завязываться на реактивные данные в @HostBinding была бы полезна:

  • Перевод атрибутов на другой язык. Если мы хотим сделать динамическое переключение языка в приложении — мы будем использовать Observable. При этом обновлять ARIA-атрибуты, title или alt для изображений довольно непросто.

  • Изменение класса или стилей. Observable-сервис может управлять размером или трансформацией через изменение стилей хоста. Или, например, мы можем использовать реактивный IntersectionObserver для применения класса к sticky-шапке в таблице:

  • Изменение полей и атрибутов. Иногда мы хотим завязаться на BreakpointObserver для обновления placeholder или на сервис загрузки данных для выставления disable на кнопке.

  • Произвольные строковые данные, хранимые в data-атрибутах. В моей практике для них тоже иногда используются Observable-сервисы.

В Taiga UI — библиотеке, над которой я работаю, — есть несколько инструментов, чтобы сделать этот процесс максимально декларативным:

import {TuiDestroyService, watch} from '@taiga-ui/cdk';
import {Language, TUI_LANGUAGE} from '@taiga-ui/i18n';
import {Observable} from 'rxjs';
import {map, takeUntil} from 'rxjs/operators';

@Component({
   selector: 'my-comp',
   template: '',
   providers: [TuiDestroyService],
})
export class MyComponent {
   @HostBinding('attr.aria-label')
   label = '';

   constructor(
       @Inject(TUI_LANGUAGE) language$: Observable<Language>,
       @Inject(TuiDestroyService) destroy$: Observable<void>,
       @Inject(ChangeDetectorRef) changeDetectorRef: ChangeDetectorRef,
   ) {
       language$.pipe(
           map(getTranslation('label')),
           watch(changeDetectorRef),
           takeUntil(destroy$),
       ).subscribe();
   }
}

Как видите, тут довольно много кода просто чтобы реализовать байндинг одного значения. Как было бы здорово, если бы можно было просто написать:

@HostBinding('attr.aria-label')
readonly label$ = this.translations.get$('label');

Это потребует серьезных доработок со стороны команды Angular. Но мы можем использовать хитрый трюк в рамках публичного API, чтобы заставить это работать!

Event-плагины спешат на помощь!

Мы не можем добавить свою логику к байндингу на хост. Но мы можем сделать это для @HostListener! Я уже писал статью на эту тему. Прочитайте ее, если хотите узнать, как добавить декларативные preventDefault/stopPropagation и оптимизировать циклы проверки изменений. Если кратко — Angular позволяет добавлять свои сервисы для обработки событий. Подходящий сервис выбирается с помощью имени события. Давайте перепишем код следующим образом:

@HostBinding('$.aria-label.attr')
@HostListener('$.aria-label.attr')
readonly label$ = this.translations.get$('label');

Выглядит странно — пытаться решить задачу @HostBinding через @HostListener но читайте дальше и вы всё увидите.

Мы будем использовать $ в качестве индикатора в имени события. Модификатор .attr добавим в конец, а не в начало. Иначе регулярное выражение в Angular решит, что мы байндим строковый атрибут.

У плагинов для обработки событий есть доступ к элементу, имени события и функции-обработчику. Последний аргумент для нас бесполезен, так как это обертка, созданная компилятором. Так что нам нужно как-то передать наш Observable через элемент. Вот тут-то нам и пригодится @HostBinding. Мы положим Observable в поле с тем же именем, и тогда у нас будет доступ к нему внутри плагина:

addEventListener(element: HTMLElement, event: string): Function {
   element[event] = EMPTY;

   const method = this.getMethod(element, event);
   const sub = this.manager
       .getZone()
       .onStable.pipe(
           take(1),
           switchMap(() => element[event]),
       )
       .subscribe(method);


   return () => sub.unsubscribe();
}

Компилятор Angular

Посмотрим на этот код повнимательнее. Первая строка может вас смутить. Хоть мы и можем назначать произвольные поля на элементы, Angular попытается их провалидировать:

Возможно, вы видели такое раньше
Возможно, вы видели такое раньше

Плагины хороши тем, что подписка на события происходит раньше разрешения байндингов. Благодаря первой строке Angular считает, что у элемента присутствует это свойство. Дальше нам нужно убедиться, что Observable уже на месте — ведь на момент подписки его еще нет. Хорошо, что у нас есть доступ до NgZone и мы можем дождаться ее стабилизации, прежде чем запросить свойство элемента.

NgZone испускает onStable когда не осталось больше микро- и макрозадач в очереди. Для нас это означает, что Angular завершил цикл проверки изменений и все байндинги обновлены.

А отписку за нас сделает сам Angular — достаточно вернуть функцию, прерывающую стрим.

Этого хватит, чтобы код заработал в JIT, AOT же более щепетилен. Мы добавили несуществующее поле во время выполнения, но AOT желает знать про него на этапе компиляции. До тех пор, пока эта задача не будет закрыта, мы не можем создавать свои списки разрешенных полей. Поэтому нам придется добавить NO_ERRORS_SCHEMA в модуль с подобным байндингом. Это может звучать страшно, но все, что эта схема делает, — перестает проверять, есть ли поле у элемента при байндинге. Кроме того, если у вас WebStorm, вы продолжите видеть предупреждение:

Это сообщение не мешает сборке
Это сообщение не мешает сборке

Также AOT требует реализации Callable-интерфейса для использования @HostListener. Мы можем имитировать его с помощью простой функции, сохранив оригинальный тип:

function asCallable<T>(a: T): T & Function {
    return a as any;
}

Итоговая запись:

@HostBinding('$.aria-label.attr')
@HostListener('$.aria-label.attr')
readonly label$ = asCallable(this.translations.get$('label'));

Другой вариант — вовсе отказаться от @HostBinding ведь нам надо назначить его лишь один раз. Если ваш стрим приходит из DI, что происходит довольно часто, можно создать FactoryProvider. В него можно передать ElementRef и назначить поле в нем:

export const TOKEN = new InjectionToken<Observable<boolean>>("");

export const PROVIDER = {
  provide: TOKEN,
  deps: [ElementRef, IntersectionObserverService],
  useFactory: factory,
}

export function factory(
  { nativeElement }: ElementRef,
  entries$: Observable<IntersectionObserverEntry[]>
): Observable<boolean> {
  return nativeElement["$.class.stuck"] = entries$.pipe(map(isIntersecting));
}

Теперь достаточно будет оставить только @HostListener. Его даже можно написать прямо в декораторе класса:

@Directive({
  selector: "table[sticky]",
  providers: [
    IntersectionObserverService,
    PROVIDER,
  ],
  host: {
    "($.class.stuck)": "stuck$"
  }
})
export class StickyDirective {
  constructor(@Inject(TOKEN) readonly stuck$: Observable<boolean>) {}
}

Приведенный выше пример можно увидеть вживую на StackBlitz. В нем IntersectionObserver используется для задания тени на sticky-шапке таблицы:

Обновление полей

В коде вы видели вызов getMethod. Байндинг в Angular работает не только на атрибутах и полях, но и на классах и стилях. Нам тоже нужно реализовать такую возможность. Для этого разберем имя нашего псевдособытия, чтобы понять, что же делать со значениями из потока:

private getMethod(element: HTMLElement, event: string): Function {
   const [, key, value, unit = ''] = event.split('.');

   if (event.endsWith('.attr')) {
       return v => element.setAttribute(key, String(v));
   }

   if (key === 'class') {
       return v => element.classList.toggle(value, !!v);
   }

   if (key === 'style') {
       return v => element.style.setProperty(value, `${v}${unit}`);
   }

   return v => (element[key] = v);
}

Никакой мудреной логики. На этом все, осталось только зарегистрировать плагин в глобальных провайдерах:

{
    provide: EVENT_MANAGER_PLUGINS,
    useClass: BindEventPlugin,
    multi: true,
}

Это небольшое дополнение способно существенно упростить ваш код. Нам больше не надо беспокоиться о подписке. Описанный плагин доступен в новой версии 2.1.1 нашей библиотеки @tinkoff/ng-event-plugins, а также в @taiga-ui/cdk. Поиграться с кодом можно на StackBlitz. Надеюсь, этот материал будет для вас полезным!

Let's block ads! (Why?)

Комментариев нет:

Отправить комментарий