Это будет познавательная статья про одну из самых интересных возможностей Angular, о которой редко вспоминают. Но также это будет и реклама нашей open-source-библиотеки. Поскольку вы, возможно, просто не знаете, насколько она вам нужна. 

За какой-то 1кБ gzip вы сможете улучшить DX во многих различных сценариях, которые мы рассмотрим ниже. Если вы уже знакомы с этой библиотекой, в статье я расскажу про пару новых возможностей.

Как Angular работает с событиями? Что происходит, когда вы пишете (click) в шаблоне? Какая магия обрабатывает клавишу Escape, когда вы подписываетесь на (keydown.esc)? Немного заглянем в исходный код и узнаем про малоизвестный публичный API и как можно использовать его себе во благо.

EventManager

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

listen(
  target: 'window' | 'document' | 'body' | any,
  event: string,
  callback: (event: any) => boolean,
): () => void {
  (typeof ngDevMode === 'undefined' || ngDevMode) &&
    this.throwOnSyntheticProps &&
    checkNoSyntheticProp(event, 'listener');
  if (typeof target === 'string') {
    target = getDOM().getGlobalEventTarget(this.doc, target);
    if (!target) {
      throw new Error(`Unsupported event target ${target} for event ${event}`);
    }
  }

  return this.eventManager.addEventListener(
    target,
    event,
    this.decoratePreventDefault(callback),
  ) as VoidFunction;
}

Когда вы пишете (window:resize) или (keydown.esc), происходит вызов этого метода. В первом случае target будет строкой window, во втором это будет элемент, на котором вы слушаете событие. После получения реальной цели из строки обработка подписки передается в eventManager. Что же это за зверь? Это глобальный сервис Angular, в котором есть один важный для нас метод:

addEventListener(
  element: HTMLElement,
  eventName: string,
  handler: Function
): Function {
  const plugin = this._findPluginFor(eventName);
  return plugin.addEventListener(element, eventName, handler);
}

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

EventManagerPlugin

Плагины предоставляются через публичный токен EVENT_MANAGER_PLUGINS. Абстрактный класс, который плагины расширяют, стал доступен только в Angular 17, но его интерфейс довольно прост и пользоваться этой функцией можно было уже очень давно. Нам нужно реализовать всего два метода:

abstract supports(eventName: string): boolean;
abstract addEventListener(element: HTMLElement, eventName: string, handler: Function): Function;

В Angular из коробки всего три плагина:

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

  1. KeyEventsPlugin отвечает за события типа (keydown.esc). Он слушает все нажатия клавиш вне zone.js, чтобы не запускать проверку изменений без необходимости. Если клавиша совпадает с esc, плагин вызовет обработчик внутри zone.js.

  1. HammerGesturesPlugin включается вручную добавлением HammerModule. Он упрощает использование Hammer.js и его специальных жестов для работы с touch-устройствами.

Поскольку EVENT_MANAGER_PLUGINS — это мульти токен, ничто не мешает нам расширить этот набор своими плагинами, как делает HammerModule. Сделать это очень просто. Давайте создадим небольшой плагин, чтобы разобраться, как и что мы можем получить.

Пишем свои плагины

Как часто вы передаете $event в обработчик, только чтобы вызывать .stopPropagation()? Если вы много работаете с DOM, наверняка такое встречается у вас в коде там и тут. Здорово было бы, если бы мы могли декларативно описать (click.stop) и делегировать Angular заботу об этом, и это просто сделать с помощью плагинов. Взгляните:

export class StopEventPlugin extends EventManagerPlugin {
  supports(eventName: string): boolean {
    return eventName.split('.').includes('stop');
  }

  addEventListener(element: HTMLElement, eventName: string, handler: Function): Function {
    const wrapped = (event: Event) => {
      event.stopPropagation();
      handler(event);
    }

    return this.manager.addEventListener(element, eventName.replace('.stop', ''), wrapped)
  }
}

Подключить StopEventPlugin можно, добавив константу в провайдеры при бутстрапе нашего приложения:

export const STOP_PLUGIN = {
  provide: EVENT_MANAGER_PLUGINS,
  multi: true,
  useClass: StopEventPlugin,
}

Мы проверили, встречается ли .stop в имени события, и сообщили менеджеру, что наш плагин умеет обрабатывать это событие, если нам встретился такой модификатор. Затем в addEventListener сделали три действия:

  1. Выбросили модификатор из имени.

  2. Добавили небольшую обертку, которая вызывает .stopPropagation() и передает события дальше в оригинальный обработчик.

  3. Передали данные назад в EventManager, чтобы он подобрал правильный плагин для этого события.

Это очень аккуратный способ расширить имеющуюся логику в Angular. Мы почти не написали код, а значит, вряд ли внесем какие-то проблемы. Мы просто встроили наш обработчик в существующий механизм. Это значит, что мы сможем писать даже  (keydown.esc.stop), и все будет работать как надо. Очень полезно, если хотим закрыть всплывающее меню, но не закрывать диалоговое окно, в котором оно было открыто.

Особо приятная фишка WebStorm — поддержка Web Types. Эта возможность позволяет расширять подсказки и проверять типы ваших событий с помощью JSON-схемы. Такой файл, добавленный в package.json вашей зависимости, позволит писать вот так:

Подсказки в WebStorm
Подсказки в WebStorm

Обработчики будут знать, что тип события — MouseEvent. Список с картинки выше подсказывает, о чем мы будем говорить далее. Вы наверняка догадались, что таким же образом можно обработать и вызов .preventDefault(), но это только вершина айсберга. Такой простой API открывает множество возможностей. Некоторое время мы собирали полезные плагины под одной крышей, и у нас уже есть вот что.

@taiga-ui/event-plugins

Недавно мы выпустили новый мажорный релиз нашей библиотеки в рамках подготовки Taiga UI 4. В нем появилась пара новых возможностей помимо поднятия версии Angular и нескольких небольших правок. Кроме уже упомянутых .stop и .prevent имеются следующие плагины.

SilentEventPlugin. Помните, как встроенный KeyEventsPlugin выходил из zone.js для клавиш, которые нас не интересуют, чтобы не запускать проверку изменений? Пока мы все терпеливо ждем стабильного zoneless-релиза Angular, можем желать выполнять какие-то действия, не беспокоя проверку изменений. К примеру, если вы не хотите, чтобы по клику фокус уходил из поля ввода, этого можно добиться такой строчкой в host вашей директивы: '(mousedown.prevent.silent)': '0'. Он отменит поведение по умолчанию события mousedown, которое уводит фокус, и сделает это вне NgZone. 0 — это просто кратчайший возможный пустой обработчик. Большинство этих плагинов можно комбинировать таким образом.

SelfEventPlugin. Иногда вы хотите игнорировать всплывшие события. Обычно для этого мы проверяем, что currentTarget равен target. Этого легко достичь той же техникой обертки обработчика. С таким плагином можно писать (transitionend.self) и быть уверенным, что мы не запустим обработчик на какую-то анимацию из вложенных DOM-элементов.

OptionsEventPlugin. Один из самых важных плагинов в наборе, потому что он не просто улучшает DX, но и добавляет возможность, ранее недоступную в Angular. Вы знаете, что третьим аргументом можно передать объект параметров в addEventListener? С помощью него можно слушать события в фазе перехвата. 

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

ResizeEventPlugin (новый). Мы все знаем, что есть событие resize на window, но что, если мы хотим следить за изменениями размера произвольного DOM-элемента? Для этого есть инструмент под названием ResizeObserver. У нас есть open-source-инициатива Web APIs for Angular для использования различных Web API в Angular в комфортной форме. Но она все равно требует импорта директивы, создающей обзервер под капотом. В то же время плагин событий можно добавить один раз и затем писать (resize) на любом элементе в приложении, что, конечно, удобнее.

GlobalEventPlugin (новый). Помните первый кусок исходного кода Angular, который мы смотрели? Он обрабатывал глобальные цели событий: window, document и body. Но это не единственные глобальные объекты с интерфейсом EventTarget. К примеру, вы можете отслеживать появление экранной клавиатуры или поворот экрана через изменение размера visualViewport. С этим плагином можно писать (visualViewport>resize), аналогично встроенному механизму, только заменив : на >, и плагин попробует найти этот объект в globalThis.

Применим на практике

Посмотрим, как плагины могут нам пригодиться в простом приложении из двух компонентов. Представьте, что у нас есть поле ввода, растущее в длину, и кнопка отправки. Пока мы терпеливо ждем field-sizing, можем сами реализовать такое поведение небольшим трюком. Мы сделаем настоящее поле ввода невидимым и абсолютно позиционированным, а текст будем показывать под ним в span. Изучите это демо, и давайте разберем каждое использование новых плагинов:

Во-первых, у нас есть (mousedown.prevent.silent): "0" на кнопке отправки. Это нужно, чтобы, когда мы нажмем на кнопку на мобильном устройстве, фокус не покинул поле ввода. Иначе экранная клавиатура пропадет, разметка сместится и кнопка уйдет из-под пальца. В результате событие click даже не случится. Сейчас такое можно наблюдать в чате Airbnb в веб-версии с телефона.

Во-вторых, мы написали (click.capture.silent) внутри кнопки. Мы используем перехват события, чтобы отреагировать на клик первыми. Если наша кнопка в состоянии загрузки, мы остановим событие click. Это не даст отправить форму несколько раз, пока мы ожидаем ответа. Вы можете спросить: почему просто не задизейблить кнопку? У этого подхода есть недостатки доступности. Отключенная кнопка потеряет фокус и никак не сообщит скрин-ридеру что произошло. В нашем же случае он зачитает пользователю новое состояние кнопки. Мы также можем добавить aria-disabled, чтобы пользователь понимал, что кнопка пока ничего не делает.

Наконец, в нашем растущем поле ввода, когда мы напишем достаточно текста, он выйдет за рамки доступного размера. Нативный input начнет скроллиться. Нам нужно компенсировать это в нашем span. На выручку придет CSS-свойство text-indent, так как оно может быть отрицательным. Но событие scroll не всплывает, так что мы не можем просто слушать его в родителе. Это отличная ситуация для .capture-плагина, потому что он позволит нам отловить событие, пока оно спускается к цели.

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

Заключение

Обработка событий в Angular, как обычно, — это механизм, который можно расширять с помощью внедрения зависимостей, одного из мощнейших инструментов фреймворка. С появлением абстрактного EventManagerPlugin в публичных экспортах плагины стали полноправным API, и всем полезно будет ознакомиться с ним. @taiga-ui/event-plugins  хорошая отправная точка с несколькими готовыми к употреблению улучшениями для жизни. Вы также можете придумать плагины, упрощающие работу в вашем конкретном случае. 

Если у вас есть плагин, который может пригодиться всем, — это отличный повод начать контрибьютить в open-source. Если вы не уверены, как лучше его реализовать, не стесняйтесь завести нам feature request на GitHub, чтобы подключить коллективный разум и помочь развитию библиотеки идеей. Обсудить плагины, задать вопрос или внести предложение можно также в телеграм-сообществе Taiga UI.

Комментарии (0)