Когда работаешь над библиотекой переиспользуемых компонентов, вопрос API встает особенно остро. С одной стороны, нужно сделать надежное, аккуратное решение, с другой — удовлетворить массу частных случаев. Это относится и к работе с данными, и к внешним особенностям различных кейсов использования. Кроме того, все должно легко обновляться и раскатываться по проектам.


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


bruce lee


Data-agnostic компоненты


Допустим, мы делаем кнопку с выпадающим меню. Каким будет его API? Разумеется, ему нужен на вход некий items — массив пунктов меню. Скорее всего, первый вариант интерфейса будет таким:


interface MenuItem {
    readonly text: string;
    readonly onClick(): void;
}

Довольно быстро к этому добавится disabled: boolean. Потом придут дизайнеры и нарисуют меню с иконками. А ребятам с соседнего проекта они нарисуют иконки с другой стороны. И вот интерфейс растет, необходимо покрывать все больше частных случаев, а от обилия флагов компонент начинает напоминать Ассамблею ООН.



На помощь приходят дженерики. Если организовать компонент так, чтобы ему не было дела до модели данных, то все эти проблемы отпадут. Вместо того чтобы по клику вызывать item.onClick, меню будет просто эмитить наружу кликнутый пункт. Что с ним делать после — задача для пользователей библиотеки. Пусть даже они вызовут тот же item.onClick.


В случае состояния disabled, к примеру, вопрос решается с помощью специальных обработчиков. В компонент передается метод disabledItemHandler: (item: T) => boolean, через который прогоняется каждый пункт. Полученный результат говорит, заблокирован ли данный элемент.



Если вы делаете ComboBox — на ум может прийти интерфейс, хранящий строку для отображения и реальное произвольное значение, которое используется в коде. Эта идея понятна. Ведь, когда пользователь печатает текст, ComboBox должен фильтровать варианты по введенной строке.


interface ComboBoxItem {
    readonly text: string;
    readonly value: any;
}

Но тут тоже всплывут ограничения подобного подхода — стоит только появиться дизайну, в котором строки? будет недостаточно. Кроме того, форма будет содержать обертку вместо реального значения, поиск не всегда осуществляется исключительно по строковому представлению (к примеру, мы можем вбивать номер телефона, но отображаться должно имя человека). А число интерфейсов будет расти с появлением других компонентов, даже если модель данных под ними одна и та же.


Здесь тоже помогут дженерики и обработчики. Дадим возможность передавать в компонент stringify функцию (item: T) => string. В качестве значения по умолчанию подойдет item => String(item). Таким образом можно даже использовать классы в качестве вариантов, задав в них метод toString(). Как упоминалось выше, бывает необходимость фильтровать варианты не только по строковому представлению. Это тоже хороший кейс для использования обработчиков. Можно предоставить в компонент функцию, получающую на вход строку поиска и элемент. Она вернет boolean — это подскажет, подходит ли элемент под запрос.


Еще один частый пример использования интерфейса — уникальный id, по которому сопоставляются копии JavaScript-объектов. Когда значение формы мы получили сразу, а варианты для выбора пришли отдельным запросом с сервера — в них будет только копия текущего элемента. Такую задачу решает обработчик, получающий на вход два элемента и возвращающий их равенство. По умолчанию подойдет обычное сравнение ===.

Компоненту отображения вкладок, по факту, не нужно знать в каком виде ему передали вкладку: хоть текстом, хоть объектом с дополнительными полями, хоть ещё как. Знание о формате необязательно для реализации, но многие делают завязку на конкретный формат. Отсутствие завязок означает, что компоненты не повлекут за собой ломающие изменения при доработке, не заставят пользователей адаптировать под них свои данные и позволят комбинировать атомарные компоненты друг с другом как кубики лего.


Один и тот же выбор из пунктов подойдет как для контекстного меню, так и для комбобокса, селекта, мультиселекта, простые компоненты легко включаются в более сложные конструкции. Однако произвольные данные нужно уметь как-то отображать.



Списки могут содержать аватарки, разные цвета, иконки, количество непрочитанных сообщений и многое другое


Для этого компоненты должны работать с внешним видом в схожей с дженериками манере.

Design-agnostic компоненты


Angular предоставляет мощные инструменты для задания внешнего вида.


Для примера рассмотрим ComboBox, так как он может выглядеть очень разнообразно. Разумеется, определенный уровень ограничений будет заложен в компонент, ведь он должен подчиняться общему дизайну приложения. Его размер, цвета по умолчанию, отступы — всё это должно работать само. Мы не хотим заставлять пользователей думать обо всем, что касается внешнего вида.



Произвольные данные подобны воде: они не имеют формы, не несут в себе ничего конкретного. Наша задача — предоставить возможность задавать для них «сосуд». В этом понимании разработка компонента, абстрагированного от внешнего вида, выглядит следующим образом:



Компонент представляет собой подобие полочки нужных размеров, а для отображения содержимого используется настраиваемый шаблон, который на нее «ставится». Стандартный способ вроде вывода строкового представления заложен в компонент изначально, а более сложные варианты пользователь может передать снаружи. Давайте рассмотрим, какие возможности для этого есть в Angular.


  1. Простейший вариант изменения внешнего вида — интерполяция строки. Но неизменная строка не сгодится для отображения пунктов меню, ведь она ничего не знает про каждый пункт — и они все будут выглядеть одинаково. Статичная строка лишена контекста. Но она вполне подойдет для задания текста «Ничего не найдено», если список вариантов пуст.


    <div>{{content}}</div>

  2. Мы уже говорили о строковом представлении произвольных данных. Результат также является строкой, но определяется входным значением. В такой ситуации контекстом будет элемент списка. Это более гибкий вариант, хотя он не позволяет стилизовать результат — строка не интерполируется в HTML — и уж тем более не даст использовать директивы или компоненты.


    <div>{{content(context)}}</div>

  3. Для задания переиспользуемого блока разметки в Angular предусмотрены шаблоны ng-template и структурная директива *ngTemplateOutlet. С их помощью мы можем определить кусочек HTML, ожидающий на вход некие данные, и передать его в компонент. Там он будет инстанциирован с конкретным контекстом. Мы передадим ему на вход наш элемент, не заботясь о модели. Составить правильный шаблон под свои объекты — задача разработчика-потребителя нашего компонента.


    <ng-container *ngTemplateOutlet="content; context: context"></ng-container>

    Шаблон — очень мощный инструмент, но его нужно определить в каком-то уже существующем компоненте. Это значительно затрудняет его переиспользование. Иногда один и тот же внешний вид требуется в разных частях приложения и даже в разных приложениях. В моей практике это, к примеру, внешний вид выбора счета с отображением названия, валюты и остатка.


  4. Наиболее сложный вариант кастомизации внешнего вида, который решает эту проблему, — динамические компоненты. В Angular уже давно существует директива *ngComponentOutlet, чтобы создавать их декларативно. Она не позволяет передавать контекст, но эта задача решается внедрением зависимостей. Мы можем сделать токен для контекста и добавить его в Injector, с которым компонент создается.


    <ng-container *ngComponentOutlet="content; injector: injector"></ng-container>

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


    <ng-template let-item let-focused="focused">
    <!-- ... -->
    </ng-template>

    К примеру, в случае вывода счета состояние сфокусированности пункта отражается на внешнем виде — фон иконки меняется с серого на белый. В общем виде в контекст имеет смысл передавать те условия, которые потенциально влияют на отображение шаблона. Этот момент является, пожалуй, единственным ограничением-интерфейсом такого подхода.




Универсальный Outlet


Описанные выше инструменты доступны в Angular с пятой версии. Но нам хочется легко переключаться с одного варианта на другой. Для этого мы соберем компонент, принимающий на вход содержимое и контекст и реализующий соответствующий способ вставки этого контента автоматически. В целом нам достаточно научиться различать типы string, number, (context: T) => string | number, TemplateRef<T> и Type<any> (но тут есть несколько нюансов, которых мы коснемся ниже).


Шаблон компонента будет выглядеть следующим образом:


<ng-container [ngSwitch]="type">
  <ng-container *ngSwitchCase="'primitive'">{{content}}</ng-container>
  <ng-container *ngSwitchCase="'function'">{{content(context)}}</ng-container>
  <ng-container *ngSwitchCase="'template'">
    <ng-container *ngTemplateOutlet="content; context: context"></ng-container>
  </ng-container>
  <ng-container *ngSwitchCase="'component'">
    <ng-container *ngComponentOutlet="content; injector: injector"></ng-container>
  </ng-container>
</ng-container>

В коде будет геттер на тип для выбора соответствующего способа. Нужно отметить, что в общем виде отличить компонент от функции мы не сможем. При использовании lazy-модулей нам понадобится Injector, знающий о существовании компонента. Для этого мы создадим класс-обертку. Это также даст возможность определять его по instanceof:


export class ComponentContent<T> {
  constructor(
    readonly component: Type<T>,
    private readonly injector: Injector | null = null,
  ) {}
}

Добавим ему метод для создания инжектора с переданным контекстом:


createInjectorWithContext(injector: Injector, context: C): Injector {
   return Injector.create({
     parent: this.injector || injector,
     providers: [
       {
         provide: CONTEXT,
         useValue: context,
       },
     ],
   });
 }

Что касается шаблонов, для большинства случаев можно работать с ними как есть. Но надо иметь в виду, что шаблон подчиняется проверке изменений места своего определения. Если передать его во View, находящийся параллельно или выше по дереву от места определения, то изменения, которые могут быть спровоцированы в нем, не будут подцеплены в исходном View.


Для исправления этой ситуации будем использовать не просто шаблон, а директиву, которая будет иметь при себе также ChangeDetectorRef места своего определения. Так мы сможем запускать проверку изменений, когда это необходимо.


Полиморфные шаблоны


На практике бывает полезно управлять поведением шаблона в зависимости от типа контента, который в него пришел.


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



Для этого нужно добавить еще одну вещь — передачу шаблона для отображения примитивов. Добавим в компонент @ContentChild, берущий из содержимого TemplateRef. Если таковой нашелся и в контент передана функция, строка или число, мы можем инстанциировать его с примитивом в качестве контекста:


 <ng-container *ngSwitchCase="'interpolation'">
   <ng-container *ngIf="!template; else child">{{primitive}}</ng-container>
   <ng-template #child>
     <ng-container
       *ngTemplateOutlet="template; context: { $implicit: primitive }"
     ></ng-container>
   </ng-template>
 </ng-container>

Теперь мы можем стилизовать интерполяцию или даже передать результат в какой-нибудь компонент для отображения:


 <content-outlet [content]="content" [context]="context">
   <ng-template let-primitive>
     <div class="primitive">{{primitive}}</div>
   </ng-template>
 </content-outlet>

Пора применить наш код на практике.


Использование


Для примеров набросаем два компонента: вкладки и ComboBox. Шаблон вкладок будет просто состоять из content-outlet на каждую вкладку, где в качестве контекста будет переданный пользователем объект:


<content-outlet
   *ngFor="let tab of tabs"
   [class.disabled]="disabledItemHandler(tab)"
   [content]="content"
   [context]="getContext(tab)"
   (click)="onClick(tab)"
></content-outlet>

Нужно заложить стили по умолчанию: например, размер шрифта, подчеркивание под текущей вкладкой, цвет. Но конкретный внешний вид мы оставим на content. Код компонента будет примерно таким:


export class TabsComponent<T> {
   @Input()
   tabs: ReadonlyArray<T> = [];

   @Input()
   content: Content = ({$implicit}) => String($implicit);

   @Input()
   disabledItemHandler: (tab: T) => boolean = () => false;

   @Input()
   activeTab: T | null = null;

   @Output()
   activeTabChange = new EventEmitter<T>();

   getContext($implicit: T): IContextWithActive<T> {
       return {
           $implicit,
           active: $implicit === this.activeTab,
       };
   }

   onClick(tab: T) {
       this.activeTab = tab;
       this.activeTabChange.emit(tab);
   }
}

Мы получили компонент, который умеет работать с произвольным массивом, отображая его как вкладки. Можно передать туда просто строки и получить базовый внешний вид:



А можно передать объекты и шаблон для их отображения и настроить внешний вид под свои нужды, добавить HTML, иконки, индикаторы:



В случае с ComboBox мы сначала сделаем два базовых компонента, из которых он состоит: поле ввода с иконкой и меню. Последний не имеет смысла расписывать подробно — он очень похож на вкладки, только расположен вертикально и имеет иные базовые стили. А поле ввода можно реализовать следующим образом:


<input #input [(ngModel)]="value"/>
<content-outlet
   [content]="content"
   (mousedown)="onMouseDown($event, input)"
>
   <ng-template let-icon>
       <div [innerHTML]="icon"></div>
   </ng-template>
</content-outlet>

Если сделать input позиционированным абсолютно — он перекроет собой outlet и все клики придутся по нему. Это удобно для простого поля ввода с декоративной иконкой, например со значком увеличительного стекла. На примере выше применен подход полиморфного шаблона — переданная строка будет использоваться как innerHTML для вставки SVG-иконки. Если же нам, например, нужно показать аватар введенного пользователя — можем передать туда шаблон.


Для ComboBox тоже нужна иконка, но она должна быть интерактивной. Чтобы она не нарушала фокус, добавим обработчик onMouseDown на outlet:


onMouseDown(event: MouseEvent, input: HTMLInputElement) {
   event.preventDefault();
   input.focus();
}

Передача шаблона в качестве содержимого позволит поднять его выше через CSS, просто сделав иконку position: relative. Тогда на клики по ней можно будет подписаться в самом ComboBox:


<app-input [content]="icon"></app-input>
<ng-template #icon>
   <svg
       xmlns="http://www.w3.org/2000/svg"
       width="24"
       height="24"
       viewBox="0 0 24 24"
       class="icon"
       [class.icon_opened]="opened"
       (click)="onClick()"
   >
       <polyline
           points="7,10 12,15 17,10"
           fill="none"
           stroke="currentColor"
           stroke-linecap="round"
           stroke-linejoin="round"
           stroke-width="2"
       />
   </svg>
</ng-template>

Благодаря такой организации мы получим нужное поведение:



Код компонента, как и в случае вкладок, обходится без знания о модели данных. Выглядит это приблизительно так:


export class ComboBoxComponent<T> {
   @Input() items: ReadonlyArray<T> = [];
   @Input() content: Content = ({$implicit}) => String($implicit);
   @Input() stringify = (item: T) => String(item);
   @Input() value: T | null = null;
   @Output() valueChange = new EventEmitter<T | null>();

   stringValue = '';

   // Это лучше реализовать через пайп чтобы избежать лишних пересчётов
   get filteredItems(): ReadonlyArray<T> {
       return this.items.filter(item =>
           this.stringify(item).includes(this.stringValue),
       );
   }
}

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



Вывод


Создание компонентов-агностиков освобождает от необходимости учитывать каждый частный случай. При этом пользователи получают простой инструмент для настройки компонента под конкретную ситуацию. Эти решения легко переиспользовать. Независимость от модели данных делает код универсальным, надежным и расширяемым. При этом мы написали не так уж много строк и в основном задействовали встроенные инструменты Angular.


Используя описанный подход, вы быстро заметите, как удобно мыслить категориями контента, а не конкретными строками или шаблонами. Отображение сообщений об ошибке валидации, тултипы, модальные окна — этот подход хорош не только для кастомизации внешнего вида, но и для передачи содержимого в целом. Набрасывать макеты и тестировать логику очень легко! К примеру, для показа попапа пользователю не нужно создавать компонент или даже шаблон, можно просто передать строку-заглушку и вернуться к ней позже.


Мы в Tinkoff.ru уже давно успешно применяем описанный подход и вынесли его в крошечную (1 КБ gzip) open-source-библиотеку под названием ng-polymorpheus.


Исходный код


npm-пакет


Интерактивное демо и «песочница»


У вас тоже есть что-то, что вы мечтали выложить в open source, но вас отпугивают сопутствующие хлопоты? Попробуйте Angular Open-source Library Starter, который мы сделали для своих проектов. В нём уже настроен CI, проверки при коммитах, линтеры, генерация CHANGELOG, покрытие тестами и всё в этом духе.

Комментарии (6)


  1. psFitz
    26.10.2019 13:18

    Как-то слишком сжато, большую часть не понял


    1. Waterplea Автор
      27.10.2019 10:08

      Статья и так получилась довольно большая, старался, как мог компактно и понятно всё разложить :-) с радостью подробнее расскажу про то, что вызвало вопросы. О чём бы хотелось больше пояснений?


  1. burusha16
    28.10.2019 18:30

    Почему не пойти дальше и перейти к полной динамике?
    Директива, которая рендерит шаблоны и компоненты, прокидывает в компоненты formControl(если они CVA) content-child и тд.
    И даже есть реализации этих директив @ngxd/core, ng-torque


    1. Waterplea Автор
      28.10.2019 18:35

      Можно заморочиться как угодно :) Описанный тут подход, по сути, полагается полностью на механизмы ангуляра и предоставляет необходимую гибкость. Мне его хватает, но посмотрю на предложенные реализации, спасибо!


  1. skoropadas
    29.10.2019 15:14

    Подскажите, немного не понял, чем не подходит для реализации всего выше перечисленного ng-content?


    1. Waterplea Автор
      29.10.2019 15:27

      По массе причин:
      1) ng-content не имеет контекста, не получится передать в компонент меню в контент какое-то содержимое и размножить его на каждый пункт, передавая пункт как контекст.
      2) У компонента может быть больше одной кастомизируемой «ячейки» — у того же меню это пункты меню и шаблон для отображения «Ничего не найдено». На практике таких мест часто 2-3.
      3) Не всегда кастомизация задаётся через шаблон, например содержимое для модального окна у нас тоже передаётся в виде такого гибкого контента — можно определить шаблон внутри компонента, вызывающего попап, можно сделать отдельный компонент попапа для переиспользования (например, просто запрос на Да/Нет от пользователя). Вызовы бывают из сервисов, где шаблонов вообще нет.
      4) Частный пример — компонент отображения ошибки. У нас он получает словарь для отображения типовых ошибок через DI. Встроенный валидатор Angular на максимальную длину создаёт ошибку вида `maxlength: { requiredLength: 12 }` — для отображения таких ошибок достаточно просто использовать функцию вида:
      (context: {requiredLength}) => `Превышена максимальная длина — ${context.requiredLength}`;
      А наши собственные валидаторы могут возвращать шаблоны или компоненты для стилизации сообщений, добавления к ним ссылок, подсказок и т.д.