[Из песочницы] Создаем шаблонизируемые переиспользуемые компоненты в Angular 2

image Много раз слышал утверждение, что Angular 2 должен быть особенно хорош в корпоративных приложениях, поскольку он, мол, предлагает все нужные (и не очень) прибамбасы сразу из коробки (Dependency Injection, Routing и т. д.). Что ж, возможно это утверждение имеет под собой основу, поскольку вместо десятков разных библиотек разработчикам надо освоить один только Angular 2, но давайте посмотрим, насколько хорошо базовая (основная) функциональность этого фреймворка годится для корпоративных приложений.

По моему опыту, типовое корпоративное приложение — это сотни (иногда тысячи) практически идентичных страниц, которые лишь слегка отличаются друг от друга. Думаю, не мне одному приходила в голову мысль, что неплохо бы выделить повторяющийся функционал в отдельный компонент, а специфичное поведение определять через параметры и внедряемые шаблоны. Давайте посмотрим, что Angular 2 может нам предложить.

Первое, что приходит в голову — это отражение содержимого декларации компонента в его DOM c помощью специального тега .

Попробую продемонстрировать этот подход на примере простого компонента «widget».
Сначала пример использования:

  
    

И то, как это выглядит:
da8432594ea54fc89e24b90331af8fb5.gif

Внутри компонента «widget» мы определяем два элемента:

  1. Помеченный классом «content» — основное содержимое виджета;
  2. Помеченный классом «settings» — некие настройки, относящиеся к виджету;

Сам компонент «widget» отвечает за:
  • Отрисовку рамки и заголовка;
  • Логику переключения между режимом показа основного содержимого и режимом показа настроек.

Теперь посмотрим на сам компонент «widget»:

@Component({
    selector: "widget",
    template: `
  
  
Some widget
Settings:
`}) export class Widget { protected settingMode: boolean = false; }

Обратите внимание на два тега ng-content. Каждый из этих тегов содержит атрибут select c помощью которого происходит поиск элементов, предназначенных для замены оригинальных ng-content. Так, например, при показе нашего виджета:

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

Описанный выше подход может быть успешно применен во многих случаях, когда требуется создать шаблонизируемый компонент, но иногда этого подхода оказывается недостаточно. Например, если нам необходимо, чтобы переданный компоненту шаблон был отображен несколько раз причем в разных контекстах. Для того, чтобы объяснить проблему давайте рассмотрим следующую задачу: в нашем корпоративном приложении есть несколько страниц со списками объектов. Каждая страница отображает объекты какого-то одного типа (пользователи, заказы, что угодно), но при этом каждая страница позволяет вести постраничный просмотр объектов (пейджинг) и имеет возможность отметить некоторые объекты для того, чтобы выполнить некую групповую операцию, например, «удалить». Хотелось бы иметь компонент, который бы отвечал за пейджинг и выбор элементов, но способ отображения элементов оставался бы ответственностью конкретной страницы, что логично, поскольку отображать пользователей и заказы обычно нужно по-разному. В случае подобной задачи ng-content уже не подходит, так как он просто отображает один объект внутрь другого, но нам нужно не просто отобразить, но еще и расставить галочки напротив каждого объекта (индивидуальный контекст).

Забегая вперед, сразу покажу решение этой задачи на примере компонента «List Navigator», который я сконфигурировал на отображение информации о пользователях (исходники здесь).


  
{{i.firstName}} {{i.lastName}}
Id: {{i.id}}, Email: {{i.email}}, Gender: {{i.gender}}

4e5538c381b94999aa105de8dc094474.gif

Идея следующая: компонент в качестве параметра получает ссылку на функцию, возвращающую диапазон объектов по смещению и размеру страницы (offset и pageSize):

[dataSource]="dataSource"

this.dataSource = (o, p) => this._data.slice(o, o + p);

, а также шаблон, описывающий как необходимо отображать эти объекты:

Аргумент *list-navigator-item — это своего рода маркер, который позволяет нашему компоненту понять, что элемент, им помеченный, является шаблоном (символ »*» говорит ангуляру, что перед нами не просто элемент, а именно шаблон) который должен быть использован для отрисовки очередного объекта из диапазона, возвращаемого dataSource. С помощью list-navigator-item мы также задаем две переменные:
  • let i — ссылка на очередной объект из диапазона;
  • let isSelected = selected — булевское значение указывающие отмечен ли этот элемент галочкой
    или нет (о том, что означает »= selected» мы поговорим позже).

Помимо этого, компонент в качестве параметра получает список «выбранных» элементов (будут обозначены галочкой), и в случае, если пользователь меняет выбор, то компонент возвращает уже обновленный список, соответствующий пользовательскому выбору:
[(selectedItems)]="selectedUsers"

Можно провести следующую аналогию: мы передаем компоненту «фабрику», которая создает новый элемент интерфейса используя переданные компонентом параметры. Затем наш компонент размещает созданный фабрикой элемент интерфейса внутри себя туда куда нужно (напротив галочки в нашем случае).

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

92d74bd923924208a251a9a4887825c5.png

  • list-navigator-item-outlet — Директива-outlet, которая, собственно, и отвечает за создание нового элемента, используя template и текущий контекст («фабрика» из вышеприведённой аналогии) (часть внутренней реализации компонента);
  • list-navigator-item-сontext — Класс-контейнер для передачи данных контекста (часть внутренней реализации компонента);
  • list-navigator-item — директива-маркер, с помощью которой компонент получает доступ к шаблону элемента;
  • list-navigator — собственно компонент, который реализует основное поведение, а именно пейджинг и выбор элементов.

list-navigator-item-outlet


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

Совсем небольшая директива, поэтому приведу ее исходный код целиком:

@Directive({
    selector: "list-navigator-item-outlet"
})
export class ListNavigatorItemOutlet
{
    constructor(private readonly _viewContainer: ViewContainerRef){}

    @Input()
    public template: TemplateRef;

    @Input()
    public context: ListNavigatorItemContext;

    public ngOnChanges(changes: SimpleChanges): void
    {
        const [, tmpl] = analyzeChanges(changes, () => this.template);
        const [, ctx] = analyzeChanges(changes, () => this.context);

        if (tmpl && ctx) {
            this._viewContainer.createEmbeddedView(tmpl, ctx);
        }
    }
}

В конструкторе мы запрашиваем у Angular 2 объект типа ViewContainerRef. С помощью этого объекта мы можем управлять визуальными элементами (View) (не путать с элементами DOM браузера) в родительском компоненте. Среди всех возможностей ViewContainerRef нас в данный момент интересует возможность создания новых визуальных элементов, это можно сделать с помощью следующих двух методов:
  • createComponent (componentFactory: ComponentFactory,…)
  • createEmbeddedView (templateRef: TemplateRef, context?: C,…)

Первый метод полезен в том случае, если мы хотим динамически создать компонент, имея на руках лишь его «класс». Этим методом пользуется, например, ангуляровский роутер, но это тема заслуживает отдельного поста (если, конечно, будет интересно). Сейчас же давайте обратим внимание на второй метод createEmbeddedView, с помощью которого мы и создаем наших пользователей. В качестве параметров он принимает TemplateRef и некий контекст.

TemplateRef — это «фабрика» по созданию новых визуальных компонентов, полученная путем «компиляции» тега