Pull to refresh

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

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

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


Простой вариант


Первое, что приходит в голову — это отражение содержимого декларации компонента в его DOM c помощью специального тега <ng-content select="...">.

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

  <widget>
    <div class="content">
      <button>
        Just do my job...
      </button>
    </div>
    <div class="settings">
      <select>
        <option selected="true">Faster</option>
        <option>Slower</option>
      </select>
    </div>
  </widget>

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



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

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

Сам компонент «widget» отвечает за:

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


Теперь посмотрим на сам компонент “widget”:

@Component({
    selector: "widget",
    template: `
  <style>
    ..
  </style>
  <div class="hdr">
    <span>Some widget</span>
    <button *ngIf="!settingMode" (click)="settingMode = true" class="wid_btn">
      Settings
    </button>
    <button *ngIf="settingMode" (click)="settingMode = false" class="wid_btn">
      Ok
    </button>
  </div>
  <div class="cnt">
    <ng-content *ngIf="!settingMode" select=".content">
    </ng-content>
    <div *ngIf="settingMode">
      Settings:
      <ng-content select=".settings">
      </ng-content>
    </div>
  <div>    
    `})
export class Widget {
  protected settingMode: boolean = false;
}

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

<ng-content select=".settings" /> будет заменен на .. a <ng-content select=".content"/> на .. Естественно, что поиск элементов ограничен тегом <widget>...</widget> в клиентской разметке. Если замена не была найдена, то мы просто ничего не увидим. Если условию выборки удовлетворяет несколько элементов, то мы увидим их все.

Вариант посложнее


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

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

<list-navigator [dataSource]="dataSource" [(selectedItems)]="selectedUsers">
  <div *list-navigator-item="let i, let isSelected = selected" 
    class="item-container" 
    [class.item-container-selected]="isSelected"
  >
    <div class="item-header">{{i.firstName}} {{i.lastName}}</div>
    <div class="item-details">
        Id: {{i.id}}, 
        Email: {{i.email}}, 
        Gender: 
        <span [ngClass]="'item-details-gender-'+ i.gender.toLowerCase()">
            {{i.gender}}
        </span>
    </div>
  </div>
</list-navigator>



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

[dataSource]="dataSource"

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

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

<div *list-navigator-item="let i, let isSelected = selected"…

Аргумент *list-navigator-item – это своего рода маркер, который позволяет нашему компоненту понять, что элемент, им помеченный, является шаблоном (символ ‘*’ говорит ангуляру, что перед нами не просто элемент, а именно шаблон) который должен быть использован для отрисовки очередного объекта из диапазона, возвращаемого dataSource. С помощью list-navigator-item мы также задаем две переменные:

  • let i – ссылка на очередной объект из диапазона;
  • let isSelected = selected – булевское значение указывающие отмечен ли этот элемент галочкой
    или нет (о том, что означает “= selected” мы поговорим позже).

Помимо этого, компонент в качестве параметра получает список “выбранных” элементов (будут обозначены галочкой), и в случае, если пользователь меняет выбор, то компонент возвращает уже обновленный список, соответствующий пользовательскому выбору:

[(selectedItems)]="selectedUsers"

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

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


  • 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<ListNavigatorItemContext>;

    @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<C>,...)
  • createEmbeddedView(templateRef: TemplateRef<C>, context?: C,...)

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

TemplateRef – это “фабрика” по созданию новых визуальных компонентов, полученная путем “компиляции” тега <template> в макете компонента (тот, который template: ‘…’ или templateUrl: “…”). Но ведь до сих пор мы нигде не видели этот тег, откуда же тогда взялся TemplateRef? На самом деле у нас был тег <template>, просто мы его определили неявно, использовав синтаксический сахар в виде символа ‘*’:

<div *list-navigator-item="let i, let isSelected = selected"…

эквивалентно

<template [list-navigator-item]="let i, let isSelected = selected"
<div …

Angular 2 создает TemplateRef для любого <template>, который он найдет в макете компонента.

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

"let i, let isSelected = selected"

Здесь опять имеет место небольшой синтаксический сахар, эту запись Angular 2 понимает как:

"let i=$implicit, let isSelected = selected"

То есть в нашем случае объект context должен иметь два свойства: $implicit и selected. Так оно и есть:

export class ListNavigatorItemContext
{
    constructor(
        public $implicit: any,
        public selected: boolean
    ) { }
}

Теперь у нас есть все знания чтобы понять, как работает list-navigator-item-outlet – как только выставляются оба свойства template и context, директива создает в своем контейнере новый визуальный элемент.

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

list-navigator-item


Тут совсем все просто:

@Directive({
    selector: "[list-navigator-item]"
})
export class ListNavigatorItem {
    constructor(@Optional() public readonly templateRef: TemplateRef<ListNavigatorItemContext>) {
    }
}

Единственная цель этой директивы — это передать скомпилированный шаблон через публичное свойство.

list-navigator


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

<div *ngFor="let i of itemsToDisplay">
    <div>
        <input 
type="checkbox" 
[ngModel]="i.selected" 
(ngModelChange)="onSelectedChange(i, $event)"/>
    </div>
    <div class="item-wrapper">
        <list-navigator-item-outlet 
[template]="templateOutlet.templateRef" 
[context]="i">
        </list-navigator-item-outlet>
    </div>
</div>
<div>
    <button (click)="onPrev()">Prev</button>
    <button (click)="onNext()">Next</button>
</div>

где:

this.itemsToDisplay = this
    .dataSource(offset, pageSize)
    .map(i => new ListNavigatorItemContext(
       i, this.selectedItems.indexOf(i) >= 0));
…
@ContentChild(ListNavigatorItem)
protected templateOutlet: ListNavigatorItem;

Принцип работы:

  • Получаем очередную порцию объектов, используя ссылку на соответствующую функцию (dataSource) и помещаем ее в itemsToDisplay
  • Перебираем все объекты из порции (*ngFor=«let i of itemsToDisplay») и для каждого объекта создаем галочку плюс вышеописанный list-navigator-item-outlet, который получает в качестве параметров:
    • Контекст состоящий из объекта и признака отметки selected;
    • Ссылку на TemplateRef, которую нам любезно предоставила директива list-navigator-item, которая в свою очередь была найдена путем декларации запроса @ContentChild(ListNavigatorItem)
  • list-navigator-item-outlet создает визуальный элемент для очередного объекта, используя переданный шаблон и контекст (помните, что в реальном проекте желательно использовать NgTemplateOutlet).

Небольшое дополнение.
Шаблон для list-navigator вполне интерактивен — мы можем добавлять кнопки, чекбоксы, поля ввода и управлять всем этим из родительской страницы. Вот пример с добавленной кнопочкой “Archive”, обработчик которой, находится на родительской странице и изменяет состояние пользователя:


<list-navigator..>
  <div  *list-navigator-item="let i..." >
    ...
    <button *ngIf="!i.archived" class="btn-arch" (click)="onArchive(i)">Archive</button>
    ...
  </div>
</list-navigator>

    protected onArchive(user: User){
        user.archived = true;
    }



Вот, собственно, и все. Статья описывает приемы, которые чуть-чуть выходят за рамки стандартной документации для Angular 2, поэтому, надеюсь, она кому-нибудь пригодится. Еще раз указываю ссылку на исходники примеров описанных в статье. (вариант с кнопкой «Archive»).
Tags:
Hubs:
+16
Comments 27
Comments Comments 27

Articles