Внедрение зависимостей и умная таблица: расширенные шаблоны

В первой статье мы узнали, как быстро настроить расширенный компонент таблицы декларативным способом с помощью smart-table-ng.

Во второй статье мы рассмотрели, как мы можем использовать встроенную в Angular инъекцию зависимостей вместе со smart-table для достижения различных шаблонов загрузки данных без изменения нашего компонента.

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

Настроить компонент

Рассмотрим следующий набор данных:

interface Product {
  id:number;
  sku:string;
  title:string;
  price:number;
  sales:number;
  refunds:number;
}
const enum MONTH {
  JANUARY = 'January',
  FEBRUARY = 'February',
  MARCH = 'March',
  APRIL = 'April',
  MAY = 'May',
  JUNE = 'June',
  JULY = 'July',
  AUGUST = 'August',
  SEPTEMBER = 'September',
  OCTOBER = 'October',
  November = 'November',
  DECEMBER = 'December'
}
export const data = {
  [MONTH.JANUARY]:[{
    id:1,
    sku:'LTP',
    title:'Laptop',
    price:800,
    sales:345,
    refunds:12
  }, {
    id:2,
    sku:'PEN',
    title:'Pen',
    price:2,sales:2023,
    refunds:1
 },
 ...
  [MONTH.FEBRUARY]:[{
    id:1,
    sku:'LTP',
    title:'Laptop',
    price:820,
    sales:300,
    refunds:5
  }, {
    id:2,
    sku:'PEN',
    title:'Pen',
    price:3,
    sales:1800,
    refunds:70
  },
...
  [MONTH.MARCH]
};

По сути, у нас есть список продуктов с набором свойств, сгруппированных по месяцам.

Сервис для получения ежемесячного отчета может иметь следующий интерфейс:

interface ProductService{
  fetchMonthlyReport(month: MONTH): Observable<Product[]>
}

Примечание. Мы используем Observable для обработки асинхронной задачи.

Тогда наш шаблон компонента ProductTable мог бы выглядеть так:

<div stTable #table="stTable">
  <div *ngIf="table.busy">Loading...</div>
  <table>
    <thead>
      <tr>
        <th stSort="id">Id</th>
        <th stSort="sku">SKU</th>
        <th stSort="title">Title</th>
        <th stSort="price">Price</th>
        <th stSort="sales">Sales</th>
        <th stSort="refunds">Refunds</th>
      </tr>
    </thead>
    <tbody>
      <tr *ngFor="let post of table.items">
        <td>{{post.value.id}}</td>
        <td>{{post.value.sku}}</td>
        <td>{{post.value.title}}</td>
        <td>{{post.value.price}}</td>
        <td>{{post.value.sales}}</td>
        <td>{{post.value.refunds}}</td>
      </tr>
    </tbody>
  </table>
</div>

Довольно простая таблица, которая использует различные директивы smart-table и позволяет сортировать таблицу по столбцам (подробности см. В первой статье).

Компонентная логика пуста. Однако мы следуем рекомендациям второй статьи для получения данных.

import { Component} from '@angular/core';
import { SmartTable, from} from 'smart-table-ng';
import { ProductService } from './product.service';
import {MONTH, Product} from './data';
@Component({
  selector: 'product-table',
  templateUrl: './product-table.component.html',
  providers: [{ 
    provide: SmartTable,
    useFactory: (products: ProductService) => {
      return from(products.fetchMonthlyReport(MONTH.JANUARY));
    },
    deps:[
      ProductService
    ]}]
})
export class ProductTableComponent {}

Важной частью, очевидно, являются «поставщики», где мы используем фабрику для создания нового изолированного экземпляра SmartTable для каждого смонтированного ProductTableComponent. Фабрике нужен синглтон нашего продукта для эффективного создания источника данных.

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

import {InjectionToken} from '@angular/core';
const MONTH_TOKEN = new InjectionToken<MONTH>('month');

Теперь провайдерами могут быть:

const providers = [{ 
  provide: SmartTable,
  useFactory: (products: ProductService, month: MONTH) => {
    return from(products.fetchMonthlyReport(month));
  },
  deps:[
    ProductService,
    MONTH_TOKEN,
  ]
}];

Отлично: наш компонент теперь полностью настраивается. Для этого компонента не важно, откуда берется MONTH_TOKEN. Он может быть введен приложением службой конфигурации, маршрутизатором, родительским компонентом и т. Д.: Это не проблема компонента ProductTable.

Вы можете увидеть работающий пример на следующем Stackblitz:



Настройте месяц декларативно через атрибут

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

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

Давайте создадим директиву, которая обновляет источник данных при изменении привязанного свойства:

import { Directive, Input, OnChanges } from '@angular/core';
import { SmartTable } from 'smart-table-ng';
import { Product, MONTH } from './data';
import { ProductService } from './product.service';
@Directive({
  selector: '[monthly-report]'
})
export class MonthlyReportDirective {
  @Input('monthly-report') month: MONTH;
  constructor(private _smartTable: SmartTable<Product>, 
    private _products: ProductService) {}
  ngOnChanges(change){
    this._products.fetchMonthlyReport(change.month.currentValue)
      .subscribe(products => this._smartTable.use(products));
  }
}

Эта директива требует наличия доступного экземпляра SmartTable и будет специально предлагать ему использовать другой набор данных при изменении входного значения: это цель метода «использовать».

Вам также необходимо немного изменить ProductTableComponent:

import { Component } from '@angular/core';
import { SmartTable, of } from 'smart-table-ng';
@Component({
  selector: 'product-table',
  templateUrl: './product-table.component.html',
  providers: [
    { provide: SmartTable, useFactory: () => of([]) }
  ]
})
export class ProductTableComponent { }

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

Теперь мы можем использовать наш компонент вместе с новой директивой, чтобы сделать его конфигурируемым декларативным способом.

<div>
  <h2>January Report</h2>
  <product-table monthly-report="January"></product-table>
  <h2>February Report</h2>
  <product-table monthly-report="February"></product-table>
  <h2>March Report</h2>
  <product-table monthly-report="March"></product-table>
</div>

Если вы запустите следующий stackblitz:



Эти три компонента привязаны к разному ежемесячному отчету и имеют собственное состояние (вы можете сортировать одну таблицу независимо от других).

Динамическое изменение источника данных

Еще лучше вы можете динамически изменять источник данных, поскольку мы зарегистрировались для привязки изменений. Например, давайте добавим к нашему компоненту контейнера переменную currentMonth:

import { Component } from '@angular/core';
import {MONTH} from './data';
@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  currentMonth = MONTH.JANUARY;
}

со следующим шаблоном

<div>
  <label>Current Month:
    <select #select (input)="currentMonth = select.value">
      <option value="January">January</option>
      <option value="February">February</option>
      <option value="March">March</option>
    </select>
  </label>
  <product-table [monthly-report]="currentMonth"></product-table></div>

Мы добавили элемент управления select, чтобы изменить текущий месяц, чтобы таблица обновлялась соответствующими данными при изменении значения выбора. Поскольку служба интеллектуальных таблиц не удаляется, вы сохраняете одно и то же состояние таблицы между двумя разными источниками данных: например, если вы сортируете свои данные за январь по «продажам», данные также будут отсортированы по «продажам», когда вы измените источник данных до февраля или марта.

См. Следующий стек:



Частичное заключение

Метод «использовать» очень мощный, поскольку он дает нам контроль над тем, когда и как данные загружаются в нашу таблицу. Однако это требует от нас управления некоторым состоянием в нашем компоненте, которое обычно может обрабатываться экземпляром службы интеллектуальной таблицы; и это может привести к более сложному коду.

Мы использовали директиву ежемесячного отчета , специфичную для нашей модели предметной области, чтобы настроить нашу таблицу декларативным образом, однако мы могли бы представить более общую директиву (например, «st-src ”), Чтобы сделать источник данных привязкой любого компонента интеллектуальной таблицы, чтобы он мог передаваться родительским компонентом в стиле React. Это может быть действительно удобно, если вашим товарищам по команде, например, не нравится внедрение зависимостей.

Используйте сервис для управления источниками данных

Observable - важное понятие в фреймворке Angular. Это действительно мощная абстракция, когда дело касается асинхронных данных или данных, которые развиваются во времени. Наш источник данных на самом деле имеет оба свойства: он разрешается асинхронно и может измениться со временем, если нам нужны возможности изменения выбранного месяца. Давайте реализуем сервис, который генерирует последовательность ежемесячных отчетов и предоставляет эту последовательность через Observable:

import { Injectable } from '@angular/core';
import { Subject } from 'rxjs';
import { Product, MONTH } from './data';
import { ProductService } from './product.service';
@Injectable()
export class MonthlyReportService {
  
  constructor(private _products: ProductService) {}
  
  private dataSource = new Subject<Product[]>();  
  data$ = this.dataSource.asObservable();
  useMonth(month: MONTH) {
    this._products.fetchMonthlyReport(month)
      .subscribe(report => this.dataSource.next(report));
  }
}

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

import { Component, Input, OnChanges } from '@angular/core';
import { SmartTable, from } from 'smart-table-ng';
import { MONTH, MONTH_TOKEN, Product } from './data';
import { MonthlyReportService } from './monthly-report.service';
@Component({
  selector: 'product-table',
  templateUrl: './product-table.component.html',
  providers: [
    MonthlyReportService, // here the monthly report service is created but it could be injected in a parent component
    { 
      provide: SmartTable, useFactory: (source: MonthlyReportService) => from(source.data$),
      deps: [MonthlyReportService]
    }]
})
export class ProductTableComponent implements OnChanges {
  @Input() month: MONTH;
  constructor(private _montlyReport: MonthlyReportService) {}
  ngOnChanges(change) {
    const month = change.month.currentValue;
    this._montlyReport.useMonth(month);
  }
}

Этот подход может показаться очень похожим на вышеупомянутый метод «использования», но он дает некоторые преимущества. Например, некоторые другие директивы могут полагаться на источник данных для создания поля выбора с диапазоном доступных значений, теперь они могут требовать внедрения службы MonthlyReport. Каким-то образом у нас есть наблюдаемый источник данных (хранящийся в экземпляре MonthlyReportService), а с другой стороны - наблюдаемая коллекция отображаемых данных (хранимая экземпляром службы SmartTable). эволюционирует вместе с состоянием таблицы.

Вы можете увидеть работающий пример в следующем стеке.



Бонус: полный контроль с динамически загружаемыми компонентами

Фреймворк Angular многое делает за нас: он создает и размещает компоненты, он управляет инжекторами для внедрения необходимых сервисов и т. Д.

Вы также можете выполнять всю эту работу «вручную», чтобы полностью контролировать то, как компоненты и службы создаются / удаляются и как компоненты присоединяются или отключаются от / к дереву DOM.

Давайте сначала создадим директиву для предоставления ViewContainerRef.

import { Directive, ViewContainerRef } from '@angular/core';
@Directive({
  selector: '[st-table-container]'
})
export class StTableContainerDirective {
  constructor(public viewContainerRef: ViewContainerRef) { }
}

Это будет использоваться позже нашим компонентом загрузчика как ViewChild.

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

import { ProductService } from './product.service';
import { MONTH, MONTH_TOKEN, Product } from './data';
import { Component, ViewChild, ComponentFactoryResolver, Input, OnChanges, Injector } from '@angular/core';
import { StTableContainerDirective } from './table-container.directive'
import { SmartTable, from } from 'smart-table-ng';
import { ProductTableComponent } from './product-table.component';
@Component({
  selector: 'table-loader',
  templateUrl: './table-loader.component.html'
})
export class TableLoaderComponent implements OnChanges {
  
  private _reports = new Map();
  
  @Input() month: MONTH;
  
  @ViewChild(StTableContainerDirective) tableContainer: StTableContainerDirective;
  constructor(private _componentFactoryResolve: ComponentFactoryResolver, private _prodcuts: ProductService) {}
  ngOnChanges(change) {
    if (change.month) {
      const month = change.month.currentValue;
      const viewContainerRef = this.tableContainer.viewContainerRef;
      viewContainerRef.detach();
   
      if (this._reports.has(month) === false) {
        const table = from(this._prodcuts.fetchMonthlyReport(month));
        const injector = Injector.create([{ provide: SmartTable, useValue: table }], viewContainerRef.injector);
        const componentFactory = this._componentFactoryResolve.resolveComponentFactory(ProductTableComponent);
        this._reports.set(month, viewContainerRef.createComponent(componentFactory, 0, injector));
      } else {
        viewContainerRef.insert(this._reports.get(month).hostView);
      }
    }
  }
}

со следующим шаблоном

<div>
  <ng-template st-table-container></ng-template>
</div>

Шаблон довольно простой. У него просто есть заполнитель с директивой st-table-container, чтобы оставить место для наших компонентов.

Интересная часть - это ловушка жизненного цикла ngOnChange.

Если значение month изменяется, мы получаем контейнер представления, связанный со слотом, удерживаемым директивой st-table-container, и отсоединяем смонтированный компонент.

Мы ведем реестр компонентов таблицы, связанных с каждым месяцем, если у нас уже есть запись для данного месяца, мы присоединяем соответствующий компонент. Если нет, мы его создадим.

Для создания компонента используем ComponentFactoryResolver. Обычно структура заботится о создании соответствующего инжектора и предоставлении требуемых услуг. Однако вся цель этого загрузчика - управлять этой частью; поэтому нам нужно создать инжектор самостоятельно, передав желаемый источник данных (относящийся к соответствующему месяцу).

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

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

Вот весь стекблиц:



Вывод

В этой статье мы рассмотрели различные интересные методы настройки источников данных смарт-таблиц во время выполнения с конфигурацией (здесь значение месяца), декларативно предоставляемой компонентом в дереве. Благодаря гибкости фреймворка Angular и дизайну smart-table-ng в нашем распоряжении широкий спектр возможностей