Создаем свой компонент с микро-шаблонами

в 15:43, , рубрики: angular, angular-material, dynamic-components, guide, tutorial, Разработка веб-сайтов

Всем привет. Все кто так или иначе писал на фреймворке Angular сталкивался или работал с библиотекой Angular Material. Это очень хорошо написанная библиотека компонентов способная к гибкой стилизации, которая реализована через возможность создания различных тем вашего приложения, с большим набором компонентов на все случаи жизни.

В моей повседневной работе ни один проект без нее не обходиться.

Но кроме всех плюсов гибкости этой библиотеки, из нее так же можно подчерпнуть опыт создателей по написанию своих собственных компонентов, а это для меня лучший мануал по best-practice разработке на Angular.

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

В качестве примера, я хочу показать как сделать список карточек с возможностью добавить пагинацию и фильтры, а за основу мы возьмем модель шаблона MatTable компонента.

Шаблон (источник):

<table mat-table [dataSource]="dataSource" class="mat-elevation-z8">

  <ng-container matColumnDef="position">
    <th mat-header-cell *matHeaderCellDef> No. </th>
    <td mat-cell *matCellDef="let element"> {{element.position}} </td>
  </ng-container>

  <ng-container matColumnDef="name">
    <th mat-header-cell *matHeaderCellDef> Name </th>
    <td mat-cell *matCellDef="let element"> {{element.name}} </td>
  </ng-container>

  <ng-container matColumnDef="weight">
    <th mat-header-cell *matHeaderCellDef> Weight </th>
    <td mat-cell *matCellDef="let element"> {{element.weight}} </td>
  </ng-container>

  <ng-container matColumnDef="symbol">
    <th mat-header-cell *matHeaderCellDef> Symbol </th>
    <td mat-cell *matCellDef="let element"> {{element.symbol}} </td>
  </ng-container>

  <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
  <tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>

После изучения шаблона, становиться ясно что мы указываем в тегах ng-container разметку для конкретной колонки таблицы, но как оно работает внутри? Именно этим вопросом я задался когда увидел эту конструкцию, отчасти именно из-за того что с динамическими компонентами не работал. И так, приступим (исходный код).

Структура

Набор сущностей которые нам необходимо создать. В этой блок-схеме наглядно показано их взаимодействие.

image

Шаг первый

Нам необходим сервис для регистрации наших микро-шаблонов.

@Injectable()
export class RegisterPropertyDef<T> {
  // для хранения шаблонов мы будем использовать обычный Map 
  // в качестве ключа - инстанс компонента, он будет всегда уникальный
  // на случай если сервис будет лежать в глобальном модуле
  // и вы будите использовать один компонент множество раз
  private store = new Map<ComponentInstance, Map<string, TemplateRef<T>>>();

  setTemplateById(cmp: ComponentInstance, id: string, template: TemplateRef<any>): void {
    const state = this.store.get(cmp) || new Map();
    state.set(id, template);

    this.store.set(cmp, state);
  }

  getTemplate(cmp: ComponentInstance, id: string): TemplateRef<T> {
    return this.store.get(cmp).get(id);
  }
}

Шаг второй

Создаем директиву для регистрации шаблонов:

@Directive({
  selector: '[providePropertyDefValue]'
})
export class ProvidePropertyDefValueDirective<T> implements OnInit {
  @Input() providePropertyDefValueId: string;

  constructor(
    private container: ViewContainerRef,
    private template: TemplateRef<any>, // шаблон в котором определена наша разметка
    private registerPropertyDefService: RegisterPropertyDefService<any>, // сервис созданый выше
    @Optional() private parent: Alias<T[]> // тут у нас храниться ссылка на компонент в котором используются наши карточки 
  ) {}

  ngOnInit(): void {
    this.container.clear(); // этот пункт не обязателен, объясню по ходу
    this.registerPropertyDefService.setTemplateById(
      this.parent as ComponentInstance,
      this.providePropertyDefValueId,
      this.template
    );
  }
}

Шаг третий

Создаем компонент:

@Component({
  selector: 'lib-card-list',
  template: `
  <mat-card *ngFor="let source of sources">
    <ul>
      <li *ngFor="let key of displayedColumns">
        <span>{{ findColumnByKey(key)?.label }}</span>
        <span>
          <ng-container
            [ngTemplateOutlet]="findColumnByKey(key)?.template || default"
            [ngTemplateOutletContext]="{ $implicit: source }"
          ></ng-container>
        </span>
      </li>
    </ul>
  </mat-card>
  <ng-template #default></ng-template>
  `,
  styles: [
    'mat-card { margin: 10px; }'
  ]
})
export class CardListComponent<T> implements OnInit, AfterViewInit {
  @Input() defaultColumns: DefaultColumn[];
  @Input() source$: Observable<T[]>;

