14 — Host bindings и host directives на пальцах

Цель: понять, как директива/компонент управляет своим собственным тегом (host binding / host listener) и как одна директива может «приклеить» к этому тегу другие директивы без обёрток в шаблоне (host directives). Точные file:line — в notes/v21/.

Что такое «хост»

Хост (host) — это сам элемент, на котором сидит директива/компонент.

<button appHighlight>Жми</button>
<!--  ↑ этот <button> — ХОСТ для директивы appHighlight -->

<app-card></app-card>
<!--  ↑ этот <app-card> — ХОСТ для компонента Card -->

Шаблон компонента описывает то, что внутри. А host binding описывает сам внешний тег. Это разные вещи: внутри компонента ты не видишь свой <app-card> — для управления им и нужны host bindings.

Host binding — «привяжи к своему тегу»

host: {} в метаданных (или старые @HostBinding/@HostListener) позволяют привязать к хост-элементу классы, атрибуты, стили и слушатели событий:

@Directive({
  selector: '[appHighlight]',
  host: {
    '[class.active]': 'isOn',          // класс по условию
    '[style.color]': 'color',          // стиль
    '[attr.aria-pressed]': 'isOn',     // атрибут
    '(click)': 'toggle()',             // слушатель события (host listener)
    'role': 'button',                  // статический атрибут
  },
})
export class HighlightDirective {
  isOn = false;
  color = 'red';
  toggle() { this.isOn = !this.isOn; }
}

Аналогия: обычный биндинг в шаблоне (<div [class.x]="...">) направлен внутрь, на чужие теги. Host binding направлен на самого себя — на тег, где висит директива.

Как host bindings работают под капотом

Помни про два режима из 01-kompilyaciya (create rf&1 / update rf&2). Host bindings компилируются в отдельную функцию hostBindings, и Angular складывает их в список операцийhostBindingOpCodes в TView (см. 06-view-lview):

TView.hostBindingOpCodes = [
  ~элемент,  индекс директивы,  её hostBindings-функция,
  ...
]

На каждом change detection Angular проходит этот список (processHostBindingOpCodes), выбирает нужный хост-элемент и вызывает hostBindings(rf&2, директива) — та обновляет класс/стиль/атрибут. То есть host bindings — часть того же update-прохода, просто выполняются в контексте хост-элемента, а не внутренностей шаблона.

Host listener ((click)) регистрируется один раз на create — той же инструкцией listener, что и обычные события в шаблоне, только цель — хост-элемент.

Host directives — «приклей чужую директиву к себе»

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

Без host directives:

<!-- пользователь ВЫНУЖДЕН добавлять cdkDrag руками каждый раз -->
<app-card cdkDrag></app-card>

С host directives — компонент сам «приносит» директиву на свой хост:

@Component({
  selector: 'app-card',
  hostDirectives: [CdkDrag],     // приклеили CdkDrag к хосту card
})
export class Card {}

// теперь просто <app-card> уже перетаскиваемый — cdkDrag писать не нужно

Это композиция поведения без обёрток в DOM: ни лишних тегов, ни директив в разметке пользователя. Поведение «вшито» в компонент.

Проброс inputs/outputs с алиасами

По умолчанию входы/выходы приклеенной директивы скрыты (инкапсуляция). Если хочешь открыть их наружу — перечисли явно, можно с переименованием:

@Component({
  selector: 'app-menu',
  hostDirectives: [{
    directive: CdkMenuTrigger,
    inputs: ['cdkMenuTriggerFor: menuFor'],   // наружу как menuFor
    outputs: ['cdkMenuOpened: opened'],        // наружу как opened
  }],
})
export class Menu {}

// использование: <app-menu [menuFor]="tpl" (opened)="...">

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

Порядок применения (важный нюанс)

Host directives применяются раньше самой директивы/компонента-хозяина:

1. host directives (в порядке объявления)   ← применяются ПЕРВЫМИ
2. сам компонент/директива

Зачем такой порядок: чтобы хозяин мог переопределить host bindings приклеенных директив (его значения «победят», т.к. применятся последними). Для DI это тоже значит, что host directives уже доступны к моменту создания хозяина.

Ограничения: приклеивать можно только standalone-директивы (не компоненты), и состав hostDirectives фиксирован — подменять директивы по условию в рантайме нельзя. Допускается лишь форма-функция hostDirectives: () => [Dir] (для forwardRef / циклов), но и она задаёт фиксированный список, известный на компиляции.

Где это реально используется

  • Angular CDK / Material: CdkMenuTrigger, CdkDrag и т.п. часто подмешивают через host directives, чтобы скрыть «кухню».
  • Свои «миксины поведения»: директива-аналитика, директива-доступность (a11y роли), директива-тема — приклеиваешь к компонентам, не засоряя их шаблоны.

Карта

HOST BINDING — управляю своим тегом
  host: { '[class.x]':'expr', '(click)':'fn()' }
     ↓ компилируется в hostBindings-функцию
  TView.hostBindingOpCodes → processHostBindingOpCodes (каждый update, rf&2)
     ↓
  обновляются класс/стиль/атрибут хост-элемента

HOST DIRECTIVE — приклеиваю чужую директиву к своему тегу
  hostDirectives: [Dir, { directive: Dir2, inputs:[...], outputs:[...] }]
     ↓ применяются ПЕРЕД хозяином (чтобы он мог переопределить)
  поведение Dir работает на хосте без тегов/директив в разметке

Итог

  • Host binding = биндинг на собственный тег директивы/компонента (host: {}): классы, стили, атрибуты, события. Выполняется в общем update-проходе через hostBindingOpCodes.
  • Host directive = «приклеить» standalone-директиву к своему хосту без обёрток в шаблоне; inputs/outputs можно пробросить наружу с алиасами.
  • Host directives применяются до хозяина, поэтому хозяин может их переопределить.

Дальше → 15-zoneless-scheduler: что именно будит Angular и планирует один tick.