Русский
Русский
English
Статистика
Реклама

Decorator

Декларативный подход в Angular

12.11.2020 18:15:59 | Автор: admin

Когда я впервые услышал про compliant-механизмы, был весьма впечатлен. Хоть они и окружают нас в повседневности в виде застежек рюкзака, кнопок мыши или колпачков от шампуней, мы редко задумываемся о концепции таких устройств.

Если говорить кратко, в compliant-механизме для обеспечения его технических характеристик используют деформацию. В то время как в традиционной технике (rigid body) гибкость зачастую является негативным качеством материала, сompliant-механизмы используют ее для передачи силы и движения в нужном направлении, вместо соединений из нескольких подвижных деталей.

Поскольку такой механизм является единым целым, а не набором частей, скрепленных осями, резьбой, шарнирами, дающими люфт, лишнее трение и сложное изнашивание, его движение полностью предсказуемо. Подобная точность понятна: ты двигаешь одну часть, а все остальное связано с ней изгибающимися секциями. Они не могут изменяться в размере, как традиционные пружины, и постоянно корректируют, направляют друг друга.

Я нахожу в этом отличную аналогию на декларативный подход в программировании в противовес императивному, в котором, подобно передачи движения через шарниры и оси, команды управляют изменениями состояния. И я хотел бы поделиться определенной философией написания Angular-кода, сложившейся у меня за время работы над библиотекой переиспользуемых компонентов для пользовательского интерфейса.

Compliant-компоненты

Angular изначально предполагает определенную долю декларативности: дата-байндинг, работа с событиями, активное использование observable-модели. Несложные компоненты буквально описаны утверждениями, что чем является и как связано с окружением. Они не выглядят набором инструкций, изменяющих состояние в ту или иную сторону. Однако с усложнением задачи компонент может легко утратить это описательное качество и превратиться в череду вызовов, подписок и хранимых данных, отвечающих за его поведение, синхронизировать которые становится всё сложнее.

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

Линейный график

Это довольно незамысловатый компонент. Мы передаем ему массив пар чисел и хотим, чтобы SVG построил по ним путь. Мы постараемся организовать наш код так, чтобы в нем не было императивных манипуляций с состоянием. Можно повесить компонент прямо на нативный SVG, чтобы избежать вложенности:

@Component({  selector: "svg[lineChart]",  templateUrl: "./line-chart.template.html",  styleUrls: ["./line-chart.style.less"],  changeDetection: ChangeDetectionStrategy.OnPush,  host: {        preserveAspectRatio: "none"  }})export class LineChartComponent {}

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

  @HostBinding('attr.viewBox')  get viewBox(): string {    return `${this.x} ${this.y} ${this.width} ${this.height}`;  }

Чтобы сделать компонент интереснее, давайте добавим инпут на уровень сглаживания. Шаблон будет состоять из единственного path-элемента:

<svg:path  fill="none"  stroke="currentColor"  vector-effect="non-scaling-stroke"  stroke-width="2"  [attr.d]="d"/>

Нам нужно рассчитать атрибут d. Одним из вариантов может стать сеттер на входные данные. Но в будущем мы можем добавить другие связанные функции, например заливку под графиком или подсказки при наведении. Поэтому давайте добавим геттер, чтобы не управлять состоянием вручную:

  get d(): string {    return this.data.reduce(      (d, point, index) =>        index ? `${d} ${draw(this.data, index, this.smoothing)}` : `M ${point}`,      ""    );  }

Вот и всё! Сами функции, рассчитывающие путь, я описывать не буду их можно нагуглить. В конце будет живое демо всех созданных в статье компонентов, где вы сможете подглядеть исходный код.

Media-директива

Давайте теперь возьмемся за что-нибудь посложнее. Мы хотим научиться управлять медиаэлементами, такими как аудио- или видеотеги. И мы будем делать это с минимумом императивных вызовов. Нам нужно контролировать три вещи: текущее время, громкость и состояние воспроизведения/паузы. Все они также могут меняться нативными контролами, так что это будет двусторонний байндинг:

  @Input()  currentTime = 0;   @Input()  paused = true;   @Input()  @HostBinding("volume")  volume = 1;   @Output()  readonly currentTimeChange = new EventEmitter<number>();   @Output()  readonly pausedChange = new EventEmitter<boolean>();   @Output()  readonly volumeChange = new EventEmitter<number>();   @HostListener("volumechange")  onVolumeChange() {    this.volume = this.elementRef.nativeElement.volume;    this.volumeChange.emit(this.volume);  }

Видите @HostBinding на volume? Этого достаточно для громкости. Но не в случае с currentTime: он быстро меняется сам во время воспроизведения. Поэтому байндинг тут вызовет заикания из-за цикла постоянных обновлений. Так что заменим инпут на сеттер и будем обрабатывать только измененные значения:

  @Input()  set currentTime(currentTime: number) {    if (currentTime !== this.currentTime) {      this.elementRef.nativeElement.currentTime = currentTime;    }  }   get currentTime(): number {    return this.elementRef.nativeElement.currentTime;  }   @HostListener("timeupdate")  @HostListener("seeking")  @HostListener("seeked")  onCurrentTimeChange() {    this.currentTimeChange.emit(this.currentTime);  }

Для воспроизведения и паузы тоже создадим пару геттер/сеттер:

  @Input()  set paused(paused: boolean) {    if (paused) {      this.elementRef.nativeElement.pause();    } else {      this.elementRef.nativeElement.play();    }  }   get paused(): boolean {    return this.elementRef.nativeElement.paused;  }

Имея такую директиву, написать свой видеоплеер не составит труда:

<video  #video  media  class="video"  [(currentTime)]="currentTime"  [(paused)]="paused"  (click)="toggleState()">  <ng-content></ng-content></video><div class="controls">  <button    class="button"    type="button"    title="Play/Pause"    (click)="toggleState()"  >    {{icon}}  </button>  <input    class="progress"    type="range"    [max]="video.duration"    [(ngModel)]="currentTime"  /></div>

С помощью ng-content пользователи смогут предоставить свои видеофайлы как для нативного видеотега. А код компонента будет бессовестно краток:

  currentTime = 0;   paused = true;   get icon(): string {    return this.paused ? "\u23F5" : "\u23F8";  }   toggleState() {    this.paused = !this.paused;  }

Комбо-бокс

Теперь, когда мы настроились на нужный лад, давайте окунемся в серьезный код. Комбо-бокс куда более сложный пример, но не волнуйтесь. У нас не будет функций длиннее строки! Ну, разве что одна.

В этой части я буду полагаться на декларативный preventDefault. Он работает благодаря библиотеке ng-event-plugins, о которой я писал ранее.

Начнем с шаблона. Мы не будем создавать кастомный контрол, поскольку это отдельная тема. Вместо этого мы обернем нативный input, чтобы у пользователей был полный контроль над ним:

<combo-box [items]="items">  <input type="text" [(ngModel)]="value"></combo-box>

Внутренний шаблон будет использовать label для того, чтобы input фокусировался при клике по стрелке. Это хак! Разработчики не смогут добавить доступный лейбл, просто обернув наш компонент. Но для примера этого хватит, чтобы сэкономить время:

<label>  <ng-content></ng-content>  <div class="toggle" (mousedown.prevent)="toggle()"></div></label><div *ngIf="open" class="list" (mousedown.prevent)="noop()">  <div     *ngFor="let item of filteredItems; let index = index"    class="item"    [class.item_active]="isActive(index)"    (click)="onClick(item)"    (mouseenter)="onMouseEnter(index)"  >    {{item}}  </div></div>

Отменять действие по умолчанию для mousedown нужно, чтобы фокус не покидал поле ввода. У компонента один единственный инпут массив строк-подсказок при вводе. И в данном случае у него будет одно внутреннее состояние, которое придется контролировать вручную.

Вы можете подумать, что это состояние раскрытости выпадашки, но нет. Это индекс текущего выбранного элемента выпадашки. Мы будем использовать NaN как индикатор отсутствия текущего элемента, чтобы остаться в рамках типа number. Открытость выпадашки будет контролировать геттер на наличие выбранного элемента:

  get open(): boolean {    return !isNaN(this.index);  }

Нам нужно сузить варианты исходя из введенного пользователем текста. Мы добавим NgControl в виде @ContentChild, чтобы получить доступ к его значению. Это позволит нам отфильтровать массив:

  @ContentChild(NgControl)  private readonly control: NgControl;   get value(): string {    return String(this.control.value);  }   get filteredItems(): readonly string[] {    return this.items.filter(item =>       item.toLowerCase().includes(this.value.toLowerCase())    );  }

Теперь мы можем добавить еще один геттер для индекса элемента выпадашки:

  get clampedIndex(): number {    return limit(this.index, this.filteredItems.length - 1);  }// ... function limit(value: number, max: number): number {  return Math.max(Math.min(value || 0, max), 0);}

С ним безопасно работать, поскольку он всегда находится в рамках реально доступных индексов.

Теперь добавим обработчики событий, на которые мы подписались в шаблоне. Нам нужно открывать и закрывать список по клику на стрелку, выбирать элемент из списка и обновлять активный индекс по наведению:

  onClick(item: string) {    this.selectItem(item);  }   onMouseEnter(index: number) {    this.index = index;  }   @HostListener('keydown.esc')  @HostListener('focusout')  close() {    this.index = NaN;  }   toggle() {    this.index = this.open ? NaN : 0;  }   private selectItem(value: string) {    this.control.control.setValue(value);    this.close();  }

Теперь добавим работу с клавиатуры: по нажатию стрелок будем менять индекс, а клавишей Enter выбирать пункт. Также список будет показываться при вводе текста в поле:

  @HostListener('keydown.arrowDown.prevent', ['1'])  @HostListener('keydown.arrowUp.prevent', ['-1'])  onArrow(delta: number) {    this.index = this.open       ? limit(        this.clampedIndex + delta,         this.filteredItems.length - 1      )       : 0;  }   @HostListener('keydown.enter.prevent')  onEnter() {    this.selectItem(      this.open        ? this.filteredItems[this.clampedIndex]        : this.value    )  }   @HostListener('input')  onInput() {    this.index = this.clampedIndex;  }

Вот, в общем-то, и все. Компонент полностью работоспособен. Мы написали его в декларативном стиле, оставив только одно состояние для ручного контроля. У нас нет императивных команд вроде показать выпадашку. Вместо этого мы описали поведение компонента относительно его состояний: введенного текста, списка вариантов и активного элемента в нем. Вот почему это называется декларативным подходом.

В реальности подобный компонент требует доработки доступности. Вы можете добавить ARIA-атрибуты, такие как aria-activedescendant. Так скрин-ридеры и другие технологии доступности тоже будут в курсе активного элемента. Узнать больше про комбобокс-паттерн можно тут и тут.

Вы задумывались, отчего АК-47 является столь популярным оружием последних десятилетий? В его конструкции всего восемь подвижных частей, благодаря этому его легко производить, использовать и обслуживать. Это применимо и к архитектуре приложений: чем меньше состояний нужно обрабатывать, тем надежнее будет код. Простой дизайн надежный дизайн. И хотя декларативный код поначалу может показаться непростым, когда к нему привыкаешь начинаешь ценить его аккуратность.

Производительность

Естественный вопрос, который может возникнуть: если мы продолжаем все пересчитывать, не пострадает ли производительность нашего приложения? Разумеется, для такого подхода обязательна OnPush-стратегия проверки изменений. И, откровенно говоря, я не встречал ситуации, когда стратегия Default была бы оправдана, кроме компонента отображения ошибки в поле, поскольку на момент написания статьи в Angular так и не появился стрим на изменение touched-состояния.

