В этой статье мы поговорим о создании компонентов динамически, шаг за шагом пройдем этот путь. Помимо простого создания компонентов, мы поговорим о более продвинутых вещах, которые можно сделать в рамках этого процесса.

Создание динамических компонентов

В первую очередь нам нужен сам компонент, который мы будем динамически создавать.

@Component({
	  selector: "alert",
	  template: `
	    <h1>Alert {{type}}</h1>
	  `,
	})
	export class AlertComponent {
	  @Input() type: string = "success";
	}

Будем использовать простой компонент alert, который принимает тип оповещения как инпут свойство

Стоит сказать, что динамические компоненты, а вернее те, которые создаются динамически, являются элементами DOM, и, соответственно, в шаблоне необходимо обеспечить место, куда его нужно добавить.

@Component({
	  selector: 'my-app',
	  template: `
	    <template #alertContainer></template>
	  `,
	})
	export class App {}

В компоненте my-app создаем шаблон, используя тег ng-template и добавляем к нему переменную через #. Этот template как раз является местом, куда добавится компонент, дальше будем называть это контейнером. В роли контейнера может выступать любой компонент или элемент DOM.

Теперь в компоненте нужно получить ссылку на контейнер. Это можно сделать с помощью ViewChild. Кстати, у меня уже есть статья, которая поможет разобраться в процессах, связанных с взаимодействием с DOM элементами, где как раз подробно рассказывается о декораторе ViewChild. Манипулируй DOM правильно.

@Component({
	  selector: 'my-app',
	  template: `
	    <template #alertContainer></template>
	  `,
	})
	export class App {
	 @ViewChild("alertContainer", { read: ViewContainerRef }) container;
	}

Дефолтное значение, которое возвращается из ViewChild, это экземпляр компонента или элемента DOM, но в нашем случае нужен ViewContainerRef. Название этого класса говорит само за себя, ViewContainerRef содержит в себе ссылку на контейнер, а также методы, которые позволяют создавать компоненты.

Подготовим небольшой интерфейс для создания компонентов. Добавим на страницу две кнопки, а также заинжектим один сервис в наш компонент.

@Component({
	  selector: 'my-app',
	  template: `
	    <template #alertContainer></template>
	    <button (click)="createComponent('success')">Create success alert</button>
	    <button (click)="createComponent('danger')">Create danger alert</button>
	  `,
	})
	export class App {
	 @ViewChild("alertContainer", { read: ViewContainerRef }) container;

constructor(private resolver: ComponentFactoryResolver) {}
	}

Сервис ComponentFactoryResolver содержит метод resolveComponentFactory, который принимает в себя компонент, и возвращает ComponentFactory, в котором, в свою очередь присутствует метод create(), именно он и будет использоваться нашим контейнером для создания компонента.

Итак, теперь есть всё необходимое для метода, создающего компоненты.

createComponent(type) {
	    this.container.clear(); 
	    const factory: ComponentFactory = this.resolver.resolveComponentFactory(AlertComponent);
	    this.componentRef: ComponentRef = this.container.createComponent(factory);
	  }

Давайте пройдемся по порядку и посмотрим, что тут происходит

this.container.clear();

Каждый раз, когда мы нажмем кнопу для создания компонента, нам необходимо очистить контейнер от предыдущего представления. Иначе это будет подобно методу push() для массивов, что в данной ситуации не является хорошей практикой.

const factory: ComponentFactory = 
      this.resolver.resolveComponentFactory(AlertComponent);

resolveComponentFactory() принимает класс компонента и возвращает ComponentFactory, где есть всё необходимое чтобы его создать.

this.componentRef: ComponentRef = this.container.createComponent(factory);

Мы вызываем метод createComponent() и передаем переменную типа ComponentFactory. Под капотом этот метод вызывает create() и добавляет готовый компонент как дочерний в контейнер.

После всех этих взаимодействий у нас есть ссылка на компонент, и мы можем установить значение его Input свойства

this.componentRef.instance.type = type;

Так же можно и подписаться на Output свойство компонента.

this.componentRef.instance.output.subscribe(event => console.log(event));

Чтобы избежать утечек памяти, стоит уничтожить компонент после использования

ngOnDestroy() {
	 this.componentRef.destroy(); 
	}

Метод для создания компонента готов, все операции изучены и понятны, и, в конце концов, остается только добавить динамический компонент в массив entryComponents в модуле.

@NgModule({
	 entryComponents: [ AlertComponent ],
	 bootstrap: [ App ]
	})
	export class AppModule {}

Ленивая загрузка компонентов

Ангуляр обеспечивает ряд возможностей по оптимизации работы вашего проекта. Одна из таких особенностей – это ленивая загрузка модулей, что позволяет подгружать различные модули с определенным функционалом по мере необходимости, а не сразу при старте приложения. Это позволяет сократить размер изначально загружаемого кода, уменьшить нагрузку на клиенте и т.д. Об этой возможности Ангуляр можно найти довольно много материалов и ознакомиться с ними подробнее.

Однако мы можем пойти дальше, и загружать лениво не только целые модули, но и отдельные компоненты на странице.

Давайте посмотрим, как это можно сделать, используя уже изученные методы из первой части статьи.

Создадим компонент BarComponent

@Component({
	  template: `
	   <h1 (click)="titleChanges.emit('changed')">{{ title }}</h1>
	  `
	})
	export class BarComponent implements OnInit {
	  title = 'Default';
	  titleChanges = new EventEmitter();

Тут присутствует свойство title, а также свойство titleChanges, на которое можно подписаться и принимать исходящие из компонента события.

Теперь давайте посмотрим, как лениво загрузить этот компонент, динамически его создать, и получить доступ к его свойствам.

@Component({
	  template: `
	   <button (click)="loadBar()">Load BarComponent</button>
	   <ng-template #vcr></ng-template>
	  `
	})
	export class MyComponent {
	  @ViewChild('vcr', { read: ViewContainerRef }) vcr: ViewContainerRef;
	  barRef: ComponentRef<BarComponent>;
	

	  constructor(private resolver: ComponentFactoryResolver) {}
	

	  async loadBar() {
	    if (!this.barRef) {
	      const { BarComponent } = await import(`./bar/bar.component`);
	      const factory = this.resolver.resolveComponentFactory(BarComponent);
	      this.barRef = this.vcr.createComponent(factory);
	      this.barRef.instance.title = 'Changed';
	      // Don't forget to unsubscribe
	      this.barRef.instance.titleChanges.subscribe(console.log);
	    }
	  }
	}

Сперва, как и в первой части статьи, получим ссылку на контейнер через ViewChild. После этого нужно загрузить сам компонент BarComponent. Синтаксис этой загрузки очень похож на синтаксис, используемый при ленивой загрузке модулей.

      const { BarComponent } = await import(`./bar/bar.component`);

Затем инжеткируем сервис ComponentFactoryResolver, чтобы пользоваться его методами для создания компонента.

Дальнейшая последовательность действий аналогична динамическому созданию компонента.

После выполнения всех действий, у нас есть доступ к компоненту и его свойствам. Тут нужно обратить внимание, что мы не используем декораторы Input и Output, потому что мы общаемся с компонентом не через шаблон, а напрямую взаимодействуем с экземпляром компонента прямо в коде.

Однако если посмотреть на созданный компонент, можно сделать вывод что он довольно бесполезный и пригодится только чтобы отображать данные из входных свойств. Для полноты картины, было бы здорово обеспечить возможность использования остальные возможности Ангуляра в нем, например: другие компоненты, директивы, пайпы. Для решения этой проблемы мы создадим BarModule, и добавим туда все необходимое. (В последней версии Ангуляр, а на данный момент — это 16 версия, появилась возможность использовать standalone компоненты и напрямую подключать в него все используемые компоненты, модули, директивы и т.д.)

	@NgModule({
	  imports: [ReactiveFormsModule],
	  declarations: [FooComponent]
	})
	class BarModule {
	}

Самое интересное, что благодаря механизму tree-shaking, если единственное место, где мы будем использовать модуль ReactiveFormsModule — это наш компонент BarComponent, то исходный код реактивных форм загрузится только при загрузке BarComponent. Это открывает новые возможности в вопросах оптимизации приложения.

Еще одно...

Вышеописанный синтаксис отлично подойдет для использования в проектах, где версия Ангуляр 12 и ниже. Однако начиная с 13 версии класс ComponentFactoryResolver был отмечен как deprecated, и это повлияло на процесс создания динамических компонентов, так как теперь из последовательности действий ушел один шаг, а именно создание фабрики компонента.

const factory = this.resolver.resolveComponentFactory(BarComponent);

Теперь же для создания компонента можно напрямую обращаться к методу createComponent и передавать класс создаваемого компонента в качестве параметра, на выходе получится тот же результат.

Итоги

Динамическое создание компонентов открывает большие возможности по взаимодействию с ними и их управлению. Помимо всего этого в шаблонах компонента остается гораздо меньше кода с описанием всех input и output свойств, что так же хорошо влияет на весь проект. Ну и самое главное, это вопросы оптимизации. Благодаря более тонкой работе с компонентами, их динамическому созданию и ленивой загрузке открываются всё большие возможности по оптимизации приложения и улучшению пользовательского опыта при работе с ним. Данная статья не раскрывает собой все вопросы по данной теме, поэтому я советую вам более подробно ознакомиться со всеми материалами, которые существуют по тематике динамических компонентов.

Динамическое создание компонентов мощный инструмент, способный дать вашему проекту новые преимущества. В свою очередь вы как разработчик, изучив досконально этот вопрос, добавите себе в копилку ценные и важные навыки.

Буду рад обратной связи в карму или в комментариях.