Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Проекція вмісту (ng-content) в Angular». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Проекція вмісту (ng-content)** дозволяє батьківському компоненту передати HTML у конкретні слоти, визначені в шаблоні дочірнього компонента. ```typescript @Component({ selector: 'app-card', template: ` <header><ng-content select="[card-header]"></ng-content></header> <main><ng-content select="[card-body]"></ng-content></main> ` }) export class CardComponent {} ``` **Головне:** проекційовані вузли залишаються живими - їхні обробники подій працюють, а lifecycle-хуки спрацьовують як завжди. Для кількох слотів використовуй `select`, для доступу до проекційованих компонентів - `@ContentChildren` в `ngAfterContentInit`.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Проекція вмісту (ng-content)** - це механізм, який дозволяє батьківському компоненту передавати довільний HTML у конкретні слоти, визначені в шаблоні дочірнього компонента. ## Теорія ### TL;DR - Уяви дочірній компонент як шаблон журналу з позначеними вирізами: заголовок, тіло, підвал. Батько кидає контент у ці вирізи, не торкаючись верстки. - `ng-content` переносить реальні DOM-вузли з батька в дочірній, зберігаючи обробники подій, анімації та lifecycle-хуки. - `select` приймає CSS-селектори: `[атрибут]`, `.клас`, або назву тегу. - Вибирай для UI-обгорток (картки, модалки, панелі). Для чистих даних - `@Input()`. ### Швидкий приклад ```typescript // 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 {} ``` ```html <!-- використання в батьківському компоненті --> <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 ```html <!-- За атрибутом (найпоширеніший варіант) --> <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-селектор тегу замість атрибута** ```html <!-- Неправильно: не збігається з <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`** ```html <!-- Неправильно: весь контент іде у перший слот, другий залишається порожнім --> <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** ```html <!-- Ненадійно: якщо 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 ```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, що зачіпає проекційований контент. ## Приклади ### Базовий: картка з одним слотом ```typescript @Component({ selector: 'app-card', standalone: true, template: ` <div class="card"> <ng-content></ng-content> </div> ` }) export class CardComponent {} ``` ```html <app-card> <h2>Профіль користувача</h2> <p>Ім'я: Олексій</p> <button (click)="editProfile()">Редагувати</button> </app-card> ``` Обробник кліку живе у батьку і продовжує спрацьовувати після проекції кнопки. Якби передавати ім'я через `@Input()` і рендерити кнопку всередині дочірнього компонента, довелося б також виводити подію `(click)` назовні - більше коду, менше гнучкості. ### Середній рівень: модалка з іменованими слотами ```typescript // 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; } ``` ```html <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 ```typescript // 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; } } ``` ```html <app-tabs> <app-tab title="Огляд">Контент огляду</app-tab> <app-tab title="Налаштування">Контент налаштувань</app-tab> <app-tab title="Оплата" [active]="true">Контент оплати</app-tab> </app-tabs> ``` `QueryList` оновлюється автоматично, коли вкладки додаються або видаляються динамічно. Саме це робить патерн придатним для списків вкладок, що змінюються в рантаймі.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.