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

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]="і">
</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).


Ось, власне, і все. Стаття описує прийоми, які трохи виходять за рамки стандартної документації для Angular 2, тому, сподіваюся, вона кому-небудь знадобиться. Ще раз вказую посилання на джерело прикладів описаних в статті..
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.