Привет Хабр. На связи Даня, 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: она решает те же самые задачи, но в более декларативном стиле. Вот ссылка на репозиторий:
Чем больше разработчики ориентируются на результат, тем большим количеством сложных концепций обрастает код. Я надеюсь, что после прочтения статьи одна из них стала более понятной и привычной для дальнейшего использования в ваших проектах. А если есть вопросы или желание поделиться своим опытом — добро пожаловать в комментарии!