  displayedColumns = [];
  sources: T[] = [];

  constructor(private readonly registerPropertyDefService: RegisterPropertyDefService<T>,
              private readonly parent: Alias<T[]>) { }

  ngOnInit() {
    this.source$.subscribe((data: T[]) => this.sources = data);
    this.displayedColumns = this.defaultColumns.map(c => c.id);
  }

  findColumnByKey(key: string): DefaultColumn {
    return this.defaultColumns.find(column => column.id === key);
  }

  ngAfterViewInit(): void {
    this.defaultColumns = this.defaultColumns.map(column =>
      Object.assign(column, {
        template: this.registerPropertyDefService.getTemplate(this.parent as ComponentInstance, column.id)
      })
    );
  }

}

Немного пояснения, основная работа компонента происходит в обогащении определения структуры данных в методе ngAfterViewInit. Тут после инициализации шаблонов мы обновляем модели defaultColumns шаблонами.

В разметке вы могли обратить внимание на следующие строки —

<ng-container [ngTemplateOutlet]="findColumnByKey(key)?.template || default"
            [ngTemplateOutletContext]="{ $implicit: source }"></ng-container>

тут используется фича по передаче scope (как в AngularJS) в разметку. Что позволяет комфортно в наших микро шаблонах объявлять переменную через конструкцию let-my-var в которой будут лежать данные.

Использование

// app.component.html
<lib-card-list [defaultColumns]="defaultColumns" [source$]="sources$"></lib-card-list>

<ng-container  *libProvidePropertyDefValue="let element; id: 'id'">
  {{ element.id }}
</ng-container>
<ng-container *libProvidePropertyDefValue="let element; id: 'title'">
  {{ element.title }}
</ng-container>

Инициализация нашего свежего компонента, и передача ему параметров.

Определение шаблонов через ng-container и нашу директиву libProvidePropertyDefValue.

Самое важное здесь это

«let element; id: 'id'»

где element это scope шаблона который равен объекту с данными из списка,
id это идентификатор микро-шаблона.

Теперь хочется вернутся к директиве providePropertyDefValue, к методу ngOnInit

  ngOnInit(): void {
    this.container.clear();
...
}

Вы можете разместить микро-шаблоны так как показано в примере, и в директиве их «чистить», или полностью перенести их определение внутрь компонента lib-card-list, следовательно разметка будет выглядеть вот так:

<lib-card-list [defaultColumns]="defaultColumns" [source$]="sources$">
   <ng-container  *libProvidePropertyDefValue="let element; id: 'id'">
     {{ element.id }}
   </ng-container>
   <ng-container *libProvidePropertyDefValue="let element; id: 'title'">
    {{ element.title }}
   </ng-container>
</lib-card-list>

Объективно — второй вариант использования производительней.

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
  providers: [{ provide: Alias, useExisting: forwardRef(() => AppComponent) }]
})
export class AppComponent extends Alias<any> {
  title = 'card-list-example';

  defaultColumns: DefaultColumn[] = [
    {
      id: 'id',
      label: 'ID'
    },
    {
      id: 'title',
      label: 'Title'
    }
  ];

  sources$ = of([
    {
      id: 1,
      title: 'Hello'
    },
    {
      id: 2,
      title: 'World'
    }
  ]);

}

Тут все достаточно элементарно, единственное что следует учесть это:

providers: [{ provide: Alias, useExisting: forwardRef(() => AppComponent) }]

Данная конструкция необходима для связи шаблона и компонента который их использует.

В сервисе в конструктор инжектор передаст экземпляр AppComponent компонента.

Дополнительно

В данном примере мы разобрали как сделать компонент, для многократного пере использования в ваших проектах, для которого можно передавать разные шаблоны с данными, в этих шаблонах может быть определенно все что угодно.

Как улучшить?

Можно добавить пагинацию из Angular Material и фильтрацию.

// card-list.component.html
<mat-paginator [pageSize]="5"showFirstLastButton></mat-paginator>

// card-list.component.ts

@ViewChild(MatPaginator) paginator: MatPaginator;

 this.paginator.initialized.subscribe(() => {
   // обновление данных для рендеринга
});
this.paginator.page.subscribe((pageEvent: PageEvent) => {
 // реализация обновления данных при переключении страницы
})

Фильтрацию можно реализовать через mat-form-field и аналогично с переключением страниц при пагинации, обновлять данные.

На этом все. Очень рекомендую периодически заглядывать в исходный код библиотеки angular/material, на мой взгляд это хорошая возможность подтянуть свои знания в создании гибких и производительных компонентов. Спасибо за внимание.

Автор: ruSl0n

Источник

* - обязательные к заполнению поля