Проекция контента — одна из базовых возможностей Angular, о которой слышали почти все. А с недавних пор по ней появилась и хорошая официальная документация. Тем не менее в реальных задачах разработчики часто обходят ng-content стороной, прибегая к более сложным и перегруженным решениям и усложняя дальнейшее использование и поддержку компонента.

В этой статье я хотел бы показать несколько типовых кейсов для ng-content при разработке многократно используемых компонентов. А еще — рассказать о преимуществах, которые они могут нам дать.

Базовые понятия

Давайте заглянем в минимальный багаж знаний по работе с ng-content. Если вы уже ас в ng-content, то можно перейти к следующей главе. А если впервые все это видите, то советую сначала прочитать документацию или статью моего коллеги.

В шаблоне любого нашего компонента мы можем написать тег <ng-content></ng-content>. В то место, где написан тег, будет проецироваться весь передаваемый контент.

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

<some-angular-component [input]=”inputData”>
    <p>Here is any HTML content coming into ng-content</p>
    <button class=”action”>Click me!</button>
</some-angular-component>

Так весь контент спроецируется внутрь и вставится в DOM в том порядке, в котором написан. 

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

<ng-content select=”button”></ng-content>
<p>
	Hello everyone!
</p>
<ng-content></ng-content>

В таком случае сначала сверху сложатся все кнопки, а весь оставшийся контент (не кнопки) попадет в ng-content без select.

Самого тега ng-content не будет в реальном DOM, на него нельзя повесить класс или Angular-директиву. Это лишь плейсхолдер для контента. Сам контент внутри не перерендеривается. Если скрыть ng-content под ngIf и после показать снова, то нам спроецируют то же самое содержимое, а не новое.

Еще для альтернативного проброса есть атрибут ngProjectAs, но мы им сегодня пользоваться не будем.

Пример интересной задачи

Я не хочу рассматривать базовые примеры с ng-content вроде содержимого кнопки, в которую потом обязательно захотят добавить иконку, или лейбла для поля ввода. Давайте лучше сразу разберем сложный пример, который покрывает как простые, так и не самые очевидные случаи применения ng-content, и постараемся натренировать своего рода «чуйку» на варианты его использования.

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

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

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

Как бы вы стали решать такую задачу в реальной жизни? Какое API было бы у вашего компонента?

Ловушка интерфейса. Одно из первых решений, которое приходит в голову, — это некий ангуляровский инпут с интерфейсом. Казалось бы, базовая ситуация: у нас может быть одна картинка, может быть несколько, у них есть названия, а в будущем еще могут понадобиться и дополнительные сведения. Предусмотрим интерфейс Image и будем принимать массив из таких интерфейсов в наш компонент.

export interface Image {
	readonly title: string;
	readonly src: string;
}

и компонент:

export class PreviewComponent {
	@Input()
	images: readonly Image[] = [];
}

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

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

export interface PreviewAction {
   readonly icon: string;
   readonly title: string;
   readonly onClick: (index: number) => void;
}

export class PreviewComponent {
  // ...
	@Input()
	actions: readonly PreviewAction[] = [];
}

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

На мой взгляд, основной минус этого решения — мы стали узниками наших интерфейсов. Мой опыт разработки многократно используемых компонентов говорит о том, что нет смысла заранее пытаться просчитать и заложить все возможные варианты применения: все равно кто-то захочет иное! Лучше давать пользователю компонента как можно больше гибкости в API и свободе использования. С этой точки зрения интерфейсный подход сулит ограничения, давайте посмотрим два контрпримера.

Не все картинки есть сразу. Допустим, кто-то решил использовать галерею для папки с большим количеством фотографий. У разработчика есть информация, что всего фотографий 5 тысяч, но их названия и ссылки на картинки он лениво подгружает по мере необходимости. Как бы нам заложить это в компонент?

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

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

Действие с условием. А теперь нам захотелось добавить новое действие редактирования цвета, но актуально оно будет только для картинок формата SVG. Сейчас можно отображать кнопку всегда и делать проверку на onClick: если юзер кликнул на редактирование цвета, а это не SVG, то не делаем ничего. Но это уже жертва UX: хотелось бы не показывать кнопку вовсе или дизейблить ее.

Сейчас действие само по себе не знает об активной картинке. Для этого нам нужно либо слушать ее переключение и перестраивать массив actions на основе новой картинки в родительском компоненте, либо добавлять дополнительные хендлеры в интерфейс, которые будут вызываться при переключении и возвращать boolean: задизейблен ли элемент для текущей картинки или нет.

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

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

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

Компоненты-конструкторы

Давайте вернемся к исходной задаче и попробуем реализовать компонент заново, с меньшим количеством ограничений и лишь с помощью базовых возможностей ng-content в Angular.

В первую очередь нам нужно уметь показывать переданную картинку. Это и есть ng-content, который мы кладем в компонент и центрируем с помощью CSS.

<section
  #contentWrapper
  class="wrapper"
  ...
>
  <ng-content></ng-content>
</section>

Шаблон компонента preview

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

Одну картинку мы показывать научились, а как работать с массивом? А с массивом нам работать и не нужно, компоненту незачем знать о том, что где-то есть массив. Ему дают контент извне — он его показывает.

Элемент pagination мы можем также легко вынести наружу и позволить пользователю компонента самому показывать счетчик и следить за переключением в удобной ему форме.

Внутри компонента preview останется лишь забить ячейку в верстке для переключателя контента.

<section
  #contentWrapper
  ...
>
  <ng-content></ng-content>
</section>

<header class="header">
  ...
  <ng-content select="preview-pagination"></ng-content>
  ...
</header>

Теперь, чтобы добавить переключатель к превью, нам нужно всего лишь добавить его в контент.

<preview class="preview tui-space_top-5" [rotatable]="true">
   ...
   <preview-pagination
     [lastIndex]="cats.length - 1"
     [(index)]="activeCat"
   ></preview-pagination>
   ...
 </preview>

Кстати, сейчас preview-pagination берет на себя ответственность за показ чисел i/n и эмитит изменение индекса.

Альтернативно этот компонент тоже можно сделать через ng-content, чтобы разработчики сами выводили цифры в удобном им формате: может, кто-нибудь захочет сделать календарь, показывая там месяц? В таком случае наружу будет эмититься относительное изменение индекса: −1 или +1. Что-то вроде такого.

<preview class="preview tui-space_top-5" [rotatable]="true">
   ...
   <preview-pagination
     (change)="onIndexChange($event)"
   >
      {{activeCat + 1}} / {{cats.length}}
   </preview-pagination>
   ...
 </preview>

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

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

А вот плашка с названием и кнопочки с действиями — отличные кандидаты на вынос наружу. Так сам preview внутри будет фактически представлять собой набор лунок для ng-content и кусочков своей логики.

<section
  #contentWrapper
  class="wrapper"
  ...
>
    <ng-content></ng-content>
</section>

<header class="header">
 <div class="title">
   <ng-content select="preview-title"></ng-content>
 </div>

 <ng-content select="preview-pagination"></ng-content>

 <div class="actions">
   <ng-content select="preview-action"></ng-content>
 </div>
</header>

<footer class="footer">
 <preview-action
   *ngIf="rotatable"
   icon="tuiIconUndo"
   (click)="rotate()"
 ></preview-action>

 <preview-zoom
   *ngIf="zoomable"
   [min]="minZoom"
   [value]="zoom$ | async"
   (valueChange)="zoom$.next($event)"
 ></preview-zoom>
</footer>

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

<preview class="preview tui-space_top-5" [rotatable]="true">
   <ng-container *ngIf="cats.length; else fallback">
     <img width="500" [src]="cats[activeCat]" />
     <label *ngIf="withDescription">
       Cool cat #{{activeCat + 1}}
     </label>
   </ng-container>
   <ng-template #fallback>
     No cats, no party...
   </ng-template>

   <preview-pagination
     [lastIndex]="cats.length - 1"
     [(index)]="activeCat"
   ></preview-pagination>

   <preview-title>cat{{activeCat + 1}}.jpg</preview-title>

   <preview-action 
     title="like"
     icon="tuiIconHeart"
     (click)=”likeCat(activeCat)
   ></preview-action>
   <preview-action
     title="remove"
     icon="tuiIconTrash"
     (click)="removeCat(activeCat)"
   ></preview-action>
</preview>

Здесь можно поковырять StackBlitz с полной имплементацией компонента и его частей.

Анализ примера — и его плюсов

Мы получили интересный компонент на ng-content, который легко подстраивается под неожиданные ситуации.

Обе беды из главы про интерфейсы здесь даже нельзя назвать проблемами: чтобы предусмотреть большую галерею, нам нужно передать другое число в pagination, а для добавления экшенов с условием достаточно в прямом смысле навесить условие через *ngIf. Сделать это можно интуитивно, использующим компонент разработчикам даже не придется обращаться к создателям самого компонента, тратя и свое, и их время.

Компонент может принимать в себя любой контент, и это сразу дает целый ряд новых возможностей. Для красивой загрузки большого объема контента можно предусмотреть лоадер — preview-loading в примере на StackBlitz. Если среди файлов есть открываемый и неоткрываемый контент, то можно предусмотреть для неоткрываемого фоллбэк с объяснениями и, например, кнопкой скачивания. В целом сейчас ничего не мешает разработчикам и подкладывать любой свой самописный компонент в preview — например, пагинацию, — подменивая им дефолтный поставляемый. Нужно лишь соблюсти контракт селектора.

Еще один не самый очевидный плюс связан с возможностью уменьшения размера бандла нашего приложения. Если мы делаем большой компонент-конструктор, то можем каждый кусочек поставлять в отдельном модуле. В нашем примере такими отдельными модулями были бы preview-title, preview-action, preview-pagination и preview-loading. Если разработчику нужна пагинация, то он импортит ее модуль вместе с модулем preview. Если не нужна, то не импортит и неиспользованный модуль будет обязательно стришейкан Angular. Так с компонентом-конструктором едут только те детали, которые ему нужны в конкретном случае.

Итого

При мысли добавить интерфейс, который будет нужен лишь для одного компонента, я всегда задумываюсь: вероятно, так я наложу ряд сильных ограничений на работу с ним на самом старте и это аукнется мне позже, когда переделывать будет уже слишком поздно. При этом в Angular есть альтернативные пути решения, которые могут лечь на задачу гораздо лучше. Часто одним из таких путей и оказывается ng-content.

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

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


  1. wdhappyk
    19.08.2021 20:44

    Спасибо, хорошая и познавательная статья. Если поработать с библиотекой devextreme, то можно рассмотреть ещё больше примеров того, как сделать передачу данных в компонент в декларативном стиле и при желании переключиться на стиль "инпут с интерфейсом".