Проекция контента в Angular или затерянная документация по ng-content

от автора

При изучении 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 команды есть более важные дела.

ссылка на оригинал статьи https://habr.com/ru/post/491136/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *