При изучении Angular очень часто упускают или уделяют недостаточное внимание такому понятию, как проекция контента. Это очень мощный инструмент для создания гибких и переиспользуемых компонентов. Но в документации о нем упоминается лишь пару абзацев в разделе Lifecycle hooks. Попробуем исправить данное упущение.



Проекция контента с помощью ng-content


Проекция контента — это способ импортировать HTML контент извне компонента и вставить его в шаблон компонента в определенное место. (вольный перевод документации)
Определение довольно сложное, но на деле все гораздо проще. У нас есть какой-то компонент, и все, что находится между его открывающим и закрывающим тегом, является контентом.

<app-parent>
    <!-- content -->
    I'm content!
    <!-- content -->
</app-parent>

И Angular позволяет вставлять в шаблон этого компонента любой HTML код (контент) с помощью элемента ng-content.

Давайте попробуем разобраться, зачем это нужно и как это работает на примере. Допустим, у нас есть простой компонент кнопки. Текст этой кнопки мы передаем в шаблон через input property.

// button.component.ts
import {Component, Input} from '@angular/core';

@Component({
  selector: 'app-button',
  template: '<button>{{text}}</button>'
})
export class ButtonComponent {
  @Input() text: string;
}

// app.component.ts
import {Component} from '@angular/core';

@Component({
  selector: 'app-root',
  template: `<app-button [text]="'Button'"></app-button>`,
})
export class AppComponent {
}

Вроде выглядит неплохо. Но вдруг нам понадобилось для некоторых кнопок добавить к тексту иконку. У нас уже есть компонент иконки. Нужно просто добавить его в шаблон кнопки, навесить директиву ngIf и написать еще одно input property для динамического отображение иконки.

// icon.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-icon',
  template: 'O',
})
export class IconComponent {
}

// button.component.ts
import {Component, Input} from '@angular/core';

@Component({
  selector: 'app-button',
  template: `<button>
               <app-icon *ngIf="showIcon"></app-icon>
               {{text}}
             </button>`,
})
export class ButtonComponent {
  @Input() text: string;
  @Input() showIcon = true;
}

Все работает. Но что будет, если нужно поменять расположение иконки относительно текста? Или добавить еще какой-нибудь новый элемент? Придется править существующий код, добавлять новые свойства и т.д.

Всего этого можно избежать с помощью ng-content. Его можно рассматривать как placeholder для контента. Он отображает все, что вы положите между открывающим и закрывающим тегами компонента.

// button.component.ts
import {Component} from '@angular/core';

@Component({
  selector: 'app-button',
  template: `<button>
               <ng-content></ng-content>
             </button>`,
})
export class ButtonComponent {
}

// app.component.ts
import {Component} from '@angular/core';

@Component({
  selector: 'app-root',
  template: `<app-button>
               <app-icon></app-icon>
               Button
             </app-button>`,
})
export class AppComponent {
}

Код на Stackblitz

Теперь, если нам понадобилась кнопка с иконкой, мы просто помещаем компонент иконки между тегами кнопки. Можно добавить что угодно и как угодно. Это ли не рай? Наш компонент кнопки стал гибким и красивым.

Какую роль играет атрибут select для ng-content?


Иногда нам нужно расположить какой-то контент в определенном месте относительно всего остального контента, в этом случае можно использовать атрибут select, который принимает в себя селектор (.some-class, some-tag, [some-attr]).

// button.component.ts
import {Component} from '@angular/core';

@Component({
  selector: 'app-button',
  template: `<button>
               <ng-content></ng-content>
               <div>
                 <ng-content select="app-icon"></ng-content>
               </div>
             </button>`,
})
export class ButtonComponent {
}

Код на Stackblitz

Сейчас иконка показывается всегда снизу независимо от остального контента. Perfecto!

Что такое ngProjectAs?


Атрибут select у ng-content отлично справляется с тегами, которые находятся на первом уровне вложенности родительского компонента. Но что будет, если мы увеличим уровень вложенности для компонента иконки, обернув его в какой-либо тег?

