9. Пользовательские директивы
🎯 Кастомные директивы Angular
Заголовок раздела «🎯 Кастомные директивы Angular»Кастомные директивы — это способ расширить HTML собственным поведением. Если встроенных директив Angular недостаточно, создай свою: изменяй стили, добавляй обработчики событий, управляй DOM — всё инкапсулировано и переиспользуемо.
🗂️ Типы кастомных директив
Заголовок раздела «🗂️ Типы кастомных директив»| Тип | Описание | Пример использования |
|---|---|---|
| Атрибутная | Изменяет внешний вид/поведение элемента | appHighlight, appTooltip, appAutoResize |
| Структурная | Добавляет/удаляет DOM-элементы | *appUnless, *appRepeat, *appPermission |
🏗️ Анатомия атрибутной директивы
Заголовок раздела «🏗️ Анатомия атрибутной директивы»import { Directive, ElementRef, Renderer2, HostListener, HostBinding, Input, OnInit} from '@angular/core';
@Directive({ selector: '[appHighlight]', // Селектор в квадратных скобках = атрибут standalone: true, // Angular 14+})export class HighlightDirective implements OnInit { // @Input() получает значение атрибута @Input('appHighlight') color = 'yellow'; // <div appHighlight="red"> @Input() highlightOpacity = 0.3;
// ElementRef — прямой доступ к DOM-элементу // Renderer2 — безопасный способ изменять DOM (работает в SSR, Web Workers) constructor( private el: ElementRef<HTMLElement>, private renderer: Renderer2 ) {}
ngOnInit(): void { // Рекомендуется через Renderer2, а не прямой доступ el.nativeElement.style this.setBackground(this.color, this.highlightOpacity); }
// @HostListener — слушает события на хост-элементе @HostListener('mouseenter') onMouseEnter(): void { this.setBackground(this.color, this.highlightOpacity); }
@HostListener('mouseleave') onMouseLeave(): void { this.setBackground('transparent', 0); }
// @HostBinding — привязывает свойства/атрибуты хост-элемента @HostBinding('style.cursor') cursor = 'pointer'; @HostBinding('attr.title') title = 'Наведи на меня!';
private setBackground(color: string, opacity: number): void { this.renderer.setStyle( this.el.nativeElement, 'background-color', \`\${color}\` ); this.renderer.setStyle( this.el.nativeElement, 'opacity', String(opacity + 0.7) ); }}<!-- Использование --><p appHighlight>Подсветится жёлтым при наведении</p><p appHighlight="red">Подсветится красным</p><p [appHighlight]="selectedColor" [highlightOpacity]="0.5">Динамический цвет</p>🖱️ @HostListener — слушаем события элемента
Заголовок раздела «🖱️ @HostListener — слушаем события элемента»@Directive({ selector: '[appClickOutside]', standalone: true,})export class ClickOutsideDirective { @Output() clickOutside = new EventEmitter<void>();
// Слушаем клик на document (не на элементе) @HostListener('document:click', ['$event']) onDocumentClick(event: MouseEvent): void { const target = event.target as Node; const isInside = this.el.nativeElement.contains(target);
if (!isInside) { this.clickOutside.emit(); } }
// Слушаем клавиатуру @HostListener('document:keydown.escape') onEscape(): void { this.clickOutside.emit(); }
// Слушаем события на самом элементе @HostListener('focus') onFocus(): void { this.renderer.addClass(this.el.nativeElement, 'focused'); }
@HostListener('blur') onBlur(): void { this.renderer.removeClass(this.el.nativeElement, 'focused'); }
constructor( private el: ElementRef, private renderer: Renderer2 ) {}}<!-- Компонент dropdown закрывается при клике снаружи --><div appClickOutside (clickOutside)="isOpen = false"> <button (click)="isOpen = !isOpen">Открыть</button> <ul *ngIf="isOpen"> <li>Пункт 1</li> <li>Пункт 2</li> </ul></div>🔗 @HostBinding — привязываем свойства хост-элемента
Заголовок раздела «🔗 @HostBinding — привязываем свойства хост-элемента»@Directive({ selector: '[appButtonLoading]', standalone: true,})export class ButtonLoadingDirective { @Input() set appButtonLoading(loading: boolean) { this.isLoading = loading; }
isLoading = false;
// Напрямую управляем атрибутами/классами/стилями хост-элемента @HostBinding('disabled') get isDisabled(): boolean { return this.isLoading; }
@HostBinding('class.loading') get hasLoadingClass(): boolean { return this.isLoading; }
@HostBinding('attr.aria-busy') get ariaBusy(): string { return this.isLoading ? 'true' : 'false'; }
@HostBinding('style.opacity') get opacity(): number { return this.isLoading ? 0.7 : 1; }
@HostBinding('style.cursor') get cursor(): string { return this.isLoading ? 'wait' : 'pointer'; }}<button [appButtonLoading]="isSubmitting" (click)="submit()"> {{ isSubmitting ? 'Загрузка...' : 'Отправить' }}</button>🔧 ElementRef и Renderer2
Заголовок раздела «🔧 ElementRef и Renderer2»@Directive({ selector: '[appAutoResize]', standalone: true,})export class AutoResizeDirective implements OnInit { constructor( private el: ElementRef<HTMLTextAreaElement>, private renderer: Renderer2 ) {}
ngOnInit(): void { // ElementRef.nativeElement — прямой доступ к DOM-узлу // Используй только для чтения! Для записи — Renderer2 this.resize(); }
@HostListener('input') onInput(): void { this.resize(); }
private resize(): void { const textarea = this.el.nativeElement;
// Renderer2 методы — работают в SSR и Web Workers this.renderer.setStyle(textarea, 'height', 'auto'); this.renderer.setStyle(textarea, 'height', textarea.scrollHeight + 'px'); this.renderer.setStyle(textarea, 'overflow', 'hidden'); }}
// Renderer2 API — основные методы:// renderer.setStyle(el, 'color', 'red') — установить CSS// renderer.removeStyle(el, 'color') — удалить CSS// renderer.addClass(el, 'active') — добавить класс// renderer.removeClass(el, 'active') — удалить класс// renderer.setAttribute(el, 'aria-label', '...')— установить атрибут// renderer.removeAttribute(el, 'disabled') — удалить атрибут// renderer.appendChild(parent, child) — добавить дочерний элемент// renderer.createElement('div') — создать элемент// renderer.listen(el, 'click', handler) — добавить слушатель событий🏗️ Структурная директива: *appUnless
Заголовок раздела «🏗️ Структурная директива: *appUnless»Структурные директивы работают с TemplateRef и ViewContainerRef:
import { Directive, Input, TemplateRef, ViewContainerRef} from '@angular/core';
@Directive({ selector: '[appUnless]', standalone: true,})export class UnlessDirective { private hasView = false;
// TemplateRef — ссылка на <ng-template> (то что прячется за *) // ViewContainerRef — место в DOM куда вставляется шаблон constructor( private templateRef: TemplateRef<unknown>, private viewContainer: ViewContainerRef ) {}
@Input() set appUnless(condition: boolean) { if (!condition && !this.hasView) { // Создаём View из шаблона и вставляем в DOM this.viewContainer.createEmbeddedView(this.templateRef); this.hasView = true; } else if (condition && this.hasView) { // Очищаем ViewContainer — удаляем из DOM this.viewContainer.clear(); this.hasView = false; } }}<!-- *appUnless — противоположность *ngIf --><div *appUnless="isLoggedIn"> Войди, чтобы увидеть контент</div>
<!-- Angular разворачивает * в ng-template: --><ng-template [appUnless]="isLoggedIn"> <div>Войди, чтобы увидеть контент</div></ng-template>🔄 Структурная директива: *appRepeat
Заголовок раздела «🔄 Структурная директива: *appRepeat»import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';
interface RepeatContext { $implicit: number; // Текущий индекс (let i = implicit) index: number; count: number; first: boolean; last: boolean; even: boolean; odd: boolean;}
@Directive({ selector: '[appRepeat]', standalone: true,})export class RepeatDirective { @Input() set appRepeat(count: number) { this.viewContainer.clear();
for (let i = 0; i < count; i++) { const context: RepeatContext = { $implicit: i, index: i, count, first: i === 0, last: i === count - 1, even: i % 2 === 0, odd: i % 2 !== 0, };
this.viewContainer.createEmbeddedView(this.templateRef, context); } }
constructor( private templateRef: TemplateRef<RepeatContext>, private viewContainer: ViewContainerRef ) {}}<!-- Повторить шаблон N раз — аналог range() в Python --><div *appRepeat="5; let i; let first=first; let last=last"> Элемент {{ i + 1 }} <span *ngIf="first">← первый</span> <span *ngIf="last">← последний</span></div>
<!-- Звёздочки рейтинга через appRepeat --><span *appRepeat="rating; let i">★</span><span *appRepeat="5 - rating; let i">☆</span>🔐 Директива: *appPermission (проверка прав)
Заголовок раздела «🔐 Директива: *appPermission (проверка прав)»import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';import { AuthService } from './auth.service';
@Directive({ selector: '[appPermission]', standalone: true,})export class PermissionDirective { @Input() set appPermission(permission: string | string[]) { const permissions = Array.isArray(permission) ? permission : [permission]; const hasAccess = permissions.some(p => this.auth.hasPermission(p));
this.viewContainer.clear();
if (hasAccess) { this.viewContainer.createEmbeddedView(this.templateRef); } }
constructor( private templateRef: TemplateRef<unknown>, private viewContainer: ViewContainerRef, private auth: AuthService ) {}}<!-- Показывать только для admins --><button *appPermission="'admin'">Удалить</button>
<!-- Для нескольких ролей --><div *appPermission="['admin', 'editor']"> Редактировать</div>🎨 Практический пример: Tooltip директива
Заголовок раздела «🎨 Практический пример: Tooltip директива»import { Directive, Input, HostListener, ElementRef, Renderer2, OnDestroy} from '@angular/core';
@Directive({ selector: '[appTooltip]', standalone: true,})export class TooltipDirective implements OnDestroy { @Input('appTooltip') text = ''; @Input() tooltipPosition: 'top' | 'bottom' | 'left' | 'right' = 'top';
private tooltipEl: HTMLElement | null = null;
constructor( private el: ElementRef<HTMLElement>, private renderer: Renderer2 ) {}
@HostListener('mouseenter') show(): void { if (!this.text) return;
this.tooltipEl = this.renderer.createElement('div'); const textNode = this.renderer.createText(this.text); this.renderer.appendChild(this.tooltipEl, textNode);
this.renderer.setStyle(this.tooltipEl, 'position', 'fixed'); this.renderer.setStyle(this.tooltipEl, 'background', '#1e293b'); this.renderer.setStyle(this.tooltipEl, 'color', 'white'); this.renderer.setStyle(this.tooltipEl, 'padding', '6px 10px'); this.renderer.setStyle(this.tooltipEl, 'border-radius', '6px'); this.renderer.setStyle(this.tooltipEl, 'font-size', '12px'); this.renderer.setStyle(this.tooltipEl, 'z-index', '9999'); this.renderer.setStyle(this.tooltipEl, 'pointer-events', 'none');
this.renderer.appendChild(document.body, this.tooltipEl);
const rect = this.el.nativeElement.getBoundingClientRect(); const tipRect = this.tooltipEl.getBoundingClientRect();
const positions = { top: { top: rect.top - tipRect.height - 8, left: rect.left + rect.width / 2 - tipRect.width / 2 }, bottom: { top: rect.bottom + 8, left: rect.left + rect.width / 2 - tipRect.width / 2 }, left: { top: rect.top + rect.height / 2 - tipRect.height / 2, left: rect.left - tipRect.width - 8 }, right: { top: rect.top + rect.height / 2 - tipRect.height / 2, left: rect.right + 8 }, };
const pos = positions[this.tooltipPosition]; this.renderer.setStyle(this.tooltipEl, 'top', pos.top + 'px'); this.renderer.setStyle(this.tooltipEl, 'left', pos.left + 'px'); }
@HostListener('mouseleave') hide(): void { if (this.tooltipEl) { this.renderer.removeChild(document.body, this.tooltipEl); this.tooltipEl = null; } }
ngOnDestroy(): void { this.hide(); }}<button appTooltip="Нажми чтобы сохранить" tooltipPosition="top"> 💾 Сохранить</button>
<span [appTooltip]="helpText" tooltipPosition="right">ℹ️</span>📦 Standalone директивы (Angular 14+)
Заголовок раздела «📦 Standalone директивы (Angular 14+)»// Объявление standalone директивы@Directive({ selector: '[appHighlight]', standalone: true, // Без NgModule!})export class HighlightDirective { /* ... */ }
// Использование в standalone компоненте@Component({ standalone: true, imports: [HighlightDirective], // ← Просто импортируем template: `<p appHighlight="blue">Текст</p>`})export class ArticleComponent {}
// Удобный barrel export// directives/index.tsexport * from './highlight.directive';export * from './tooltip.directive';export * from './unless.directive';export * from './repeat.directive';export * from './click-outside.directive';📊 ElementRef vs Renderer2 — что использовать?
Заголовок раздела «📊 ElementRef vs Renderer2 — что использовать?»ElementRef.nativeElement | Renderer2 | |
|---|---|---|
| Прямой доступ к DOM | ✅ | ❌ (абстракция) |
| Работает в SSR | ⚠️ Нет | ✅ Да |
| Работает в Web Workers | ❌ Нет | ✅ Да |
| Безопасность от XSS | ⚠️ Ручная | ✅ Встроенная |
| Рекомендуется для | Только чтение | Изменение DOM |