Проекція вмісту (ng-content) в Angular
Проекція вмісту (ng-content) - це механізм, який дозволяє батьківському компоненту передавати довільний HTML у конкретні слоти, визначені в шаблоні дочірнього компонента.
Теорія
TL;DR
- Уяви дочірній компонент як шаблон журналу з позначеними вирізами: заголовок, тіло, підвал. Батько кидає контент у ці вирізи, не торкаючись верстки.
ng-contentпереносить реальні DOM-вузли з батька в дочірній, зберігаючи обробники подій, анімації та lifecycle-хуки.selectприймає CSS-селектори:[атрибут],.клас, або назву тегу.- Вибирай для UI-обгорток (картки, модалки, панелі). Для чистих даних -
@Input().
Швидкий приклад
// card.component.ts
@Component({
selector: 'app-card',
standalone: true,
template: `
<div class="card">
<header><ng-content select="[card-header]"></ng-content></header>
<main><ng-content select="[card-body]"></ng-content></main>
<footer><ng-content select="[card-footer]"></ng-content></footer>
</div>
`
})
export class CardComponent {}<!-- використання в батьківському компоненті -->
<app-card>
<h2 card-header>Профіль</h2>
<p card-body>Ім'я: Олексій</p>
<button card-footer (click)="editProfile()">Редагувати</button>
</app-card>Angular зіставляє кожен елемент зі слотом за значенням select і рендерить картку: заголовок у header, абзац у main, кнопку у footer. Обробник кліку залишається у батьку і спрацьовує нормально після проекції.
Ключова відмінність від @Input()
ng-content переміщує реальні DOM-вузли з батька в розмітку дочірнього компонента. Ці вузли залишаються живими: обробники подій спрацьовують, анімації відтворюються, вкладені компоненти продовжують свій lifecycle. @Input() передає дані, а дочірній компонент сам будує розмітку з них. Коли треба вставити розмітку батька в структуру дочірнього - вибирай ng-content.
Коли використовувати
- Перевикористовувані обгортки (картки, панелі, модалки): визначай слоти через
select, щоб кожна зона мала чіткого власника. - Динамічні списки елементів: один
<ng-content>безselect- батько повністю контролює шаблон кожного елемента. - Вбудовування сторонніх компонентів: проекція зберігає
ngOnInit/ngOnDestroyвбудованого компонента, тому його внутрішній стан залишається коректним. - Тільки дані: використовуй
@Input(). Немає DOM-накладних витрат, повна типобезпека, простіше тестувати.
Як Angular компілює ng-content
Під час компіляції шаблону Angular сканує теги <ng-content> і фіксує значення кожного select як CSS-селектор. Під час виконання він зіставляє вузли від батька з цими селекторами і вставляє відповідні DOM-фрагменти безпосередньо у вигляд (view) дочірнього компонента - без клонування і повторного рендеру. Вузли, що не підійшли жодному select, потрапляють у дефолтний <ng-content> (без атрибута select), якщо він є.
Довідник синтаксису select
<!-- За атрибутом (найпоширеніший варіант) -->
<ng-content select="[card-header]"></ng-content>
<!-- За CSS-класом -->
<ng-content select=".card-footer"></ng-content>
<!-- За назвою тегу -->
<ng-content select="h2"></ng-content>
<!-- За селектором компонента -->
<ng-content select="app-icon"></ng-content>
<!-- Дефолтний слот: забирає все, що не підійшло вище -->
<ng-content></ng-content>Типові помилки
1. Голий CSS-селектор тегу замість атрибута
<!-- Неправильно: не збігається з <h2 card-header>Заголовок</h2> -->
<ng-content select="h2"></ng-content>
<!-- Правильно: шукаємо за атрибутом -->
<ng-content select="[card-header]"></ng-content>
<!-- Або комбінуємо тег + атрибут для точності -->
<ng-content select="h2[card-header]"></ng-content>Значення select - це CSS-селектор. h2 збігається з будь-яким тегом <h2>. [card-header] збігається з будь-яким елементом, що має цей атрибут. Це різні речі.
2. Кілька <ng-content> без select
<!-- Неправильно: весь контент іде у перший слот, другий залишається порожнім -->
<header><ng-content></ng-content></header>
<main><ng-content></ng-content></main>
<!-- Правильно: кожен слот потребує власного селектора -->
<header><ng-content select="[card-header]"></ng-content></header>
<main><ng-content select="[card-body]"></ng-content></main>3. Проекція контенту під *ngIf, що на старті false
<!-- Ненадійно: якщо show спочатку false, вузла ще не існує і проектувати нічого -->
<app-card><p *ngIf="show">Умовний текст</p></app-card>
<!-- Правильно: обгортаємо у ng-container -->
<app-card>
<ng-container *ngIf="show"><p>Умовний текст</p></ng-container>
</app-card>4. Стилі дочірнього компонента не досягають проекційованих вузлів
ViewEncapsulation.Emulated (за замовчуванням) додає до стилів дочірнього компонента атрибутні селектори. Але проекційовані вузли належать view батьківського компонента, тому ці селектори до них не застосовуються. Щоб стилізувати проекційований контент з боку дочірнього компонента, або перенеси стилі у батька, або встанови encapsulation: ViewEncapsulation.None і використовуй точні селектори.
Доступ до проекційованого вмісту з TypeScript
import { Component, ContentChildren, QueryList, AfterContentInit } from '@angular/core';
import { TabComponent } from './tab.component';
@Component({
selector: 'app-tabs',
standalone: true,
template: `<ng-content></ng-content>`
})
export class TabsComponent implements AfterContentInit {
@ContentChildren(TabComponent) tabs!: QueryList<TabComponent>;
ngAfterContentInit() {
// проекційований контент доступний тут, але не в ngOnInit
if (!this.tabs.some(tab => tab.active)) {
this.tabs.first.active = true;
}
}
}Запитуй проекційовані компоненти в ngAfterContentInit. Якщо читати @ContentChildren в ngOnInit, повернеться порожній список - проекція ще не відбулась.
Де зустрічається на практиці
- Angular Material:
mat-cardслотує заголовок, підзаголовок, зображення та дії через іменованіng-contentселектори на зразок[mat-card-title]. - NG Bootstrap:
ngb-modalпроектує кастомний заголовок і підвал у іменовані слоти. - PrimeNG:
p-tableпоєднуєng-contentзng-templateта директивоюpTemplateдля визначення колонок і рядків. - Власні дизайн-системи: більшість команд будують картки, модалки і drawer-компоненти саме так, щоб продуктові розробники контролювали розмітку, не торкаючись внутрішньої верстки обгортки.
Питання на співбесіді
Q: У чому різниця між <ng-content> і *ngTemplateOutlet?
A: ng-content проектує наявні DOM-вузли батька у слот дочірнього компонента. *ngTemplateOutlet рендерить TemplateRef і може передати в нього контекстний об'єкт. Вибирай ng-content, коли батько сам описує розмітку. Вибирай ngTemplateOutlet, коли дочірній компонент має рендерити шаблон кілька разів або передавати в нього дані.
Q: Чи може проекційований контент пройти через кілька рівнів компонентів?
A: Так. Кожен проміжний компонент повинен мати власний <ng-content>, щоб передати контент далі. Angular тунелює вузли через кожен рівень до першого відповідного слота.
Q: Як проекція впливає на продуктивність порівняно з @Input()?
A: Проекція потребує більше DOM-операцій, ніж прив'язка даних, тому на великих списках з багатьма проекційованими вузлами різниця помітна. OnPush change detection на дочірньому компоненті зменшує зайві перевірки і компенсує більшу частину витрат.
Q: Як ViewEncapsulation впливає на стилізацію проекційованого контенту?
A: Emulated (за замовчуванням) обмежує стилі дочірнього компонента атрибутними селекторами, які не досягають проекційованих вузлів. ShadowDom повністю ізолює view. None вимикає інкапсуляцію. Для компонентів, яким треба стилізувати проекційовані слоти зсередини, None з точними селекторами - стандартний підхід.
Q: (Senior) Який lifecycle-хук спрацьовує, коли проекційований контент готовий, і чому це важливо?
A: ngAfterContentInit спрацьовує після того, як Angular завершив проекцію контенту у view. ngOnInit спрацьовує раніше, тому будь-який запит через @ContentChild або @ContentChildren там повертає нічого. ngAfterContentChecked спрацьовує після кожного циклу change detection, що зачіпає проекційований контент.
Приклади
Базовий: картка з одним слотом
@Component({
selector: 'app-card',
standalone: true,
template: `
<div class="card">
<ng-content></ng-content>
</div>
`
})
export class CardComponent {}<app-card>
<h2>Профіль користувача</h2>
<p>Ім'я: Олексій</p>
<button (click)="editProfile()">Редагувати</button>
</app-card>Обробник кліку живе у батьку і продовжує спрацьовувати після проекції кнопки. Якби передавати ім'я через @Input() і рендерити кнопку всередині дочірнього компонента, довелося б також виводити подію (click) назовні - більше коду, менше гнучкості.
Середній рівень: модалка з іменованими слотами
// modal.component.ts
@Component({
selector: 'app-modal',
standalone: true,
imports: [NgIf],
template: `
<div class="modal-overlay" *ngIf="open">
<div class="modal">
<ng-content select="[modal-title]"></ng-content>
<ng-content select="[modal-content]"></ng-content>
<ng-content select="[modal-actions]"></ng-content>
</div>
</div>
`
})
export class ModalComponent {
@Input() open = false;
}<app-modal [open]="isOpen">
<h1 modal-title>Підтвердити видалення</h1>
<p modal-content>Цю дію не можна скасувати.</p>
<div modal-actions>
<button (click)="confirmDelete()">Так, видалити</button>
<button (click)="isOpen = false">Скасувати</button>
</div>
</app-modal>Модалка відповідає за верстку і видимість. Батько відповідає за текст і логіку. Тестувати flow видалення можна без монтування внутрішнього устрою модалки.
Просунутий: вкладки з @ContentChildren
// tabs.component.ts
@Component({
selector: 'app-tabs',
standalone: true,
template: `
<div class="tab-bar">
<button
*ngFor="let tab of tabs"
(click)="activate(tab)"
[class.active]="tab.active">
{{ tab.title }}
</button>
</div>
<ng-content></ng-content>
`
})
export class TabsComponent implements AfterContentInit {
@ContentChildren(TabComponent) tabs!: QueryList<TabComponent>;
ngAfterContentInit() {
if (!this.tabs.some(t => t.active)) {
this.tabs.first.active = true;
}
}
activate(selected: TabComponent) {
this.tabs.forEach(tab => (tab.active = false));
selected.active = true;
}
}<app-tabs>
<app-tab title="Огляд">Контент огляду</app-tab>
<app-tab title="Налаштування">Контент налаштувань</app-tab>
<app-tab title="Оплата" [active]="true">Контент оплати</app-tab>
</app-tabs>QueryList оновлюється автоматично, коли вкладки додаються або видаляються динамічно. Саме це робить патерн придатним для списків вкладок, що змінюються в рантаймі.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.