Овладейте искусством проецирования контента Angular
Когда дело доходит до создания настраиваемых компонентов в Angular
, думайте об этом как о рецепте вашего утреннего кофе: у вас есть основа (@Input()
, @Output()
) и дополнительные компоненты (*ngIf
) для создания идеального напитка. Однако чем больше начинки вы добавляете в свой утренний кофе, тем больше ваш компонент становится слишком тесным для вашей базовой бизнес-логики.
В идеале задача компонента — обеспечивать только взаимодействие с пользователем.
В этом сообщении блога мы отодвигаем слои, чтобы раскрыть его неиспользованный потенциал для создания Angular
гибких и настраиваемых компонентов.
Начнем с Angular
проецирования контента.
1. Что такое проекция контента Angular? (ng-content)
Проекция контента — это шаблон, в котором вы вставляете или проецируете контент, который хотите использовать внутри другого компонента.
Чтобы проиллюстрировать эту концепцию, рассмотрим следующий пример:
@Component({
standalone: true,
selector: 'app-greeny',
template: `
<p>Welcome to Greeny Land 🍀!</p>
<ng-content></ng-content>
`,
})
export class GreenyComponent {}
@Component({
selector: 'my-app',
standalone: true,
imports: [CommonModule, GreenyComponent],
template: `
<app-greeny>
<p>I'm a Seeding 🌱, a new beginning and a fresh start.</p>
</app-greeny>
`,
})
export class App {}
bootstrapApplication(App);
Добавляя элементы <ng-content></ng-content>
в GreenyComponent
шаблон, вы обеспечиваете динамическое включение и плавное смешивание содержимого родительского компонента.
Welcome to Greeny Land 🍀!
I'm a Seeding 🌱, a new beginning and a fresh start.
2. Многослотовая проекция контента
Angular
расширяет проекцию контента за пределы своей базовой концепции, вводя проекцию контента с несколькими слотами, которая позволяет вставлять контент в определенные назначенные слоты внутри компонента, обеспечивая более точный контроль над настройкой.
Давайте рассмотрим эту расширенную функцию на практическом примере с использованием Pokémon API:
pokemon.component.ts
@Component({
standalone: true,
selector: 'app-pokemon',
template: `
<div class="header-wrapper">
<ng-content select=".pokemon-header"></ng-content>
</div>
<div class="detail-wrapper">
<ng-content select=".pokemon-detail"></ng-content>
</div>
`,
})
export class PokemonComponent {}
Мы определили PokemonComponent
поддержку проекций контента на несколько слотов, указав конкретный select
атрибут (селектор) для каждого <ng-content>
слота:
pokemon-header
pokemon-detail
Angular
поддерживает селекторы для любой комбинации имени тега, атрибута, класса CSS и псевдокласса :not
.
standard-pokemon.component.ts
Давайте создадим StandardPokemonComponent
, чтобы использовать проекцию контента с несколькими слотами:
@Component({
selector: 'app-standard-pokemon',
standalone: true,
imports: [PokemonComponent, TitleCasePipe],
template: `
<div class="standard">
<app-pokemon [class]="pokemon.type">
<div class="pokemon-header">
<div class="number"><small>#{{pokemon.id}}</small></div>
<img [src]="pokemon.image" [alt]="pokemon.name" />
</div>
<div class="pokemon-detail">
<h3>{{pokemon.name | titlecase}}</h3>
<small>Type: {{pokemon.type}}</small>
</div>
</app-pokemon>
</div>
`,
styleUrls: ['./standard-pokemon.component.scss'],
})
export class StandardPokemonComponent {
@Input() pokemon: any;
}
Чтобы проецировать содержимое в соответствующий слот, вам просто нужно определить <div>
элемент, содержащий класс pokemon-header
или pokemon-detail
.
pokemon-header
будет включать индекс и изображение покемона,
<div class="pokemon-header">
<div class="number"><small>#{{pokemon.id}}</small></div>
<img [src]="pokemon.image" [alt]="pokemon.name" />
</div>
В то время как pokemon-detail
будет содержать имя и тип покемона.
<div class="pokemon-detail">
<h3>{{pokemon.name | titlecase}}</h3>
<small>Type: {{pokemon.type}}</small>
</div>
Кроме того, мы отрисуем цвет фона в зависимости от типа покемона.
А теперь оживим наших покемонов:
@Component({
selector: 'my-app',
standalone: true,
imports: [CommonModule, StandardPokemonComponent],
template: `
<h1 class="title">Pokémon Evolution</h1>
<div class="container">
<ng-container *ngIf="pokemons$ | async as pokemonList">
<ng-container *ngFor="let pokemon of pokemons">
<app-standard-pokemon [pokemon]="pokemon"></app-standard-pokemon>
</ng-container>
</ng-container>
</div>
`,
styles: [...]
})
export class App {
pokemons$ = inject(PokemonService).getPokemons();
}
На данный момент мы узнали, как работает многослотовая проекция контента Angular. Однако какова в этом практическая польза? Почему бы нам просто не скомпоновать все внутри PokemonComponent
? Давайте рассмотрим эти вопросы в следующем разделе.
3. Пришло время сделать Content Projection блестящим
Предположим, мы получили новое требование от нашего начальника. Вместо того, чтобы просто поддерживать стандартных покемонов, он хочет, чтобы мы приняли современный стиль покемонов, а именно:
Давайте сначала посмотрим, как реализуется ModernPokemonComponent
:
@Component({
selector: 'app-modern-pokemon',
standalone: true,
imports: [PokemonComponent, TitleCasePipe],
template: `
<div class="modern">
<app-pokemon>
<div class="pokemon-header">
<div class="number"><small>{{pokemon.id}}</small></div>
<img pokemon-image [src]="pokemon.artwork" [alt]="pokemon.name" />
</div>
<div class="pokemon-detail">
<div class="headline">
<h3 class="name">{{pokemon.name | titlecase}}</h3>
<div class="type-wrapper">
<small class="type">{{pokemon.type | titlecase}}</small>
<small>{{getTypeEmoji(pokemon.type)}}</small>
</div>
</div>
<div class="stat-wrapper">
<div class="stat">
<span class="stat-number">{{pokemon.attack}}</span>
<span class="stat-name">Attack</span>
</div>
<div class="stat">
<span class="stat-number">{{pokemon.defense}}</span>
<span class="stat-name">Defense</span>
</div>
<div class="stat">
<span class="stat-number">{{pokemon.speed}}</span>
<span class="stat-name">Speed</span>
</div>
</div>
</div>
</app-pokemon>
</div>
`,
styleUrls: ['./modern-pokemon.component.scss'],
})
export class ModernPokemonComponent {
@Input() pokemon: any;
getTypeEmoji(type: string): string {
...
}
}
Заголовок Pokémon остается неизменным, а раздел сведений о Pokémon состоит из двух основных компонентов: headline
и stats
headline
<div class="headline">
<h3 class="name">{{pokemon.name | titlecase}}</h3>
<div class="type-wrapper">
<small class="type">{{pokemon.type | titlecase}}</small>
<small>{{getTypeEmoji(pokemon.type)}}</small>
</div>
</div>
stats
<div class="stat-wrapper">
<div class="stat">
<span class="stat-number">{{pokemon.attack}}</span>
<span class="stat-name">Attack</span>
</div>
<div class="stat">
<span class="stat-number">{{pokemon.defense}}</span>
<span class="stat-name">Defense</span>
</div>
<div class="stat">
<span class="stat-number">{{pokemon.speed}}</span>
<span class="stat-name">Speed</span>
</div>
</div>
Как видите, все pokemon-detail
совершенно разные. Однако для поддержки нам не нужно вносить какие-либо изменения в PokemonComponent
. Все обрабатывается отдельно внутри ModernPokemonComponent
. Таким образом, мы можем гарантировать, что StandardPokemonComponent
останется неизменным без каких-либо последствий.
Благодаря Content Projection мы можем разместить две разные карты покемонов совершенно разных стилей. Другими словами, Content Projection позволяет нам придерживаться принципа разделения ответственности в отношении пользовательского интерфейса. Пользовательские интерфейсы динамически управляются на основе конкретной бизнес-логики каждого компонента (StandardPokemonComponent
и ModernPokemonComponent
)
Последняя мысль
Спасибо, что дошли до конца! Content Projection — это фундаментальный инструмент, который помогает нам создавать гибкие компоненты. Мы продолжим эту серию, углубившись в еще один мощный инструмент Angular — *ngTemplateOutlet
.