Создание многократно используемых компонентов требует соблюдения баланса между гибкостью и техническими требованиями, особенно когда пользователям необходимо самостоятельно определять контент. Разрабатывая UI-библиотеку для Angular, я часто сталкивался с этой проблемой и искал различные пути ее решения.
Рассмотрим ее с точки зрения разработчиков UI-библиотек, сосредоточившись на важности предоставления пользователям гибкой настройки API.
Ng-content
Сразу перейдем к практическому примеру. Предположим, мы разрабатываем компонент Field, который отображает ввод. Как правило, элемент ввода редко используется сам по себе. В некоторых случаях необходимо включить метку (label) с автоматической привязкой атрибута [for], отобразить обратную связь, например сообщение об ошибке (error message), если поле недействительно, или предоставить текст подсказки (hint). Иногда может потребоваться добавить иконку (icon) или интерактивный элемент с одной стороны поля ввода.

Учитывая дизайн Field, можно определить ключевые области контента, которые должны быть открыты для настройки разработчиком:

Теперь нужно определиться с подходом, который позволит заполнить эти области контентом. Начинающие разработчики иногда используют функцию input для передачи текстового контента, например метки или текста ошибки/подсказки. Однако это не самый гибкий способ работы с контентом, поскольку он существенно ограничивает нас. Даже такое, казалось бы, незначительное требование, как добавление иконки (как показано в примере, где в подсказке используется иконка ключа >), может застать нас врасплох.
Естественным и гибким подходом является проекция контента. Он позволяет определить часть шаблона, которая будет вставлена в нужную область. Angular, как и другие современные фреймворки, перенял концепцию слотов из Web Components и позволяет определять плейсхолдеры для каждой области контента в компоненте с помощью ng-content с атрибутом [select] (по аналогии с именованными слотами).
Псевдокод шаблона, использующего этот подход, может выглядеть следующим образом (селекторы [attribute] используются в этом примере для краткости):
<label [attr.for]="id" class="label">
<ng-content select="[label]" />
</label>
<div class="field">
<div class="prefix">
<ng-content select="[prefix]" />
</div>
<div class="infix">
<!-- Делегируйте определение собственных <input> или <textarea> разработчикам.
developers. Это позволяет им напрямую привязывать атрибуты доступности, применять директивы форм и так далее.
-->
<ng-content select="input, textarea" />
</div>
<div class="suffix">
<ng-content select="[suffix]" />
</div>
</div>
<div class="subscript">
<div class="error">
<ng-content select="[error]" />
</div>
<div class="hint">
<ng-content select="[hint]" />
</div>
</div>
А с точки зрения пользователя компонента, его определение может выглядеть примерно так:
<app-field>
<!-- проекция узла текста -->
<ng-container label>Label</ng-container>
<input ... >
<!-- проекция узла элемента -->
<b hint>Hint</b>
</app-field>
Однако пользователю компонента может не потребоваться определять контент для всех существующих слотов. Это приводит к следующим проблемам:
- Слот, который не определен, не должен создавать дополнительный узел или, по крайней мере, не должен влиять на компоновку макета (например, контейнеры слотов
[prefix]/[suffix]могут добавлять дополнительные отступы к элементу ввода).
- Узлы, связанные с неопределенным слотом, также не должны влиять на работу программ чтения с экрана или других вспомогательных технологий (если элемент-контейнер контента потенциального слота остается в дереве).
На этом этапе мы сталкиваемся с ограничением: нет способа обнаружить во время выполнения, был ли определен конкретный ng-content для компонента.
Обходное решение с использованием CSS
Наличие контента внутри соответствующего элемента-контейнера можно определить с помощью CSS. Используя псевдокласс :empty и display: none, можно удалить контейнер из потока документов, если внутри нет контента (более того, он также будет исключен из дерева доступности):
.suffix:empty {
display: none;
}
Псевдокласс :empty работает не только с узлами элементов, но и с текстом, а это именно то, что нам нужно. Такой подход используется, например, в Angular Material для скрытия пустой метки <mat-checkbox>, при этом добавляется ненужный отступ.
Относительно новый :has (с точки зрения поддержки основными браузерами) позволяет манипулировать стилями через родителя. Если разработчик добавляет контент в слот [suffix], может понадобиться изменить отступ поля ввода, чтобы обеспечить правильное выравнивание и отображение:
:host:has(.suffix:not(:empty)) .infix {
padding-inline-end: 2rem;
}
Важно не злоупотреблять такими селекторами из-за их высокой специфичности. Например, описанный выше селектор имеет специфичность 0.4.0 (проверьте это), что делает его довольно сложным для переопределения.
Обходное решение с использованием CSS является довольно ситуативным и не всегда позволяет эффективно управлять слотами компонентов. Это касается и потенциально «тяжелых» селекторов в сложных сценариях, и одной только технологии CSS не всегда бывает достаточно. В идеале необходимо контролировать наличие контента на уровне TS.
Ng-template
Фрагменты шаблонов предлагают еще один вариант проекции контента, дающий возможность осуществлять операции во время выполнения. Такой подход позволяет избежать вставки в дерево узлов, связанных с неопределенными слотами. Например:
readonly suffix = contentChild('suffix', { read: TemplateRef });
А затем в шаблоне:
@if (suffix(); as template) {
<div class="suffix">
<ng-container *ngTemplateOutlet="template" />
</div>
}
Кроме того, он позволяет передавать в слот контекст, что в некоторых случаях становится чрезвычайно мощным инструментом настройки:
<ng-container *ngTemplateOutlet="template; context: { ... }" />
Как показано в ожидаемом результате Field, имеется особое требование к кнопке, отображаемой в слоте [suffix]: если элемент управления недействителен, кнопка должна окрашиваться в красный цвет. При наличии прямого доступа к экземпляру FormControl это было бы просто. Но становится довольно утомительным, если глубоко вложенный элемент управления привязывается из FormArray или FormGroup по его имени.
Использование ng-template позволяет использовать контекст и передавать соответствующий экземпляр элемента управления в слот. Концептуально слот не только служит плейсхолдером для контента — он также обладает определенной полезной нагрузкой, которую можно использовать.

Теперь разработчик может определить слот #suffix и получить доступ к данным из контекста с помощью let-*:
<app-field>
...
<input formControlName="name">
<ng-template #suffix let-control>
<button [class.bg-red]="control.invalid"
[attr.disabled]="control.disabled || null"
... >
<app-icon icon="search" />
</button>
</ng-template>
</app-field>
Не просто слот, а настраиваемый шаблон
Здесь стоит обсудить ментальную модель ng-template, которая мало чем отличается от ng-content. Благодаря контексту, шаблоны (templates) могут служить не только для вставки чего-либо в слот, но и для изменения того, как компонент отображает существующий контент. Например, при использовании компонента Calendar можно с помощью контекста настроить рендеринг отдельных ячеек дня.

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

По сути, слот уже содержит определенное содержимое (номер дня), но ng-template позволяет переопределить его шаблон отображения на основе пользовательской логики разработчика. Псевдокод использования:
<app-calendar>
...
<ng-template #cell let-day>
<div [appTooltip]="day.disabled ? 'Not available' : null">
@if (day.isLastDay) {
🌚
}
<span [class.line-through]="day.isAdjacent">{{ day }}</span>
@if (day.isFirstDay) {
🌝
}
</div>
</ng-template>
</app-calendar>
Этот подход является основой для создания многократно используемых компонентов в Angular и применяется почти всеми существующими UI-библиотеками в экосистеме.
Объединим усилия
После изучения принципов существующих подходов к проецированию контента возникает логичный вопрос: когда следует применять каждый из них? На практике при разработке UI-компонентов оба способа часто идут рука об руку, и выбор во многом зависит от конкретного случая использования.
Рассмотрим еще несколько реальных примеров.
Компонент Comparator

- Comparator имеет 2 слота, которые всегда будут определены в соответствии с дизайном компонента.
- Контент слотов не подвержен условному рендерингу и может быть инициализирован сразу вместе с представлением компонента.
- Оба слота также не требуют никакого контекста.
Это наглядный пример применения ng-content. Псевдокод использования:
<app-comparator>
<img left src="1.jpg" alt="1" >
<img right src="2.jpg" alt="2" >
</app-comparator>
Компонент Accordion

Accordion — отличный пример вариативности подходов. Складной элемент имеет 3 слота: title, icon и content.

