https://habr.com/ru/post/491136/- Разработка веб-сайтов
- JavaScript
- Angular
- TypeScript
При изучении 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: '☻',
})
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: '☻',
})
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 команды есть более важные дела.