// app.component.ts
import {Component} from '@angular/core';

@Component({
  selector: 'app-root',
  template: `<app-button>
               <ng-container>
                 <app-icon></app-icon>
               </ng-container>
               Button
             </app-button>`
})
export class AppComponent {}

Мы увидим, что select не работает, будто его вовсе не существует. Это происходит, потому что <ng-content select="..."> ищет только на первом уровне вложенности контента родителя. Для решения этой проблемы существует атрибут ngProjectAs. Он принимает в себя селектор и «маскирует» весь DOM узел под него.

// app.component.ts
import {Component} from '@angular/core';

@Component({
  selector: 'app-root',
  template: `<app-button>
               <ng-container ngProjectAs="app-icon">
                 <app-icon></app-icon>
               </ng-container>
               Button
             </app-button>`
})
export class AppComponent {}

Код на Stackblitz

Случай *ngIf + ng-content


Разберем еще один интересный случай. Предположим, нам нужно по клику на кнопку скрывать/показывать иконку. Добавляем к классу компонента кнопки булевое свойство, отвечающее за отображение иконки, меняем его по клику на кнопку и вешаем ngIf.

// button.component.ts
import {Component, Input} from '@angular/core';

@Component({
  selector: 'app-button',
  template: `<button (click)="toggleIcon()">
               <ng-content></ng-content>
               <div *ngIf="showIcon">
                 <ng-content select="app-icon"></ng-content>
               </div>
             </button>`,
})
export class ButtonComponent {
  showIcon = true;

  toggleIcon() {
    this.showIcon = !this.showIcon;
  }
}

Иконка скрывается/появляется по клику. Отлично! Но давайте добавим немного логов на хуки OnInit и OnDestroy для компонента иконки. Общеизвестный факт, что директива ngIf при смене условия полностью удаляет/создает элемент, при этом OnDestroy/OnInit должны срабатывать каждый раз соответствующим образом.

// icon.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';

@Component({
  selector: 'app-icon',
  template: 'O',
})
export class IconComponent implements OnInit, OnDestroy {
  ngOnInit() {
    console.log('app-icon init');
  }

  ngOnDestroy() {
    console.log('app-icon destroy')
  }
}

Код на Stackblitz

Пару раз кликнем на кнопку, убедимся что иконка исчезает, а потом появляется. Дальше заходим в консоль разработчика в надежде увидеть наши заветные логи, однако… их нет!

Есть только один лог на создание компонента. Выходит, наш компонент иконки никогда не удаляется, а просто скрывается. Почему же так происходит?

ng-content не создает новый контент, он просто проецирует уже существующий. Поэтому за создание и удаление отвечает компонент, в котором объявлен контент. Для меня это был совсем неочевидный момент. Поправим наше решение, чтобы оно работало, как ожидалось изначально.

// button.component.ts
import {Component} from '@angular/core';

@Component({
  selector: 'app-button',
  template: `<button>
                <ng-content></ng-content>
                <ng-content select="app-icon"></ng-content>
             </button>`,
})
export class ButtonComponent {
}

// app.component.ts
import { Component } from "@angular/core";

@Component({
  selector: 'app-root',
  template: `<app-button (click)="toggleIcon()">
              <div *ngIf="showIcon" ngProjectAs="app-icon">
                <app-icon></app-icon>
              </div>
              Button
            </app-button>`,
})
export class AppComponent {
  showIcon = true;

  toggleIcon() {
    this.showIcon = !this.showIcon;
  }
}

Код на Stackblitz

Открыв логи, мы можем увидеть, что компонент иконки создается и удаляется как положено.

Вместо заключение


Надеюсь, эта статья немного помогла разобраться с проекцией контента в Angular.
Мне категорично непонятно, почему в официальной документации обошли эту тему стороной. В репозитории Angular даже висит issue на это с 2017 года. Видимо, у Angular команды есть более важные дела.