При изучении Angular очень часто упускают или уделяют недостаточное внимание такому понятию, как проекция контента. Это очень мощный инструмент для создания гибких и переиспользуемых компонентов. Но в документации о нем упоминается лишь пару абзацев в разделе Lifecycle hooks. Попробуем исправить данное упущение.
И Angular позволяет вставлять в шаблон этого компонента любой HTML код (контент) с помощью элемента
Давайте попробуем разобраться, зачем это нужно и как это работает на примере. Допустим, у нас есть простой компонент кнопки. Текст этой кнопки мы передаем в шаблон через
Вроде выглядит неплохо. Но вдруг нам понадобилось для некоторых кнопок добавить к тексту иконку. У нас уже есть компонент иконки. Нужно просто добавить его в шаблон кнопки, навесить директиву
Все работает. Но что будет, если нужно поменять расположение иконки относительно текста? Или добавить еще какой-нибудь новый элемент? Придется править существующий код, добавлять новые свойства и т.д.
Всего этого можно избежать с помощью
Код на Stackblitz
Теперь, если нам понадобилась кнопка с иконкой, мы просто помещаем компонент иконки между тегами кнопки. Можно добавить что угодно и как угодно. Это ли не рай? Наш компонент кнопки стал гибким и красивым.
Иногда нам нужно расположить какой-то контент в определенном месте относительно всего остального контента, в этом случае можно использовать атрибут
Код на Stackblitz
Сейчас иконка показывается всегда снизу независимо от остального контента. Perfecto!
Атрибут
Мы увидим, что
Код на Stackblitz
Разберем еще один интересный случай. Предположим, нам нужно по клику на кнопку скрывать/показывать иконку. Добавляем к классу компонента кнопки булевое свойство, отвечающее за отображение иконки, меняем его по клику на кнопку и вешаем
Иконка скрывается/появляется по клику. Отлично! Но давайте добавим немного логов на хуки
Код на Stackblitz
Пару раз кликнем на кнопку, убедимся что иконка исчезает, а потом появляется. Дальше заходим в консоль разработчика в надежде увидеть наши заветные логи, однако… их нет!
Есть только один лог на создание компонента. Выходит, наш компонент иконки никогда не удаляется, а просто скрывается. Почему же так происходит?
Код на Stackblitz
Открыв логи, мы можем увидеть, что компонент иконки создается и удаляется как положено.
Надеюсь, эта статья немного помогла разобраться с проекцией контента в Angular.
Мне категорично непонятно, почему в официальной документации обошли эту тему стороной. В репозитории Angular даже висит issue на это с 2017 года. Видимо, у Angular команды есть более важные дела.
Проекция контента с помощью 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 команды есть более важные дела.
s1im
Данная техника называется transclusion, в Angular.js присутствовало в следующем виде docs.angularjs.org/api/ng/directive/ngTransclude
s1im
Мне, кстати, тоже не совсем понятно было почему информация по данной фиче так слабо упоминается в официальной документации (фактически не упоминается). Я сделал вывод, что подобные махинации местами противоречат задуманной архитектуре современного Angular-приложения.
ItNoN Автор
Может быть. Но можно же по чуть-чуть убирать эту фичу, а они просто игнорируют ее
s1im
А мне наоборот больше понравилось то, что они решили не убирать эту фичу совсем — для тех кто работал с первым Angular или другими фреймворками, где такое в ходу, возможность использовать есть без всяких deprecated warnings. Но и рекомендаций по использованию данной фичи от разработчиков нет.
Из подмеченных мною минусов использования transclusion, не получается использовать стандартную передачу данных из компонента в компонент через Input/Output, приходится использовать redux-архитектуру или завязываться на сервисы.