Привет Хабр. На связи Даня, Angular-разработчик из команды Т-Бизнеса.

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

Добро пожаловать под кат!

Для чего оно нужно

Фреймворк Angular преподносит немало трудностей во время работы с ним. Большое влияние на это оказывает то, что из коробки Angular доступно много инструментов для разработки и можно вообще ничего не подключая извне делать сложные и навороченные проекты. 

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

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

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

У меня есть статья, в которой подробно описаны шаги создания динамических компонентов и дальнейшее взаимодействие с ними:

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

Создаем первый динамический компонент

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

Карточки товаров одинаковые по структуре и являются одним и тем же компонентом.

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

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

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

У каждой транзакции есть своя категория, которая описывает суть транзакции. Категорий может быть много, их можно создавать и редактировать. Именно такой компонент, который содержит информацию об определенной категории, мы и будем создавать.

Вот файловая структура нашего проекта

Код компонента category.component.ts:

category.component.ts
@Component({
  selector: 'app-category',
  templateUrl: './category.component.html',
  styleUrls: ['./category.component.scss'],
  standalone: true,
})
export class CategoryComponent {
  @Input() categoryName!: string;
  id!: number;
 
  author!: string;
 
  updatedDate!: string;
}

Начнем с простого варианта, где есть несколько полей, которые через интерполяцию отображаются в шаблоне.

Основой для создания компонента является класс ViewContainerRef, который содержит множество полезных методов, в том числе интересующий нас createComponent.

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

dynamic-components-loader.directive.ts
@Directive({
  selector: '[dynamicComponentLoader]',
})
export class DynamicComponentsLoaderDirective {
  viewContainerRef = inject(ViewContainerRef);

}

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

categories.component.html:

<div class="dynamic">
    <ng-template dynamicComponentLoader></ng-template>
</div>

А внутри самого компонента контейнера через декоратор @ViewChild получаем доступ к нашей директиве.

categories.component.ts
  @ViewChild(DynamicComponentsLoaderDirective, { static: true })
  dynamicComponentContainer!: DynamicComponentsLoaderDirective;

Итак, базовые вещи сделаны, теперь давайте создавать сами компоненты. Код функции для создания компонента:

categories.component.ts
  async loadSingleCategory() {
    const vcr = this.dynamicComponentContainer.viewContainerRef;
    vcr.clear();
 
    const { CategoryComponent } = await import(
      '../../shared/components/category/category.component'
    );
 
    const categoryComp: ComponentRef<CategoryDynamicComponent> =
      vcr.createComponent<CategoryDynamicComponent>(CategoryComponent);
  }

Получаем доступ к классу ViewContainerRef, который инжектирован в нашу директиву, и записываем его в переменную vcr, далее будем называть ее контейнером:

const vcr = this.dynamicComponentContainer.viewContainerRef;

Чистим все содержимое контейнера при помощи метода clear, ведь автоматически он этого не сделает и при каждом вызове функции к уже существующим будет добавляться новый динамический компонент:

vcr.clear();

После того как мы подготовили контейнер, необходимо загрузить сам компонент, который будем создавать. Сделаем это через функцию import, она асинхронная, поэтому объявим функцию loadSingleCategory через async:

const { CategoryComponent } = await import(
      '../../shared/components/category/category.component'
    );

Теперь можно создавать компонент. Вызываем у контейнера метод createComponent, куда параметром передаем наш компонент:

const categoryComp: ComponentRef<CategoryDynamicComponent> =
      vcr.createComponent<CategoryDynamicComponent>(CategoryComponent);

Взаимодействие с созданным компонентом

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

При создании компонента мы можем получить доступ к его полям и проинициализировать их с помощью объекта categoryData:

const categoryData = {
  categoryName: 'New Name',
  id: 1,
  author: 'Daniel',
  updatedDate: '11.08.2001',
  isRedact: false,
};
 
categories.component.ts
async loadSingleCategory() {
    const vcr = this.dynamicComponentContainer.viewContainerRef;
    vcr.clear();
 
    const { CategoryComponent } = await import(
      '../../shared/components/category/category.component'
    );
 
    const categoryComp: ComponentRef<CategoryDynamicComponent> =
      vcr.createComponent<CategoryDynamicComponent>(CategoryComponent);
 
	// Обращаемся к полям
    categoryComp.instance.categoryName = categoryData.categoryName;
    categoryComp.instance.id = categoryData.id;
    categoryComp.instance.isRedact = false;
    categoryComp.instance.updatedDate = categoryData.updatedDate;
    categoryComp.instance.author = categoryData.author;
  }

После инициализации компонента так изменить его данные уже не получится. К примеру, создадим отдельную функцию, которая будет менять название категории, и будем вызывать ее при клике на кнопку:

categories.component.ts
  changeName() {
    this.categoryComp.instance.categoryName = 'custom';
  }

Для отслеживания процесса добавим хук ngOnChanges в динамический компонент, ведь именно он вызывается при изменении @Input свойств. А еще добавим в хук OnInit вывод в консоль, чтобы знать, когда создается компонент:

category.component.ts
  ngOnInit(): void {
    console.log('OnInit');
  }
 
  ngOnChanges(changes: SimpleChanges) {
    console.log(changes, 'OnChanges');
  }

Если поменять поля компонента извне, то изменения никак не отобразятся, так как не будет запущен цикл обнаружения изменений. Наши изменения будут проигнорированы и отобразятся только на следующем шаге обнаружения изменений.

Есть уже встроенный метод для работы с Input-свойствами таких компонентов, он называется setInput. Метод setInput принимает в себя два параметра: название свойства и его значение.

Доработаем код функции changeName:

categories.component.ts
  changeName() {
    this.categoryComp.setInput('categoryName', 'custom');
  }

Видим, что в консоли выводится сообщение от onInit, помимо него срабатывает onChanges — и мы получаем доступ к объекту changes. Это может понадобиться для выполнения дополнительной логики, связанной с изменениями Input-значений, например повторной инициализации формы, где это значение используется.

Что касается обработки исходящих событий из компонента, то тут выбора не так много, поскольку встроенных механизмов для этого нет и придется пользоваться базовым eventEmitter.

Добавим компоненту категории возможность редактировать его название и сделаем этот процесс в духе глупых компонентов. То есть при редактировании и нажатии кнопки «Сохранить» компонент категории будет отправлять данные в родительский компонент, который в свою очередь будет общаться с сервером. Добавим Output-свойство компоненту категории:

category.component.ts
  @Output() redactCategory: EventEmitter<any> =
    new EventEmitter<any>();
 
И саму функцию для эмита значений
category.component.ts
  sendRedactedCategoryData() {
    this.redactCategory.emit({
      newTitle: this.categoryFormControl.value,
      id: this.id,
    });
  }

А в родительском компоненте внутри функции loadSingleCategory добавим логику подписки на исходящие из компонента категории события

categories.component.ts:

this.categoryComp.instance.redactCategory.subscribe(
      (redactedCategoryData) => {
        // логика по обработке данных тут
      }
    );

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

Еще больше компонентов

Обычно однотипных компонентов у нас на странице много, и рендерить их по одному за раз — как-то не очень. Более того, с каждым из них нужно как-то взаимодействовать, передавать им данные и прослушивать события.

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

После создания и наполнения компонента данными мы добавляем его в массив со всеми остальными динамическими компонентами. Подписываемся на события редактирования, которые эмитит компонент категории. А чтобы понимать, какой конкретно компонент пробросил событие, будем передавать его id и выполнять поиск по массиву динамических компонентов:

categories.component.ts
  async loadCategories(categoriesData: any[]) {
    const vcr = this.dynamicComponentContainer.viewContainerRef;
    vcr.clear();
 
    const { CategoryComponent } = await import(
      '../../shared/components/category/category.component'
    );
 
    // Проходимся по данным
         categoriesData.forEach((categoryData) => {
      // Создаем компонент и наполняем его данными
      const categoryComp: ComponentRef<CategoryDynamicComponent> =
        vcr.createComponent<CategoryDynamicComponent>(CategoryComponent);
 
      categoryComp.instance.categoryName = categoryData.title;
      categoryComp.instance.id = categoryData.id;
      categoryComp.instance.isRedact = false;
      categoryComp.instance.updatedDate = categoryData.updatedAt;
      categoryComp.instance.author = categoryData.user.email;
 
      // Добавляем новый компонент в массив с уже созданными
      this.categoryComponents.push(categoryComp);
 
      // Подписываемся на события и обрабатываем их
      categoryComp?.instance.redactCategory.subscribe(
        ({ newTitle, id }: RedactCategory) => {
          const redactedCategory = this.categoryComponents.find(
            (category) => category.instance.id === id
          );
 
          if (newTitle !== redactedCategory?.instance.categoryName) {
            this.store.dispatch(
              CategoriesActions.redactCategory({ data: { newTitle, id } })
            );
          }
 
          redactedCategory!.instance.isRedact = false;
        }
      );
    });

    }
  }

Предлагаю перейти на StackBlitz и проверить, как это все работает.

Я слегка упростил структуру проекта, чтобы лишние детали не отвлекали вас от экспериментов.

Итоги

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

Стоит сказать о том, что в Angular уже из коробки присутствует директива ngComponentOutlet, которая может решать задачу динамического рендеринга компонентов гораздо проще и быстрее. Однако, используя ngComponentOutlet, мы теряем возможность общаться с создаваемыми компонентами через @Input - и - @Output свойства. Выходом может стать использование общего сервиса, но это уже не слишком ложится в парадигму умных и глупых компонентов. 

Если же столь гибкого взаимодействия с динамическими компонентами не требуется, рекомендую посмотреть в сторону библиотеки ng-polymorpheus: она решает те же самые задачи, но в более декларативном стиле. Вот ссылка на репозиторий:

Чем больше разработчики ориентируются на результат, тем большим количеством сложных концепций обрастает код. Я надеюсь, что после прочтения статьи одна из них стала более понятной и привычной для дальнейшего использования в ваших проектах. А если есть вопросы или желание поделиться своим опытом — добро пожаловать в комментарии!

Комментарии (3)


  1. Zy2ba
    16.08.2024 06:13

    Осталось дописать дополнение к статье на сигнальных инпутах и будет идеально. В теории как раз боль с eventEmitter должна уйти


    1. jQwery Автор
      16.08.2024 06:13

      Похоже на на еще одну часть)


  1. Defersa
    16.08.2024 06:13

    Подход из статьи уже устарел - ангулярский "@defer” сделает тоже самое, но с более очевидным кодом, да и кода будет поменьше. Единственное - есть разница работы в сср, но это уже другой вопрос