Що таке директиви та які їх типи існують в Angular?
Angular директива - це клас з декоратором @Directive, який Angular використовує для розширення або зміни HTML-елементів під час компіляції шаблону.
Теорія
TL;DR
- Директиви - це HTML-плагіни: чіпляєш до будь-якого елемента і отримуєш нові стилі, події або зміни DOM без переписування самого елемента
- 3 типи: Атрибутні (декорують існуючі елементи), Структурні (додають або видаляють вузли DOM), Компоненти (директиви з власним шаблоном)
- Атрибутний селектор:
[appHighlight]. Структурний використовує префікс*:*ngIf. Компонент - тег:<app-card> - Потрібна логіка для DOM без UI? Директива. Потрібен блок UI з розміткою? Компонент.
Швидкий приклад
Класична appHighlight атрибутна директива показує весь патерн приблизно в 12 рядках:
<div appHighlight>Наведи мишку</div>import { Directive, ElementRef, HostListener } from '@angular/core';
@Directive({
selector: '[appHighlight]'
})
export class HighlightDirective {
constructor(private el: ElementRef) {}
@HostListener('mouseenter') onEnter() {
this.el.nativeElement.style.backgroundColor = 'yellow';
}
@HostListener('mouseleave') onLeave() {
this.el.nativeElement.style.backgroundColor = null;
}
}Angular знаходить атрибут [appHighlight], впроваджує ElementRef, і @HostListener підключає події миші. Шаблон не потрібен.
Ключова різниця
Атрибутні директиви декорують існуючий елемент: змінюють стиль, клас або поведінку, але структуру DOM не чіпають. Структурні директиви переписують DOM. *ngIf повністю видаляє елемент коли умова хибна. *ngFor клонує шаблонний вузол для кожного елемента масиву. Компоненти - це теж директиви, тільки з доданим шаблоном і інкапсуляцією стилів. Такий поділ дозволяє Angular пропустити компіляцію шаблону для атрибутних директив, тоді як структурні кожну зміну пропускають через ViewContainerRef.
Коли використовувати
- Зміна стилів або класів елемента: атрибутна директива (
ngClass,ngStyle, кастомна стилізація валідатора) - Умовний рендеринг або ітерація: структурна (
*ngIf,*ngFor,*ngSwitch) - Блок UI з власною розміткою: компонент, не директива
- Обробники подій на хост-елементі: атрибутна з
@HostListenerабо@HostBinding - Логіка без жодної залежності від DOM: сервіс, не директива
Типи директив
| Тип | Синтаксис селектора | Вплив на DOM | Ключові Angular API | Вбудовані приклади |
|---|---|---|---|---|
| Атрибутна | [appHighlight] | Відсутній (декорує хост-елемент) | ElementRef, HostListener, HostBinding | ngClass, ngStyle |
| Структурна | *appUnless, *ngIf | Додає або видаляє елементи | ViewContainerRef, TemplateRef | *ngIf, *ngFor, *ngSwitch |
| Компонент | <app-user-card> | Рендерить власний шаблон | @Component (розширює @Directive) | Будь-який кастомний UI-блок |
Як Angular обробляє директиви
Під час AOT-компіляції Angular сканує шаблони, знаходить відповідності між селекторами і метаданими @Directive, потім генерує фабричні функції. В рантаймі фреймворк створює екземпляри директив через dependency injection і запускає lifecycle hooks: спочатку ngOnInit, потім ngAfterViewInit. Для структурних директив будь-яка зміна стану викликає createEmbeddedView() або clear() на ViewContainerRef. Саме так *ngIf видаляє і вставляє вузли DOM без повного перерендеру. Атрибутні директиви цей крок пропускають, тому вони дешевші у великих списках.
Типові помилки
1. Прямий доступ до nativeElement замість Renderer2
// Ламає SSR (Angular Universal)
constructor(el: ElementRef) {
el.nativeElement.style.color = 'red';
}
// Правильно: Renderer2 працює і в браузері, і на сервері
constructor(private el: ElementRef, private renderer: Renderer2) {
renderer.setStyle(el.nativeElement, 'color', 'red');
}Прямі зміни через nativeElement спричиняють помилки гідратації в Angular Universal. Renderer2 абстрагує DOM так, щоб Angular правильно обробляв обидва середовища.
2. Забутий TemplateRef і ViewContainerRef у структурній директиві
// Шаблон ніколи не відрендериться
@Directive({ selector: '[appIf]' })
export class AppIfDirective {
@Input() appIf: boolean; // Angular немає куди вставити шаблон
}
// Правильно
export class AppIfDirective {
constructor(
private templateRef: TemplateRef<any>,
private viewContainer: ViewContainerRef
) {}
@Input() set appIf(condition: boolean) {
condition
? this.viewContainer.createEmbeddedView(this.templateRef)
: this.viewContainer.clear();
}
}Це найпоширеніший баг «директива не рендериться». Синтаксис * - це лише синтаксичний цукор. Angular все одно потребує ViewContainerRef, щоб фізично вставити шаблон у DOM.
3. Кілька структурних директив на одному елементі
<!-- Angular застосовує тільки першу структурну директиву, друга ігнорується -->
<div *ngFor="let item of items" *ngIf="item.active">{{ item.name }}</div>
<!-- Правильно: вкладай -->
<div *ngFor="let item of items">
<span *ngIf="item.active">{{ item.name }}</span>
</div>4. Відсутній $event у @HostListener
@HostListener('click') onClick() {} // Немає доступу до координат або target
@HostListener('click', ['$event']) onClick(event: MouseEvent) {
console.log(event.target); // Працює як очікується
}Де зустрічається
- Angular Material:
matTooltipдодає підказки до будь-якого елемента через один атрибут - NG Bootstrap:
ngbTooltipтак само підключається до Bootstrap CSS класів - PrimeNG:
pTooltipу enterprise таблицях з даними - Nx workspaces: кастомна структурна
*nxLoadingдля lazy-завантаження модулів - Reactive Forms: кастомні атрибутні валідатори інтегруються безпосередньо з системою контролів
NgForm
Питання на співбесіді
Q: Яка різниця між @Directive і @Component?
A: @Component розширює @Directive і додає шаблон, стилі та інкапсуляцію вигляду. Компонент - це директива, яка знає як відрендерити себе. Звичайний @Directive не має шаблону і не має пов'язаного view.
Q: Як *ngFor використовує trackBy і навіщо це потрібно?
A: trackBy приймає функцію, що повертає унікальний ідентифікатор для кожного елемента. Angular повторно використовує існуючі DOM-вузли при зміні масиву замість того, щоб знищувати і відтворювати кожен елемент. Без trackBy будь-яка зміна масиву (сортування, фільтрація, push) повністю перебудовує список у DOM.
Q: Побудуй кастомну структурну директиву. Що потрібно?
A: Впровадь TemplateRef<any> і ViewContainerRef у конструктор. Додай сеттер через @Input. Коли умова true - виклич this.viewContainer.createEmbeddedView(this.templateRef). Коли false - this.viewContainer.clear(). Використовуй у шаблоні як *appDirectiveName="expression".
Q: Навіщо @HostBinding, якщо можна написати [style.color] прямо в шаблоні?
A: @HostBinding живе всередині класу директиви, правильно працює в ланцюгах успадкування і сумісний з SSR. Прив'язки в шаблоні вимагають, щоб ти контролював цей шаблон, а в бібліотеці директив це не завжди так.
Q: Який перформанс-вплив директив у великих списках?
A: Структурні директиви відтворюють view при кожному проході change detection, якщо батьківський компонент використовує стратегію за замовчуванням. Атрибутні легші, але колбеки @HostListener все одно спрацьовують на кожну відповідну подію. Рішення: ChangeDetectionStrategy.OnPush на батьківському компоненті і trackBy у кожному *ngFor. Signals в Angular 17 скорочують зайві перевірки, реагуючи тільки на конкретні зміни стану.
Приклади
Базовий: SSR-сумісне підсвічування при наведенні
<p appHighlight>Наведи мишку для підсвічування</p>import { Directive, ElementRef, HostListener, Renderer2 } from '@angular/core';
@Directive({
selector: '[appHighlight]'
})
export class HighlightDirective {
constructor(private el: ElementRef, private renderer: Renderer2) {}
@HostListener('mouseenter') onEnter() {
// Renderer2 зберігає сумісність з Angular Universal
this.renderer.setStyle(this.el.nativeElement, 'backgroundColor', 'yellow');
}
@HostListener('mouseleave') onLeave() {
this.renderer.removeStyle(this.el.nativeElement, 'backgroundColor');
}
}Renderer2 замість прямого доступу до nativeElement - це те, що відрізняє швидкий прототип від production-директиви. Той самий атрибут працює на будь-якому елементі в будь-якому шаблоні.
Середній: Кастомна структурна директива
import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';
@Directive({
selector: '[appUnless]'
})
export class UnlessDirective {
private hasView = false;
constructor(
private templateRef: TemplateRef<any>,
private viewContainer: ViewContainerRef
) {}
@Input() set appUnless(condition: boolean) {
if (!condition && !this.hasView) {
this.viewContainer.createEmbeddedView(this.templateRef);
this.hasView = true;
} else if (condition && this.hasView) {
this.viewContainer.clear();
this.hasView = false;
}
}
}<div *appUnless="isLoading">Контент готовий</div>Прапорець hasView запобігає дублюванню рендерів при швидких змінах вхідного значення. Це саме той патерн, що лежить в основі *ngIf. Я бачив цю задачу на senior Angular співбесідах, і саме наявність hasView зазвичай відрізняє хорошу відповідь від відмінної.
Просунутий: Структурна директива з async-вхідним значенням
import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';
@Directive({
selector: '[appUnlessLoading]'
})
export class UnlessLoadingDirective {
private hasView = false;
@Input() set appUnlessLoading(loading: Promise<boolean> | boolean) {
if (typeof loading === 'boolean') {
this.updateView(loading);
} else {
// Асинхронне розв'язання: Promise повертає фінальний стан завантаження
loading.then(result => this.updateView(result));
}
}
constructor(
private templateRef: TemplateRef<any>,
private viewContainer: ViewContainerRef
) {}
private updateView(loading: boolean) {
if (!loading && !this.hasView) {
this.viewContainer.createEmbeddedView(this.templateRef);
this.hasView = true;
} else if (loading && this.hasView) {
this.viewContainer.clear();
this.hasView = false;
}
}
}<div *appUnlessLoading="fetchingUsers$ | async">Користувачів завантажено</div>Головна пастка: без прапорця hasView швидкі async-зміни викликають createEmbeddedView кілька разів і залишають дублікати DOM-вузлів у view. clear() видаляє всі view перед створенням нового, але перевірка hasView дозволяє уникнути зайвої роботи з DOM взагалі.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.