13 — Content projection (ng-content) на пальцах

Цель: понять <ng-content> — как контент, который ты кладёшь между тегами компонента, попадает внутрь него; что узлы при этом переезжают, а не копируются; и кому они «принадлежат». Точные file:line — в notes/v21/.

Проблема, которую решает projection

Ты хочешь переиспользуемую «рамку» (карточка, модалка, кнопка), но содержимое каждый раз своё. Не передавать же весь HTML строкой. Решение: компонент оставляет «дырку», а пользователь вставляет туда свой контент.

<!-- card.html — шаблон компонента: -->
<div class="card">
  <div class="card-head">Заголовок</div>
  <ng-content></ng-content>          ← ДЫРКА: сюда упадёт чужой контент
</div>

<!-- использование снаружи: -->
<app-card>
  <p>Любой мой контент!</p>          ← это и есть «спроецированный контент»
</app-card>

Результат в DOM:

<app-card>
  <div class="card">
    <div class="card-head">Заголовок</div>
    <p>Любой мой контент!</p>        ← переехал в дырку
  </div>
</app-card>

Аналогия: компонент — это фоторамка, а <ng-content>стекло-окошко, куда вставляют твоё фото. Рамка одна, фото каждый раз разное.

Главное: узлы ПЕРЕЕЗЖАЮТ, а не копируются

Это ключевой момент. <ng-content> не дублирует твой контент — он физически перемещает уже существующие DOM-узлы в новое место.

Кадр 1. Angular видит контент между <app-card>...</app-card> — узлы созданы.
Кадр 2. projectionDef раскладывает их по слотам (по селекторам ng-content).
Кадр 3. projection переносит готовые DOM-узлы в позицию <ng-content>.
        Это ОДНИ И ТЕ ЖЕ узлы — не клоны.

Практическое следствие: нельзя использовать один <ng-content> дважды — узел один, он не может быть в двух местах. Для повторов нужен <ng-template> + *ngTemplateOutlet (см. 10-vstroennye-direktivy).

Мульти-слот: раскладываем по местам через select

Часто нужно несколько дырок: заголовок отдельно, тело отдельно, футер отдельно. Тогда у <ng-content> есть атрибут select с CSS-селектором:

<!-- card.html: -->
<header><ng-content select="[card-title]"></ng-content></header>
<main>  <ng-content></ng-content></main>           ← без select = «всё остальное»
<footer><ng-content select="app-actions"></ng-content></footer>

<!-- использование: -->
<app-card>
  <h2 card-title>Привет</h2>        → уедет в <header>
  <p>Тело карточки</p>             → уедет в <main> (дефолтный слот)
  <app-actions>...</app-actions>   → уедет в <footer>
</app-card>

Как это работает: на create-фазе projectionDef проходит все переданные узлы и для каждого ищет первый подходящий select. Что не подошло ни одному именованному слоту — падает в слот без select (дефолтный). Совпадение селектора считается так же, как для директив (см. 07-selektory) — сравнением с атрибутами/тегом узла, а не живым DOM-поиском.

ngProjectAs — «притворись другим селектором»

Иногда узел надо отправить в слот, под чей select он не подходит напрямую (например, обернул в <ng-container>). Тогда ngProjectAs заставляет узел «выдать себя» за нужный селектор:

<ng-container ngProjectAs="[card-title]">
  <h2>Заголовок из контейнера</h2>
</ng-container>
<!-- хоть это <ng-container>, для слота он матчится как [card-title] -->

Кому принадлежит спроецированный контент

Тонкий, но важный момент. Контент написан у родителя, а показан у потомка. Кому он «принадлежит»?

Спроецированный контент принадлежит родителю (тому, кто его написал) — его declaration view. У потомка он только отображается.

Из этого вытекает всё «странное» поведение:

• Биндинги в контенте считаются в контексте РОДИТЕЛЯ:
  <app-card>{{ parentField }}</app-card>  — parentField берётся у родителя, не у card.

• DI: сервис, который инжектит спроецированный контент, ищется по дереву РОДИТЕЛЯ
  (см. [[09-di-injectory]]), а не потомка.

• Lifecycle: change detection контента идёт вместе с родителем.

Поэтому у компонента-обёртки (card) есть отдельный хук ngAfterContentInit (см. 11-lifecycle) — «мой спроецированный контент готов и вставлен». А contentChild (см. 12-queries) ищет именно в этом — в спроецированном контенте.

А что в новом коде?

<ng-content> — это по-прежнему актуальный, не deprecated механизм (в отличие от *ngIf/*ngFor). Control flow (@if/@for) и projection — про разное:

  • @if/@for — управляют своим шаблоном;
  • <ng-content> — принимает чужой контент снаружи.

Новое рядом: можно задать fallback — что показать, если в слот ничего не передали (содержимое внутри тега <ng-content>...</ng-content>).

Карта

родитель пишет контент между <app-card> ... </app-card>
        │  (узлы созданы в declaration view родителя)
        ▼
projectionDef (create): разложить узлы по слотам по select=""
        ▼
projection (<ng-content>): ПЕРЕНЕСТИ (не копировать) узлы в дырку
        ▼
DOM: узлы видны внутри потомка,
     НО принадлежат родителю (биндинги/DI/CD — родительские)
        ▼
у потомка: ngAfterContentInit + contentChild видят этот контент

Итог

  • <ng-content> — «дырка» в шаблоне, куда переезжает контент, переданный между тегами.
  • Узлы перемещаются, а не копируются → один <ng-content> нельзя задействовать дважды.
  • select="..." раскладывает по слотам; без select — дефолтный слот «всё остальное»; ngProjectAs позволяет узлу притвориться нужным селектором.
  • Контент принадлежит родителю (биндинги/DI/CD — его), у потомка только отображается → отсюда ngAfterContentInit и contentChild.

Дальше → 14-host-bindings: как директива управляет своим собственным тегом и «приклеивает» к нему другие директивы.