Перейти к содержимому

52. Динамические компоненты

Привет! Яша здесь. Динамические компоненты — мощный инструмент Angular для создания диалогов, тостов, вкладок и любого UI, который создаётся не в шаблоне, а в коде. Разберём всё от основ до production-паттернов 🚀


Статический шаблон <app-toast></app-toast> не подходит когда:

  • Нужно показать любое количество тостов
  • Компонент должен создаваться в произвольный момент
  • Родитель не знает заранее какой компонент показать
  • Нужен портал — рендер в другом месте DOM

Примеры: диалоги, уведомления, динамические формы, plug-in системы.


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(); // удалить компонент
});
}
}

// ✅ Современный способ — setInput запускает Change Detection
const ref = container.createComponent(ToastComponent);
ref.setInput('message', 'Сохранено!'); // типобезопасно
ref.setInput('duration', 3000);
ref.setInput('type', 'success');
// ✅ Альтернатива — напрямую через instance
ref.instance.message = 'Сохранено!';
ref.changeDetectorRef.detectChanges(); // нужно вызвать вручную!
// ❌ Устаревший способ (Angular < 13)
// ComponentFactoryResolver был удалён в Angular 15
const factory = resolver.resolveComponentFactory(ToastComponent); // deprecated!
const ref = container.createComponent(factory);

dialog.service.ts
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 = [];
}
}
confirm-dialog.component.ts
@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);
}
}
}

dynamic-form.component.ts
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);
}
}

@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>;
}

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);
}
}

Интерактивный загрузчик динамических компонентов: