DevGang
Авторизоваться

Создание высоконастраиваемого компонента

Цель этой серии заданий Angular - повысить свои навыки, практикуясь на примерах из реальной жизни. Кроме того, вам придется представить свою работу через PR, которую я или кто-либо другой может просмотреть; как вы будете делать в реальном рабочем проекте или если вы хотите внести свой вклад в программное обеспечение с открытым исходным кодом.

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

Мы собираемся внедрить панель мониторинга множественных объектов (учитель, ученик и город). Наивная рабочая реализация Teacher card и Student Card уже закодирована, и нам нужно ее реорганизовать, чтобы упростить настройку.

Каждая карточка должна иметь фоновый цвет, изображение, список удаляемых элементов и кнопку добавления.

Карточный компонент студенческой организации
Карточный компонент студенческой организации

Ниже вы найдете текущую реализацию CardComponent и ListItemComponent.

card.component.html

<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>
card.component.ts
@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());
    }
  }
}
list-item.component.ts

@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);
    }
  }
}

Вопросы:

  1. Много условий ngIf: в будущем будет все труднее внедрять новые карты. Для каждой карты потребуется новое условие.
  2. Вы можете передать имя только в качестве входных данных в свой ItemListComponent. Что произойдет, если команда разработчиков решит добавить значок или несколько отображаемых свойств, или если у вас появится новая кнопка? Вам нужно будет добавить новые конкретные входные данные и новые условия if
  3. Конструктор компонента содержит очень специфические импорты. Нам нужен общий компонент (называемый презентационным компонентом), внутри которого нет никакой логики.
  4. Компонент должен быть настроен на стратегию OnPush.
  5. Компонент не является строго типизированным.

Давайте рассмотрим каждую проблему один за другим:

Вопрос 1:

Чтобы удалить все if внутри html, нам нужно спроецировать содержимое нашего изображения из родителя в компонент карты. Для этого в Angular есть тег ng-content. (Вы можете добавить атрибут «select», чтобы уточнить, что вы хотите проецировать от своего родителя).

card.component.html
<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>
teacher.component.html
<app-card>
    <img src="assets/img/teacher.png" width="200px" />
</app-card>

Теперь можно увидеть, что l.2-11 из нашего предыдущего компонента был заменен простой строкой. А в родительском компоненте вы можете просто добавить тег img, который вы хотите проецировать, между тегами вашего компонента Card.

Вопрос 1 (повторно) и 3:

Как насчет всех условий if внутри компонента? Чтобы решить эту проблему, нужно добавить декоратор @Output() для отправки события родительскому компоненту, который позаботится о выполнении его конкретного действия.

card.component.html
   <button
      class="border border-blue-500 bg-blue-300 p-2 rounded-sm"
      (click)="add.emit()"
    >
      Add
    </button>
card.component.ts

export class CardComponent<T> {
  @Output() add = new EventEmitter<void>();
}
student.component.html
<app-card (add)="addStudent()"></app-card>
student.component.ts
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 снова нас прикрывает. Можно передать контекст нашему пользовательскому шаблону.

card.component.html
<ng-container *ngFor="let item of list">
  <ng-template
    [ngTemplateOutlet]="rowTemplate"
    [ngTemplateOutletContext]="{ $implicit: item }"
  ></ng-template>
</ng-container>
card.component.ts

export class CardComponent {
  @Input() list: any[] | null = null;

  @ContentChild('rowRef', { read: TemplateRef })
  rowTemplate!: TemplateRef<ListItemComponent>;
}
student.component.html
<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.

card.component.ts
export class CardComponent<T> {
  @Input() list: T[] | null = null;
}

И теперь окончательные CardComponent и ListItemComponent выглядят так: (Это позволяет настраивать любую карточку панели управления по желанию)

card.component.ts
@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>;
}
gistfile1.txt
@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 выглядит следующим образом:

student.component.ts
@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 для извлечения наших данных из хранилища.
#Angular
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

В подарок 100$ на счет при регистрации

Получить