- Слот title часто является обычным текстом, но важно не ограничивать гибкость пользователей компонента. Вместо того чтобы использовать ввод, лучше предложить слот с помощью
ng-content:
<app-accordion-item>
Title <!-- Предполагается, что это не именованный слот -->
</app-accordion-item>
- Слот icon содержит контент по умолчанию — шеврон. Хотя в Angular 18 появилась возможность определять резервный контент для
ng-content, этот слот мог бы использовать контекст, например, позволяющий определить, является ли текущий элемент открытым или закрытым. В этом случае разумным подходом будет настройка с помощьюng-template:
<app-accordion-item>
Title
<ng-template #icon let-open>
{{ open ? '🙉' : '🙈' }}
</ng-template>
</app-accordion-item>
- Слот content — один из тех случаев, когда необходимы оба подхода.
ng-contentпозволяет немедленно инициализировать контент, даже если он скрыт внутри родителя (с помощью@if/@switch). Он применяется к проецируемым дочерним компонентам, жизненный цикл которых начинается сразу после их проецирования в слот. С другой стороны,ng-templateинициализирует контент лениво — только когда шаблон вставляется в дерево. Стоит предложить пользователям оба варианта на выбор:
<app-accordion-item>
Title
<ng-template #icon let-open>
{{ open ? '🙉' : '🙈' }}
</ng-template>
<ng-container content>
... Eager content
</ng-container>
<ng-template #content>
... Lazy content
</ng-template>
</app-accordion-item>
Унифицированное использование
При работе с проекцией контента в UI-библиотеке моя команда столкнулась с некоторыми неудобствами из-за отсутствия унифицированного метода определения именованных слотов.
Обращаясь к компоненту Field, мы могли бы создать специальные директивы, такие как [appLabel], [appSuffix] и так далее. Это позволило бы нам в случае ng-content иметь более безопасный селектор (поскольку при выборе по атрибуту с одним словом можно столкнуться с коллизиями с атрибутами нативного HTML5). Кроме того, если иметь дело с ng-template, директиву можно прикрепить к <ng-template /> и запрашивать ее с помощью contentChild по локатору директивы. Псевдокод использования:
<app-field>
...
<span appLabel>Label</span>
<ng-template appSuffix let-ctx>...</ng-template>
</app-field>
Применяя изначально подобный подход, позже мы решили использовать единый универсальный селектор для всех компонентов со слотами:

Важно отметить, что в примерах я использую селектор slot-* без каких-либо префиксов. Однако при разработке системы стоит учитывать префикс, чтобы избежать конфликтов с нативным атрибутом slot. Например, в Vue используется именование v-slot (возможно, когда-нибудь мы увидим ng-slot «из коробки» в Angular?).
В первую очередь это позволяет четко идентифицировать проецируемый контент при использовании компонента:
<app-field>
...
<span slot="label">Label</span>
<ng-template slot="suffix" let-ctx>...</ng-template>
</app-field>
Кроме того, при документировании компонентов становится проще описывать проецируемый контент — достаточно указать имена слотов и их контекст, если это применимо.
Реализация
При использовании ng-content реализация проста:
<ng-content select="[slot='name']" />
При использовании шаблонов можно создать легковесную директиву, которая предоставляет токен SLOT (чтобы абстрагироваться от конкретной реализации) и принимает имя слота в качестве входных данных:
export const SLOT = new InjectionToken<Slot>('SLOT');
@Directive({
selector: 'ng-template[slot]',
providers: [{ provide: SLOT, useExisting: Slot }],
})
export class Slot {
readonly template = inject(TemplateRef);
readonly name = input.required<string>({ alias: 'slot' });
}
В UI-компоненте с проецируемым контентом можно запросить список слотов по локатору токена через contentChildren:
readonly slots = contentChildren(SLOT);
Функция contentChildren должна вызываться только в инициализаторе члена класса, что не позволяет напрямую использовать ее в обертке для преобразования результата в запись в .ts. Нам представилось удобным создать вспомогательный конвейер для преобразования непосредственно в шаблоне, избежав необходимости в отдельном свойстве класса:
@Pipe({ name: 'asRecord' })
export class SlotsAsRecordPipe implements PipeTransform {
transform(slots: readonly Slot[]): Record<string, TemplateRef<unknown> | undefined> {
return Object.fromEntries(slots.map(slot => [slot.name(), slot.template]));
}
}
И вот мы имеем запись всех пользовательских слотов шаблона:
@let templates = slots() | asRecord;
@if (templates.label; as label) {
<label [attr.for]="id" class="label">
<ng-container *ngTemplateOutlet="label; context: { ... }" />
</label>
}
@if (templates.suffix; as suffix) {
<div class="suffix">
<ng-container *ngTemplateOutlet="suffix; context: { ... }" />
</div>
}
...
По сути, мы приходим к чему-то вроде Conditional Slots в Vue, где $slots позволяет правильно настроить рендеринг на основе наличия определенного слота.
Заключение
Эта статья не является глубоким погружением в проекцию контента на уровне исходного кода, а скорее размышлением о том, как этот фундаментальный механизм работает в Angular. Хотя высказанные соображения не всегда могут быть приоритетными при написании кода приложения, их необходимо учитывать при проектировании системы.
Подход с унифицированным определением слотов оказался достаточно гибким, и я уверенно использую его при разработке API для низкоуровневых компонентов. Однако единственно верного решения не существует — способ работы с проекцией контента в Angular в конечном итоге определяется архитектурой конкретной системы.
Читайте также:
- RxSignals: самая мощная синергия в истории Angular
- 5 правил ESLint для применения новейших возможностей Angular
- Как сделать интернет-магазин из Spring Boot, Angular, MySQL и Jasper Reports
Читайте нас в Telegram, VK и Дзен
Перевод статьи Vyacheslav Borodin: Slots: Make your Angular API flexible





