52. Динамические компоненты
55. Динамические компоненты 🎭
Заголовок раздела «55. Динамические компоненты 🎭»Привет! Яша здесь. Динамические компоненты — мощный инструмент Angular для создания диалогов, тостов, вкладок и любого UI, который создаётся не в шаблоне, а в коде. Разберём всё от основ до production-паттернов 🚀
Зачем нужны динамические компоненты
Заголовок раздела «Зачем нужны динамические компоненты»Статический шаблон <app-toast></app-toast> не подходит когда:
- Нужно показать любое количество тостов
- Компонент должен создаваться в произвольный момент
- Родитель не знает заранее какой компонент показать
- Нужен портал — рендер в другом месте DOM
Примеры: диалоги, уведомления, динамические формы, plug-in системы.
ViewContainerRef: контейнер для компонентов
Заголовок раздела «ViewContainerRef: контейнер для компонентов»import { Component, ViewChild, ViewContainerRef } from '@angular/core';import { AlertComponent } from './alert/alert.component';
@Component({ selector: 'app-root', template: ` <button (click)="addAlert()">Добавить алерт</button>
<!-- Контейнер — здесь будут рендериться динамические компоненты --> <ng-container #alertContainer></ng-container> `})export class AppComponent { // ViewContainerRef привязан к ng-container @ViewChild('alertContainer', { read: ViewContainerRef }) container!: ViewContainerRef;
addAlert(): void { // createComponent — современный API (Angular 13+) const componentRef = this.container.createComponent(AlertComponent);
// Передать @Input через instance componentRef.instance.message = 'Операция выполнена успешно!'; componentRef.instance.type = 'success';
// Подписаться на @Output componentRef.instance.closed.subscribe(() => { componentRef.destroy(); // удалить компонент }); }}Передача @Input через setInput (Angular 14+)
Заголовок раздела «Передача @Input через setInput (Angular 14+)»// ✅ Современный способ — setInput запускает Change Detectionconst ref = container.createComponent(ToastComponent);ref.setInput('message', 'Сохранено!'); // типобезопасноref.setInput('duration', 3000);ref.setInput('type', 'success');
// ✅ Альтернатива — напрямую через instanceref.instance.message = 'Сохранено!';ref.changeDetectorRef.detectChanges(); // нужно вызвать вручную!
// ❌ Устаревший способ (Angular < 13)// ComponentFactoryResolver был удалён в Angular 15const factory = resolver.resolveComponentFactory(ToastComponent); // deprecated!const ref = container.createComponent(factory);Dialog Service: production-паттерн
Заголовок раздела «Dialog Service: production-паттерн»import { Injectable, ApplicationRef, createComponent, EnvironmentInjector, ComponentRef, Type} from '@angular/core';
export interface DialogRef<T> { componentRef: ComponentRef<T>; close: () => void; afterClosed: Promise<unknown>;}
@Injectable({ providedIn: 'root' })export class DialogService { private dialogs: ComponentRef<unknown>[] = [];
constructor( private appRef: ApplicationRef, private injector: EnvironmentInjector, ) {}
open<T>(component: Type<T>, inputs: Partial<T> = {}): DialogRef<T> { // Создаём компонент вне дерева Angular const componentRef = createComponent(component, { environmentInjector: this.injector, });
// Передаём входные данные for (const [key, value] of Object.entries(inputs)) { componentRef.setInput(key, value); }
// Добавляем в ApplicationRef для CD this.appRef.attachView(componentRef.hostView);
// Монтируем в DOM const domElement = (componentRef.hostView as any).rootNodes[0]; document.body.appendChild(domElement);
this.dialogs.push(componentRef);
let resolveClose: (value: unknown) => void; const afterClosed = new Promise(resolve => resolveClose = resolve);
const close = () => { this.appRef.detachView(componentRef.hostView); domElement.remove(); componentRef.destroy(); this.dialogs = this.dialogs.filter(d => d !== componentRef); resolveClose(undefined); };
return { componentRef, close, afterClosed }; }
closeAll(): void { this.dialogs.forEach(ref => { this.appRef.detachView(ref.hostView); (ref.hostView as any).rootNodes[0]?.remove(); ref.destroy(); }); this.dialogs = []; }}@Component({ standalone: true, selector: 'app-confirm-dialog', template: ` <div class="overlay"> <div class="dialog"> <h3>{{ title }}</h3> <p>{{ message }}</p> <div class="actions"> <button (click)="onCancel()">Отмена</button> <button class="primary" (click)="onConfirm()">Подтвердить</button> </div> </div> </div> `, changeDetection: ChangeDetectionStrategy.OnPush,})export class ConfirmDialogComponent { @Input() title = 'Подтверждение'; @Input() message = 'Вы уверены?'; @Output() confirmed = new EventEmitter<boolean>();
onConfirm(): void { this.confirmed.emit(true); } onCancel(): void { this.confirmed.emit(false); }}
// Использование в компоненте@Component({...})export class UserListComponent { constructor(private dialog: DialogService) {}
async deleteUser(user: User): Promise<void> { const ref = this.dialog.open(ConfirmDialogComponent, { title: 'Удалить пользователя?', message: \`Удалить \${user.name}? Это действие необратимо.\`, });
const confirmed = await new Promise<boolean>(resolve => { ref.componentRef.instance.confirmed.subscribe(resolve); });
if (confirmed) { await this.userService.delete(user.id); ref.close(); } else { ref.close(); } }}Динамический компонент лоадер: plug-in система
Заголовок раздела «Динамический компонент лоадер: plug-in система»// component-registry.ts — регистр компонентовimport { Type } from '@angular/core';
export interface WidgetConfig { type: string; component: Type<unknown>; inputs?: Record<string, unknown>;}
@Injectable({ providedIn: 'root' })export class ComponentRegistry { private registry = new Map<string, Type<unknown>>();
register(type: string, component: Type<unknown>): void { this.registry.set(type, component); }
get(type: string): Type<unknown> | undefined { return this.registry.get(type); }}
// dynamic-widget-host.component.ts@Component({ selector: 'app-widget-host', template: `<ng-container #host></ng-container>`, standalone: true,})export class DynamicWidgetHostComponent implements OnChanges { @Input() widgetType!: string; @Input() widgetInputs: Record<string, unknown> = {};
@ViewChild('host', { read: ViewContainerRef }) host!: ViewContainerRef;
constructor(private registry: ComponentRegistry) {}
ngOnChanges(changes: SimpleChanges): void { if (changes['widgetType']) { this.loadWidget(); } }
private loadWidget(): void { this.host.clear(); const component = this.registry.get(this.widgetType); if (!component) { console.warn(\`Widget "\${this.widgetType}" not found in registry\`); return; }
const ref = this.host.createComponent(component); for (const [key, value] of Object.entries(this.widgetInputs)) { ref.setInput(key, value); } }}Динамические формы
Заголовок раздела «Динамические формы»export interface FieldConfig { name: string; type: 'text' | 'select' | 'date' | 'rating' | 'rich-text'; label: string; options?: { value: string; label: string }[];}
@Component({ selector: 'app-dynamic-form', template: ` <form [formGroup]="form"> @for (field of fields; track field.name) { <div class="field"> <label>{{ field.label }}</label> <ng-container [dynamicField]="field" [formGroup]="form"> </ng-container> </div> } </form> `})export class DynamicFormComponent implements OnInit { @Input() fields: FieldConfig[] = []; form!: FormGroup;
constructor( private fb: FormBuilder, private vcr: ViewContainerRef, private registry: ComponentRegistry, ) {}
ngOnInit(): void { const controls: Record<string, FormControl> = {}; this.fields.forEach(f => controls[f.name] = new FormControl('')); this.form = this.fb.group(controls); }}ngTemplateOutlet: динамические шаблоны
Заголовок раздела «ngTemplateOutlet: динамические шаблоны»@Component({ template: ` <!-- Шаблоны как параметры --> <app-list [itemTemplate]="userTpl" [items]="users"></app-list>
<ng-template #userTpl let-user> <div class="user-row">{{ user.name }} — {{ user.email }}</div> </ng-template> `})export class ParentComponent { users = [...];}
@Component({ selector: 'app-list', template: ` @for (item of items; track item.id) { <ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item }"> </ng-container> } `})export class ListComponent { @Input() items: unknown[] = []; @Input() itemTemplate!: TemplateRef<unknown>;}Порталы через CDK
Заголовок раздела «Порталы через CDK»import { Portal, ComponentPortal, DomPortalOutlet } from '@angular/cdk/portal';
@Injectable({ providedIn: 'root' })export class ToastService { private outlet: DomPortalOutlet;
constructor( private appRef: ApplicationRef, private injector: EnvironmentInjector, ) { // Создаём контейнер в body const container = document.createElement('div'); container.id = 'toast-container'; document.body.appendChild(container);
this.outlet = new DomPortalOutlet( container, undefined, this.appRef, this.injector ); }
show(message: string, type: 'success' | 'error' = 'success'): void { const portal = new ComponentPortal(ToastComponent); const ref = this.outlet.attach(portal); ref.setInput('message', message); ref.setInput('type', type);
setTimeout(() => { this.outlet.detach(); }, 3000); }}Playground 🎮
Заголовок раздела «Playground 🎮»Интерактивный загрузчик динамических компонентов: