Динамический рендеринг Angular-компонентов

от автора

Привет Хабр. На связи Даня, 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: она решает те же самые задачи, но в более декларативном стиле. Вот ссылка на репозиторий:

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


ссылка на оригинал статьи https://habr.com/ru/articles/836036/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *