Создание высоконастраиваемого компонента
Цель этой серии заданий Angular - повысить свои навыки, практикуясь на примерах из реальной жизни. Кроме того, вам придется представить свою работу через PR, которую я или кто-либо другой может просмотреть; как вы будете делать в реальном рабочем проекте или если вы хотите внести свой вклад в программное обеспечение с открытым исходным кодом.
Первая задача — создать компонент с широкими возможностями настройки, который можно было бы повторно использовать с любыми безумными идеями, которые может придумать команда разработчиков.
Мы собираемся внедрить панель мониторинга множественных объектов (учитель, ученик и город). Наивная рабочая реализация Teacher card и Student Card уже закодирована, и нам нужно ее реорганизовать, чтобы упростить настройку.
Каждая карточка должна иметь фоновый цвет, изображение, список удаляемых элементов и кнопку добавления.
Ниже вы найдете текущую реализацию CardComponent и ListItemComponent.
<div class="border-2 border-black rounded-md p-4 w-fit flex flex-col gap-3" [class]="customClass">
<img
*ngIf="type === CardType.TEACHER"
src="assets/img/teacher.png"
width="200px"
/>
<img
*ngIf="type === CardType.STUDENT"
src="assets/img/student.webp"
width="200px"
/>
<section>
<app-list-item
*ngFor="let item of list"
[name]="item.firstname"
[id]="item.id"
[type]="type"
>
</app-list-item>
</section>
<button
class="border border-blue-500 bg-blue-300 p-2 rounded-sm"
(click)="addNewItem()"
>
Add
</button>
</div>
@Component({
selector: 'app-card',
templateUrl: './card.component.html',
standalone: true,
imports: [NgIf, NgFor, ListItemComponent],
})
export class CardComponent {
@Input() list: any[] | null = null;
@Input() type!: CardType;
@Input() customClass = '';
CardType = CardType;
constructor(
private teacherStore: TeacherStore,
private studentStore: StudentStore
) {}
addNewItem() {
if (this.type === CardType.TEACHER) {
this.teacherStore.addOne(randTeacher());
} else if (this.type === CardType.STUDENT) {
this.studentStore.addOne(randStudent());
}
}
}
@Component({
selector: 'app-list-item',
template: `
<div class="border border-grey-300 py-1 px-2 flex justify-between">
{{ name }}
<button (click)="delete(id)">
<img class="h-5" src="assets/svg/trash.svg" />
</button>
</div>
`,
standalone: true,
})
export class ListItemComponent {
@Input() id!: number;
@Input() name!: string;
@Input() type!: CardType;
constructor(
private teacherStore: TeacherStore,
private studentStore: StudentStore
) {}
delete(id: number) {
if (this.type === CardType.TEACHER) {
this.teacherStore.deleteOne(id);
} else if (this.type === CardType.STUDENT) {
this.studentStore.deleteOne(id);
}
}
}
Вопросы:
- Много условий ngIf: в будущем будет все труднее внедрять новые карты. Для каждой карты потребуется новое условие.
- Вы можете передать имя только в качестве входных данных в свой ItemListComponent. Что произойдет, если команда разработчиков решит добавить значок или несколько отображаемых свойств, или если у вас появится новая кнопка? Вам нужно будет добавить новые конкретные входные данные и новые условия if…
- Конструктор компонента содержит очень специфические импорты. Нам нужен общий компонент (называемый презентационным компонентом), внутри которого нет никакой логики.
- Компонент должен быть настроен на стратегию OnPush.
- Компонент не является строго типизированным.
Давайте рассмотрим каждую проблему один за другим:
Вопрос 1:
Чтобы удалить все if внутри html, нам нужно спроецировать содержимое нашего изображения из родителя в компонент карты. Для этого в Angular есть тег ng-content. (Вы можете добавить атрибут «select», чтобы уточнить, что вы хотите проецировать от своего родителя).
<div class="border-2 border-black rounded-md p-4 w-fit flex flex-col gap-3" [class]="customClass">
<ng-content select="img"></ng-content>
<section>
<app-list-item
*ngFor="let item of list"
[name]="item.firstname"
[id]="item.id"
[type]="type"
>
</app-list-item>
</section>
<button
class="border border-blue-500 bg-blue-300 p-2 rounded-sm"
(click)="addNewItem()"
>
Add
</button>
</div>
<app-card>
<img src="assets/img/teacher.png" width="200px" />
</app-card>
Теперь можно увидеть, что l.2-11 из нашего предыдущего компонента был заменен простой строкой. А в родительском компоненте вы можете просто добавить тег img, который вы хотите проецировать, между тегами вашего компонента Card.
Вопрос 1 (повторно) и 3:
Как насчет всех условий if внутри компонента? Чтобы решить эту проблему, нужно добавить декоратор @Output() для отправки события родительскому компоненту, который позаботится о выполнении его конкретного действия.
<button
class="border border-blue-500 bg-blue-300 p-2 rounded-sm"
(click)="add.emit()"
>
Add
</button>
export class CardComponent<T> {
@Output() add = new EventEmitter<void>();
}
<app-card (add)="addStudent()"></app-card>
export class StudentCardComponent {
constructor(private store: StudentStore) {}
addStudent() {
this.store.addOne(randStudent());
}
}
Вопрос 2:
Это самая сложная часть упражнения. Можно было бы переместить ngFor в компонент student и использовать ng-content для проецирования результата. Это сработает, но нам нужно сохранить шаблон списка внутри компонента card, потому что мы хотим добавить (вне рамок этого упражнения) общую логику, и не хотим копировать эту логику во все наши родительские компоненты.
Можно добавить ng-контент внутрь ngFor. Но это не сработает, так как не хватает ссылки на текущий отображаемый элемент.
Но у Angular есть прикрытие. NgTemplateOutlet — это директива, принимающая TemplateRef в качестве входных данных. Затем можно получить пользовательский шаблон от родителя с помощью @ContentChild() и передать его в директиву выхода. На данном этапе все еще не хватает текущих предметов. И Angular снова нас прикрывает. Можно передать контекст нашему пользовательскому шаблону.
<ng-container *ngFor="let item of list">
<ng-template
[ngTemplateOutlet]="rowTemplate"
[ngTemplateOutletContext]="{ $implicit: item }"
></ng-template>
</ng-container>
export class CardComponent {
@Input() list: any[] | null = null;
@ContentChild('rowRef', { read: TemplateRef })
rowTemplate!: TemplateRef<ListItemComponent>;
}
<app-card [list]="students">
<ng-template #rowRef let-student>
<app-list-item [name]="student.firstname">
</app-list-item>
</ng-template>
</app-card>
Примечания:
- Первый аргумент ngTemplateOutletContext — $implicit. Если вы хотите передать больше, вы должны назвать их.
[ngTemplateOutletContext]={$implicit: item; arg2: index}
<ng-template #rowRef let-teacher let-myIndex="arg2">
- let-teacher в ng-template не набирается. Вы можете добавить ngTemplateContextGuard для строгой типизации, но это будет частью другой задачи. (Следите за обновлениями)
- app-list-item по-прежнему плохо настраивается. Но теперь вы знаете, как это сделать. Вернитесь к вопросу 1 и примените ту же стратегию к своему компоненту.
Вопрос 4:
Теперь, когда у нашего компонента есть только входы и выходы, нет никакой опасности просто добавить **changeDetection: ChangeDetectionStrategy.OnPush ** в декораторе компонента.
Вопрос 5:
Чтобы ввести список элементов в компонент, можем использовать Generic.
export class CardComponent<T> {
@Input() list: T[] | null = null;
}
И теперь окончательные CardComponent и ListItemComponent выглядят так: (Это позволяет настраивать любую карточку панели управления по желанию)
@Component({
selector: 'app-card',
template: `
<ng-content select="img"></ng-content>
<section>
<ng-container *ngFor="let item of list">
<ng-template
[ngTemplateOutlet]="rowTemplate"
[ngTemplateOutletContext]="{ $implicit: item }"
></ng-template>
</ng-container>
</section>
<button
class="border border-blue-500 bg-blue-300 p-2 rounded-sm"
(click)="add.emit()"
>
Add
</button>
`,
standalone: true,
imports: [NgIf, NgFor, NgTemplateOutlet],
host: {
class: 'border-2 border-black rounded-md p-4 w-fit flex flex-col gap-3',
},
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CardComponent<T> {
@Input() list: T[] | null = null;
@Output() add = new EventEmitter<void>();
@ContentChild('rowRef', { read: TemplateRef })
rowTemplate!: TemplateRef<ListItemComponent>;
}
@Component({
selector: 'app-list-item',
template: `
<div class="border border-grey-300 py-1 px-2 flex justify-between">
<ng-content></ng-content>
<button (click)="delete.next()">
<img class="h-5" src="assets/svg/trash.svg" />
</button>
</div>
`,
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ListItemComponent {
@Output() delete = new EventEmitter<void>();
}
Примечания
- Добавляя свойство host в свой декоратор, есть возможность избавиться от одного уровня инкапсуляции.
- Не забудьте импортировать NgIf, NgFor и NgTemplateOutlet в массив импорта. Или вы можете просто импортировать CommonModule.
Наконец, код StudentCard выглядит следующим образом:
@Component({
selector: 'app-student-card',
template: `<app-card
[list]="students$ | async"
(add)="addStudent()"
class="bg-light-green"
>
<img src="assets/img/student.webp" width="200px" />
<ng-template #rowRef let-student>
<app-list-item (delete)="deleteStudent(student.id)">
{{ student.firstname }}
</app-list-item>
</ng-template>
</app-card>`,
standalone: true,
styles: [
`
.bg-light-green {
background-color: rgba(0, 250, 0, 0.1);
}
`,
],
imports: [CardComponent, ListItemComponent, AsyncPipe],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StudentCardComponent implements OnInit {
students$ = this.store.students$;
constructor(private http: FakeHttpService, private store: StudentStore) {}
ngOnInit(): void {
this.http.fetchStudents$.subscribe((s) => this.store.addAll(s));
}
addStudent() {
this.store.addOne(randStudent());
}
deleteStudent(id: number) {
this.store.deleteOne(id);
}
}
Примечания
- Вся логика теперь находится только внутри смарт-компонента. Легко поддерживать.
- app-list-item полностью настраиваемый. Мы можем легко добавить значок или изменить свойство, которое мы хотим отобразить.
- Компонент использует стратегию OnPush, поскольку мы используем AsyncPipe для извлечения наших данных из хранилища.