Чтобы оценить производительность, давайте внимательно посмотрим на то, что мы делаем в геттерах. Конкатенация строк, как в случае с viewBox, имеет скорость порядка 1 млрд операций в секунду, или 300 млн на средненьком android-устройстве. Очевидно, это не может повредить скорости работы компонента. То же самое относится к простым арифметическим и булевым операциям.

Все становится интереснее, когда дело доходит до работы с массивами и объектами. Перебор массива из 100 элементов для поиска последнего имеет скорость 15 млн операций в секунду на моем ПК и в десять раз медленнее на смартфоне. Немутабельные операции, которые создают новые экземпляры массивов, еще медленнее. Фильтрация массива из 100 элементов выдает только 3 млн операций в секунду на компьютере и всего 300 тысяч на телефоне. Работа с созданием объектов схожий кейс за счет внутренней механики JavaScript. Можете сами оценить быстродействие вот тут. Это значит, что для того, чтобы концепция compliant-компонентов взлетела, требуется оптимизация.

Добавим простую мемоизацию, чтобы избежать лишних пересчетов. Мы можем создать декоратор для чистых методов. Он будет запоминать переданные аргументы и последний подсчитанный результат. Если аргументы не изменились, он просто вернет прошлое значение:

export function Pure<T>(  _target: Object,  propertyKey: string,  { enumerable, value }: TypedPropertyDescriptor<T>): TypedPropertyDescriptor<T> {  const original = value;   return {    enumerable,    get(): T {      let previousArgs: ReadonlyArray<unknown> = [];      let previousResult: any;       const patched = (...args: Array<unknown>) => {        if (          previousArgs.length === args.length &&          args.every((arg, index) => arg === previousArgs[index])        ) {          return previousResult;        }         previousArgs = args;        previousResult = original(...args);         return previousResult;      };       Object.defineProperty(this, propertyKey, {        value: patched      });       return patched as any;    }  };}

Таким образом мы можем переписать наш код на пары геттер + чистый приватный метод:

  get filteredItems(): readonly string[] {    return this.filter(this.items, this.value);  }   @Pure  private filter(items: readonly string[], value: string): readonly string[] {    return items.filter(item =>       item.toLowerCase().includes(value.toLowerCase())    );  }

Давайте оценим этот подход. Сравним его с неоптимизированным декларативным кодом и императивным, в котором все обновляется руками в ngOnChanges: stackblitz.com/edit/compliant-components-performance-ivy.

В этом примере список из 1000 компонентов и кнопка, которая запускает проверку изменений во всех разом. Императивные компоненты равноценны холостому прогону, ведь инпуты не меняются и ничего не происходит. Декларативные же компоненты содержат в себе несколько геттеров. Логическая проверка значения на превышение предела, конкатенация строк, математический расчет вместе с @HostBinding, вешающим класс. А также перебор массива, создание массива и создание объекта. Не забывайте, что все это умножено на 1000, ведь каждый компонент содержит эти геттеры в себе. Последний столбец в примере использует @Pure-декоратор для операций с массивами и объектами. Такие результаты я получил в среднем за 100 прогонов проверки изменений на своем компьютере:

А такие на смартфоне:

Различия на ПК укладываются в погрешность, в то время как android-девайс средней производительности выдает разницу в 10%. Можно посмотреть на это и сказать: Да оно же на 10% медленнее! Но можно взглянуть на это иначе. Даже на слабом устройстве с несколькими тысячами геттеров, считающимися одновременно, мы остаемся в пределах 60 кадров в секунду. И это всего на 1,5 миллисекунды дольше, чем холостой прогон накладные расходы от Angular.

На практике операции с DOM чаще всего могут стать бутылочным горлышком. Это самые затратные операции типового приложения. Помните байндинг геттера на класс? Фишка в том, что браузеры не будут вносить изменения в DOM, если значение осталось прежним. Это касается классов, стилей или атрибутов.

Если организовывать свое дерево компонентов разумно, применять мемоизации и использовать OnPush, декларативный подход не станет затыком производительности.

Итог

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

Компоненты, которые мы создали, можно потыкать здесь:

Я возглавляю разработку проприетарного UI-кита в Tinkoff. Вся библиотека построена на принципах, изложенных в этой статье. Прямо сейчас библиотека в процессе выхода в open-source, и основополагающий пакет уже доступен на GitHub и npm. В нем вы найдете описанный Pure-декоратор и много других полезных низкоуровневых инструментов для создания крутых приложений на Angular. Мы обязательно расскажем про них в будущих статьях, так что до встречи!

Подробнее..

Кастомные декораторы для NestJS от простого к сложному

14.07.2020 12:05:49 | Автор: admin

image


Введение


NestJS стремительно набирающий популярность фрeймворк, построенный на идеях IoC/DI, модульного дизайна и декораторов. Благодаря последним, Nest имеет лаконичный и выразительный синтаксис, что повышает удобство разработки.


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


Базовые декораторы


Возьмем простейший http-контроллер. Допустим, нам требуется, чтобы только определенные пользователи могли воспользоваться его методами. Для этого кейса в Nest есть встроенная функциональность гардов.
Guard это комбинация класса, реализующего интерфейс CanActivate и декоратора @UseGuard.


@Injectable()export class RoleGuard implements CanActivate {  canActivate(    context: ExecutionContext,  ): boolean | Promise<boolean> | Observable<boolean> {    const request = context.switchToHttp().getRequest();    return getRole(request) === 'superuser'  }}@Controller()export class MyController {  @Post('secure-path')  @UseGuards(RoleGuard)  async method() {    return  }}

Захардкоженный superuser не самое лучшее решение, куда чаще нужны более универсальные декораторы.
Nest в этом случае предлагает использовать
декоратор @SetMetadata. Как понятно из названия, он позволяет ассоциировать метаданные с декорируемыми объектами классами или методами.
Для доступа к этим данным используется экземпляр класса Reflector, но можно и напрямую через reflect-metadata.


@Injectable()export class RoleGuard implements CanActivate {  constructor(private reflector: Reflector) {}  canActivate(    context: ExecutionContext,  ): boolean | Promise<boolean> | Observable<boolean> {    const role = this.reflector.get<string>('role', context.getHandler());    const request = context.switchToHttp().getRequest();    return getRole(request) === role  }}@Controller()export class MyController {  @Post('secure-path')  @SetMetadata('role', 'superuser')  @UseGuards(RoleGuard)  async test() {    return  }}

Композитные декораторы


Декораторы зачастую применяются в связках.
Обычно это обусловлено тесной связностью эффектов в каком-то бизнес-сценарии. В этом случае имеет смысл объединить несколько декораторов в один.
Для композиции можно воспользоваться утилитной функцией applyDecorators.


const Role = (role) => applyDecorators(UseGuards(RoleGuard), SetMetadata('role', role))

или написать агрегатор самим:


const Role = role => (proto, propName, descriptor) => {  UseGuards(RoleGuard)(proto, propName, descriptor)  SetMetadata('role', role)(proto, propName, descriptor)}@Controller()export class MyController {  @Post('secure-path')  @Role('superuser')  async test() {    return  }}

Полиморфные декораторы


Легко столкнуться с ситуацией, когда оказывается нужным задекорировать все методы класса.


@Controller()@UseGuards(RoleGuard)export class MyController {  @Post('secure-path')  @Role('superuser')  async test1() {    return  }  @Post('almost-securest-path')  @Role('superuser')  async test2() {    return  }  @Post('securest-path')  @Role('superuser')  async test3() {    return  }}

Такой код можно сделать чище, если повесить декоратор на сам класс. И уже внутри декоратора класса обойти прототип, применяя эффекты на все методы, как если бы декораторы были повешены на каждый метод по-отдельности.
Однако для этого обработчику необходимо различать типы объектов применения класс и метод и в зависимости от этого выбирать поведение.
Реализация декораторов в typescript не содержит этот признак в явном виде,
поэтому его приходится выводить из сигнатуры вызова.


type ClassDecorator = <TFunction extends Function>(target: TFunction) => TFunction | void;type MethodDecorator = <T>(target: Object, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<T>) => TypedPropertyDescriptor<T> | void;type ParameterDecorator = (target: Object, propertyKey: string | symbol, parameterIndex: number) => void;const Role = (role: string): MethodDecorator | ClassDecorator => (...args) => {  if (typeof args[0] === 'function') {    // Получение конструктора    const ctor = args[0]    // Получение прототипа    const proto = ctor.prototype    // Получение методов    const methods = Object      .getOwnPropertyNames(proto)      .filter(prop => prop !== 'constructor')    // Обход и декорирование методов    methods.forEach((propName) => {      RoleMethodDecorator(        proto,        propName,        Object.getOwnPropertyDescriptor(proto, propName),        role,      )    })  } else {    const [proto, propName, descriptor] = args    RoleMethodDecorator(proto, propName, descriptor, role)  }}

Есть вспомогательные библиотеки, которые берут на себя часть этой рутины: lukehorvat/decorator-utils, qiwi/decorator-utils.
Это несколько улучшает читаемость.


import { constructDecorator, CLASS, METHOD } from '@qiwi/decorator-utils'const Role = constructDecorator(  ({ targetType, descriptor, proto, propName, args: [role] }) => {    if (targetType === METHOD) {      RoleMethodDecorator(proto, propName, descriptor, role)    }    if (targetType === CLASS) {      const methods = Object.getOwnPropertyNames(proto)      methods.forEach((propName) => {        RoleMethodDecorator(          proto,          propName,          Object.getOwnPropertyDescriptor(proto, propName),          role,        )      })    }  },)

Совмещение в одном декораторе логики для разных сценариев дает очень весомый плюс для разработки:
вместо @DecForClass, @DecForMethood, @DecForParam получается всего один многофункциональный @Dec.


Так, например, если роль пользователя вдруг потребуется в бизнес-слое контроллера, можно просто расширить логику @Role.
Добавляем в ранее написанную функцию обработку сигнатуры декоратора параметра.
Так как подменить значение параметров вызова напрямую нельзя, createParamDecorator делегирует это вышестоящему декоратору посредством метаданных.
И далее именно декоратор метода / класса будет резолвить аргументы вызова (через очень длинную цепочку от ParamsTokenFactory до RouterExecutionContext).


// Сигнатура параметра  if (typeof args[2] === 'number') {    const [proto, propName, paramIndex] = args    createParamDecorator((_data: unknown, ctx: ExecutionContext) => {      return getRole(ctx.switchToHttp().getRequest())    })()(proto, propName, paramIndex)  }

Также стоит отметить, что при помощи метадаты можно решать разные интересные кейсы, например, вводить ограничения для повторяемости или сочетаемости аннотаций.
Предположим, нам потребовалось ограничение размера запроса, и соответствующий декоратор повесили дважды. Какому значению доверять?
Без знания логики компилятора возникает неопределенность. Правильнее, наверное, было бы бросить ошибку.


class SomeController {   @RequestSize(1000)   @RequestSize(5000)   @Post('foo')   method(@Body() body) {   }}

Вот другой пример: необходимо ограничить работу методов контроллера отдельными портами. Здесь, скорее, требуется не затирать предыдущие значения,
а добавлять новые к имеющимся.


class SomeController {   @Port(9092)   @Port(8080)   @Post('foo')   method(@Body() body) {   }}

Схожая ситуация возникает с ролевой моделью.


class SomeController {  @Post('securest-path')  @Role('superuser')  @Role('usert')  @Role('otheruser')  method(@Role() role) {  }}

Обобщая рассуждения, реализация декоратора для последнего примера с использованием reflect-metadata и полиморфного контракта
может иметь вид:


import { ExecutionContext, createParamDecorator } from '@nestjs/common'import { constructDecorator, METHOD, PARAM } from '@qiwi/decorator-utils'@Injectable()export class RoleGuard implements CanActivate {  canActivate(context: ExecutionContext): boolean | Promise<boolean> {    const roleMetadata = Reflect.getMetadata(      'roleMetadata',      context.getClass().prototype,    )    const request = context.switchToHttp().getRequest()    const role = getRole(request)    return roleMetadata.find(({ value }) => value === role)  }}const RoleMethodDecorator = (proto, propName, decsriptor, role) => {  UseGuards(RoleGuard)(proto, propName, decsriptor)  const meta = Reflect.getMetadata('roleMetadata', proto) || []  Reflect.defineMetadata(    'roleMetadata',    [      ...meta, {        repeatable: true,        value: role,      },    ],    proto,  )}export const Role = constructDecorator(  ({ targetType, descriptor, proto, propName, paramIndex, args: [role] }) => {    if (targetType === METHOD) {      RoleMethodDecorator(proto, propName, descriptor, role)    }    if (targetType === PARAM) {      createParamDecorator((_data: unknown, ctx: ExecutionContext) =>        getRole(ctx.switchToHttp().getRequest()),      )()(proto, propName, paramIndex)    }  },)

Макродекораторы


Nest спроектирован таким образом, что его собственные декораторы удобно расширять и переиспользовать. На первый взгляд довольно сложные кейсы, к примеру, связанные с добавлением поддержки новых протоколов, реализуются парой десятков строк обвязочного кода. Так, стандартный @Controller можно обсахарить
для работы с JSON-RPC.
Не будем останавливаться на этом подробно, это слишком бы далеко вышло за формат этой статьи, но покажу основную идею: на что способны декораторы, в сочетании с Nest.


import {  ControllerOptions,  Controller,  Post,  Req,  Res,  HttpCode,  HttpStatus,} from '@nestjs/common'import { Request, Response } from 'express'import { Extender } from '@qiwi/json-rpc-common'import { JsonRpcMiddleware } from 'expressjs-json-rpc'export const JsonRpcController = (  prefixOrOptions?: string | ControllerOptions,): ClassDecorator => {  return <TFunction extends Function>(target: TFunction) => {    const extend: Extender = (base) => {      @Controller(prefixOrOptions as any)      @JsonRpcMiddleware()      class Extended extends base {        @Post('/')        @HttpCode(HttpStatus.OK)        rpc(@Req() req: Request, @Res() res: Response): any {          return this.middleware(req, res)        }      }      return Extended    }    return extend(target as any)  }}

Далее необходимо извлечь @Req() из rpc-method в мидлваре, найти совпадение с метой, которую добавил декоратор @JsonRpcMethod.
Готово, можно использовать:


import {  JsonRpcController,  JsonRpcMethod,  IJsonRpcId,  IJsonRpcParams,} from 'nestjs-json-rpc'@JsonRpcController('/jsonrpc/endpoint')export class SomeJsonRpcController {  @JsonRpcMethod('some-method')  doSomething(    @JsonRpcId() id: IJsonRpcId,    @JsonRpcParams() params: IJsonRpcParams,  ) {    const { foo } = params    if (foo === 'bar') {      return new JsonRpcError(-100, '"foo" param should not be equal "bar"')    }    return 'ok'  }  @JsonRpcMethod('other-method')  doElse(@JsonRpcId() id: IJsonRpcId) {    return 'ok'  }}

Вывод


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

Подробнее..

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

26.02.2021 18:20:16 | Автор: admin

Как и многие другие 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. Надеюсь, этот материал будет для вас полезным!

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru