Советы и рекомендации
Оптимизация фронтенда
Оптимизация фильтров таблиц
Перерисовка всех фильтров при изменении одного

Перерисовка всех фильтров при изменении одного

Проблема

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

  • Повторной загрузке данных для dropdown (p-dropdown) во всех фильтрах.
  • Потере состояния компонентов (например, открытых выпадающих списков, введенных значений).
  • Снижению производительности при большом количестве фильтров.
  • Множественным HTTP-запросам для получения опций справочников.
  • Ухудшению пользовательского опыта из-за задержек и мерцания интерфейса.

Причина проблемы

Angular отслеживает элементы в *ngFor по ссылке на объект. Когда массив фильтров изменяется (даже если большинство элементов остаются прежними), Angular не может определить, какие элементы действительно изменились, и перерисовывает все элементы заново.

В контексте QPalette и PrimeNG это особенно критично, так как компоненты p-columnFilter, p-dropdown, p-calendar могут загружать данные при инициализации.

Решения

Существует два основных подхода для оптимизации:

1. Использование trackBy в *ngFor

Функция trackBy позволяет Angular идентифицировать элементы по уникальному ключу вместо ссылки на объект. Это позволяет перерисовывать только добавленные, удаленные или измененные элементы.

Пример: Фильтры в PrimeNG Table без оптимизации

Рассмотрим типичный сценарий с динамическими фильтрами в p-table. Обратите внимание, что фильтры обычно статичны в шаблоне, но проблема возникает при динамическом управлении видимостью или составом фильтров.

<!-- Шаблон таблицы с фильтрами -->
<p-table [value]="data" [columns]="columns">
  <ng-template pTemplate="caption">
    <div class="q-filter__container p-d-flex p-flex-wrap q-gap-2">
      <!-- Фильтры добавляются/удаляются динамически -->
      <div *ngFor="let filterConfig of activeFilters">
        <p-columnFilter 
          [field]="filterConfig.field" 
          [matchMode]="filterConfig.matchMode" 
          [showMenu]="false" 
          [showClearButton]="false">
          <ng-template pTemplate="filter" let-value let-filter="filterCallback">
            <q-filter-wrapper [label]="filterConfig.label">
              <p-dropdown
                [ngModel]="value"
                [options]="filterConfig.options"
                optionLabel="label"
                optionValue="value"
                [style.width.px]="200"
                appendTo="body"
                (onChange)="filter($event.value)"
                [showClear]="true">
              </p-dropdown>
            </q-filter-wrapper>
          </ng-template>
        </p-columnFilter>
      </div>
    </div>
  </ng-template>
</p-table>
import { Component, OnInit } from '@angular/core';
 
interface FilterConfig {
  id: string;
  field: string;
  label: string;
  matchMode: string;
  options?: any[];
}
 
@Component({
  selector: 'app-master-table',
  templateUrl: './master-table.component.html'
})
export class MasterTableComponent implements OnInit {
  activeFilters: FilterConfig[] = [
    { id: '1', field: 'type', label: 'Тип:', matchMode: 'contains', options: [] },
    { id: '2', field: 'status', label: 'Статус:', matchMode: 'contains', options: [] }
  ];
 
  ngOnInit(): void {
    // Загрузка опций для каждого фильтра
    this.activeFilters.forEach(filter => {
      this.loadFilterOptions(filter);
    });
  }
 
  loadFilterOptions(filter: FilterConfig): void {
    // HTTP-запрос для загрузки опций
    console.log(`Загрузка опций для фильтра ${filter.id}`);
    // this.filterService.getOptions(filter.field).subscribe(...);
  }
 
  addFilter(filterConfig: FilterConfig): void {
    this.activeFilters = [...this.activeFilters, filterConfig];
    this.loadFilterOptions(filterConfig);
    // ❌ ВСЕ фильтры будут перерисованы!
    // ❌ loadFilterOptions вызовется для ВСЕХ фильтров в ngOnInit каждого компонента!
  }
 
  removeFilter(id: string): void {
    this.activeFilters = this.activeFilters.filter(f => f.id !== id);
    // ❌ ВСЕ оставшиеся фильтры будут перерисованы!
  }
}

Пример с trackBy для оптимизации

<!-- Шаблон таблицы с оптимизированными фильтрами -->
<p-table [value]="data" [columns]="columns">
  <ng-template pTemplate="caption">
    <div class="q-filter__container p-d-flex p-flex-wrap q-gap-2">
      <!-- ✅ Используем trackBy для отслеживания фильтров -->
      <div *ngFor="let filterConfig of activeFilters; trackBy: trackByFilterId">
        <p-columnFilter 
          [field]="filterConfig.field" 
          [matchMode]="filterConfig.matchMode" 
          [showMenu]="false" 
          [showClearButton]="false">
          <ng-template pTemplate="filter" let-value let-filter="filterCallback">
            <q-filter-wrapper [label]="filterConfig.label">
              <p-dropdown
                [ngModel]="value"
                [options]="filterConfig.options"
                optionLabel="label"
                optionValue="value"
                [style.width.px]="200"
                appendTo="body"
                (onChange)="filter($event.value)"
                [showClear]="true">
              </p-dropdown>
            </q-filter-wrapper>
          </ng-template>
        </p-columnFilter>
      </div>
    </div>
  </ng-template>
</p-table>
import { Component, OnInit } from '@angular/core';
 
interface FilterConfig {
  id: string;
  field: string;
  label: string;
  matchMode: string;
  options?: any[];
}
 
@Component({
  selector: 'app-master-table',
  templateUrl: './master-table.component.html'
})
export class MasterTableComponent implements OnInit {
  activeFilters: FilterConfig[] = [
    { id: 'filter_type', field: 'type', label: 'Тип:', matchMode: 'contains', options: [] },
    { id: 'filter_status', field: 'status', label: 'Статус:', matchMode: 'contains', options: [] }
  ];
 
  /**
   * Функция отслеживания для оптимизации ngFor.
   * Позволяет Angular определить, какие фильтры изменились.
   * 
   * @param index - Индекс элемента в массиве.
   * @param filterConfig - Конфигурация фильтра.
   * @returns Уникальный идентификатор фильтра.
   */
  trackByFilterId(index: number, filterConfig: FilterConfig): string {
    return filterConfig.id;
  }
 
  ngOnInit(): void {
    this.activeFilters.forEach(filter => {
      this.loadFilterOptions(filter);
    });
  }
 
  loadFilterOptions(filter: FilterConfig): void {
    console.log(`Загрузка опций для фильтра ${filter.id}`);
    // HTTP-запрос для загрузки опций
  }
 
  addFilter(filterConfig: FilterConfig): void {
    this.activeFilters = [...this.activeFilters, filterConfig];
    this.loadFilterOptions(filterConfig);
    // ✅ Только НОВЫЙ фильтр будет отрендерен!
    // ✅ loadFilterOptions вызовется только для нового фильтра!
  }
 
  removeFilter(id: string): void {
    this.activeFilters = this.activeFilters.filter(f => f.id !== id);
    // ✅ Только УДАЛЕННЫЙ фильтр будет удален из DOM!
    // ✅ Остальные фильтры сохранят своё состояние!
  }
}

2. Использование @for из Angular Control Flow (Angular 17+)

Начиная с Angular 17, появился новый синтаксис управления потоком @for, который автоматически требует указание track и является более производительным.

Пример с @for

<!-- Современный синтаксис Angular 17+ -->
<p-table [value]="data" [columns]="columns">
  <ng-template pTemplate="caption">
    <div class="q-filter__container p-d-flex p-flex-wrap q-gap-2">
      <!-- ✅ Используем @for с обязательным track -->
      @for (filterConfig of activeFilters; track filterConfig.id) {
        <p-columnFilter 
          [field]="filterConfig.field" 
          [matchMode]="filterConfig.matchMode" 
          [showMenu]="false" 
          [showClearButton]="false">
          <ng-template pTemplate="filter" let-value let-filter="filterCallback">
            <q-filter-wrapper [label]="filterConfig.label">
              <p-dropdown
                [ngModel]="value"
                [options]="filterConfig.options"
                optionLabel="label"
                optionValue="value"
                [style.width.px]="200"
                appendTo="body"
                (onChange)="filter($event.value)"
                [showClear]="true">
              </p-dropdown>
            </q-filter-wrapper>
          </ng-template>
        </p-columnFilter>
      }
    </div>
  </ng-template>
</p-table>
import { Component, OnInit } from '@angular/core';
 
interface FilterConfig {
  id: string;
  field: string;
  label: string;
  matchMode: string;
  options?: any[];
}
 
@Component({
  selector: 'app-master-table',
  standalone: true,
  templateUrl: './master-table.component.html'
})
export class MasterTableComponent implements OnInit {
  activeFilters: FilterConfig[] = [
    { id: 'filter_type', field: 'type', label: 'Тип:', matchMode: 'contains', options: [] },
    { id: 'filter_status', field: 'status', label: 'Статус:', matchMode: 'contains', options: [] }
  ];
 
  ngOnInit(): void {
    this.activeFilters.forEach(filter => {
      this.loadFilterOptions(filter);
    });
  }
 
  loadFilterOptions(filter: FilterConfig): void {
    console.log(`Загрузка опций для фильтра ${filter.id}`);
  }
 
  addFilter(filterConfig: FilterConfig): void {
    this.activeFilters = [...this.activeFilters, filterConfig];
    this.loadFilterOptions(filterConfig);
  }
 
  removeFilter(id: string): void {
    this.activeFilters = this.activeFilters.filter(f => f.id !== id);
  }
}

Альтернативные варианты track для фильтров

Помимо отслеживания по id, можно использовать другие подходы:

Отслеживание по полю фильтра

// Если у каждого фильтра уникальное поле таблицы
trackByField(index: number, filterConfig: FilterConfig): string {
  return filterConfig.field;
}
 
// С @for
@for (filterConfig of activeFilters; track filterConfig.field) {
  <p-columnFilter [field]="filterConfig.field">...</p-columnFilter>
}

Отслеживание по индексу (не рекомендуется)

// С trackBy
trackByIndex(index: number): number {
  return index;
}
 
// С @for
@for (filterConfig of activeFilters; track $index) {
  <div>{{ filterConfig.label }}</div>
}

⚠️ Внимание: Отслеживание по индексу НЕ решает проблему перерисовки и не рекомендуется для динамических фильтров.

Отслеживание по составному ключу

/**
 * Отслеживание по составному ключу для сложных сценариев,
 * когда нужно учитывать несколько параметров.
 */
trackByCompositeKey(index: number, filterConfig: FilterConfig): string {
  return `${filterConfig.field}_${filterConfig.matchMode}`;
}
 
// С @for
@for (filterConfig of activeFilters; track filterConfig.field + '_' + filterConfig.matchMode) {
  <p-columnFilter [field]="filterConfig.field" [matchMode]="filterConfig.matchMode">...</p-columnFilter>
}

Реальный пример: Статичные фильтры в мастер-шаблоне таблицы

В большинстве случаев фильтры в таблицах QPalette являются статичными (определены в шаблоне напрямую). Рассмотрим пример из мастер-шаблона:

<p-table [value]="stands" [columns]="tableCols">
  <ng-template pTemplate="caption">
    <div class="q-filter__container p-d-flex p-flex-wrap q-gap-2">
      <!-- Фильтр по номеру -->
      <p-columnFilter field="number" matchMode="equals" [showMenu]="false" [showClearButton]="false">
        <ng-template pTemplate="filter" let-value let-filter="filterCallback">
          <q-filter-wrapper label="Номер:" (clear)="filter(null)">
            <input
              [ngModel]="value"
              pInputText
              (input)="filter($event.target.value)"
              type="text"
              class="p-inputtext-sm"
              placeholder="Все"
            />
          </q-filter-wrapper>
        </ng-template>
      </p-columnFilter>
 
      <!-- Фильтр по типу с загрузкой опций -->
      <p-columnFilter field="type" matchMode="contains" [showMenu]="false" [showClearButton]="false">
        <ng-template pTemplate="filter" let-value let-filter="filterCallback">
          <q-filter-wrapper label="Тип инцидента:">
            <p-dropdown
              [ngModel]="value"
              [options]="typeOptions"
              optionLabel="label"
              optionValue="value"
              placeholder="Все"
              [style.width.px]="200"
              appendTo="body"
              (onChange)="filter($event.value)"
              [showClear]="true">
            </p-dropdown>
          </q-filter-wrapper>
        </ng-template>
      </p-columnFilter>
 
      <!-- Фильтр по дате -->
      <p-columnFilter field="date" matchMode="equals" [showMenu]="false" [showClearButton]="false">
        <ng-template pTemplate="filter" let-value let-filter="filterCallback">
          <q-filter-wrapper label="Дата:" (clear)="filter(null)">
            <p-calendar
              [ngModel]="value"
              dateFormat="dd.mm.yy"
              placeholder="Все"
              firstDayOfWeek="1"
              (onSelect)="filter(dateTransform($event))">
            </p-calendar>
          </q-filter-wrapper>
        </ng-template>
      </p-columnFilter>
 
      <!-- Фильтр по состоянию -->
      <p-columnFilter field="state" matchMode="contains" [showMenu]="false" [showClearButton]="false">
        <ng-template pTemplate="filter" let-value let-filter="filterCallback">
          <q-filter-wrapper label="Состояние:">
            <p-dropdown
              [ngModel]="value"
              [options]="stateOptions"
              optionLabel="label"
              optionValue="value"
              placeholder="Состояние"
              [style.width.px]="200"
              appendTo="body"
              (onChange)="filter($event.value)"
              [showClear]="true">
            </p-dropdown>
          </q-filter-wrapper>
        </ng-template>
      </p-columnFilter>
    </div>
  </ng-template>
</p-table>
import { Component, OnInit } from '@angular/core';
 
interface OptionItem {
  label: string;
  value: string;
}
 
@Component({
  selector: 'app-master-table',
  templateUrl: './master-table.component.html',
  styleUrls: ['./master-table.component.scss']
})
export class MasterTableComponent implements OnInit {
  stands: any[] = [];
  
  typeOptions: OptionItem[] = [];
  stateOptions: OptionItem[] = [];
  statusOptions: OptionItem[] = [];
 
  ngOnInit(): void {
    // Загрузка опций для фильтров
    this.loadTypeOptions();
    this.loadStateOptions();
    this.loadStatusOptions();
  }
 
  loadTypeOptions(): void {
    // HTTP-запрос или статичные данные
    this.typeOptions = [
      { label: 'Несписания в срок', value: 'Несписания в срок' },
      { label: 'Эскалация несписаний', value: 'Эскалация несписаний' },
      { label: 'Использование библиотечных PBC', value: 'Использование библиотечных PBC' }
    ];
  }
 
  loadStateOptions(): void {
    this.stateOptions = [
      { label: 'Зарегистрирован', value: 'Зарегистрирован' },
      { label: 'Обработка завершена', value: 'Обработка завершена' },
      { label: 'Ошибка обработки', value: 'Ошибка обработки' }
    ];
  }
 
  loadStatusOptions(): void {
    this.statusOptions = [
      { label: 'Недоступно', value: 'Недоступно' },
      { label: 'Низкая', value: 'Низкая' },
      { label: 'Средняя', value: 'Средняя' },
      { label: 'Высокая', value: 'Высокая' }
    ];
  }
 
  dateTransform(value: Date): string {
    return new Date(value).toLocaleDateString('ru');
  }
}

Когда возникает проблема?

Проблема перерисовки возникает, когда:

  1. Динамическое управление видимостью фильтров — фильтры показываются/скрываются в зависимости от условий.
  2. Динамическое добавление/удаление фильтров — пользователь может настраивать набор фильтров.
  3. Загрузка конфигурации фильтров с сервера — состав фильтров определяется динамически.

В этих случаях использование trackBy или @for с track критично для производительности.

Сравнение производительности

СценарийБез trackByС trackBy / @for
Добавление 1 фильтра из 66 перерисовок всех p-columnFilter + 6 загрузок опций для p-dropdown1 перерисовка нового фильтра + 1 загрузка опций
Удаление 1 фильтра из 65 перерисовок оставшихся фильтров + 5 повторных загрузок опций1 удаление DOM-элемента, остальные без изменений
Изменение порядка фильтров6 полных перерисовокТолько перестановка в DOM без перерисовки
Изменение конфигурации 1 фильтраВсе 6 фильтров пересоздаютсяТолько изменённый фильтр обновляется

Рекомендации для QPalette и PrimeNG

  1. Используйте trackBy или track при динамическом управлении фильтрами в p-table.
  2. Предпочитайте @for в новых проектах на Angular 17+ (более современный синтаксис, обязательный track).
  3. Используйте уникальные идентификаторы для отслеживания (например, filter.id или filter.field), а не индексы массива.
  4. Объявляйте функции trackBy как методы компонента, а не в шаблоне.
  5. Применяйте иммутабельный подход при обновлении массивов фильтров (используйте spread-оператор или методы вроде filter(), map()).
  6. Кэшируйте опции для p-dropdown на уровне компонента или сервиса, чтобы избежать повторных загрузок.
  7. Используйте appendTo="body" для p-dropdown и p-calendar, чтобы избежать проблем с позиционированием при перерисовках.

Дополнительные оптимизации для PrimeNG

Для дальнейшего улучшения производительности фильтров в таблицах:

1. OnPush Change Detection Strategy

import { Component, ChangeDetectionStrategy } from '@angular/core';
 
@Component({
  selector: 'app-master-table',
  templateUrl: './master-table.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class MasterTableComponent {
  // Компонент будет обновляться только при изменении входных параметров или событиях
}

2. Ленивая загрузка опций для p-dropdown

loadFilterOptionsLazy(field: string): void {
  // Загружать опции только при первом открытии dropdown
  if (!this.filterOptionsCache[field]) {
    this.filterService.getOptions(field).subscribe(options => {
      this.filterOptionsCache[field] = options;
    });
  }
}

3. Кэширование на уровне сервиса

import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { shareReplay, tap } from 'rxjs/operators';
 
@Injectable({ providedIn: 'root' })
export class FilterOptionsService {
  private cache = new Map<string, Observable<any[]>>();
 
  getOptions(field: string): Observable<any[]> {
    if (!this.cache.has(field)) {
      const request$ = this.http.get<any[]>(`/api/filters/${field}/options`).pipe(
        shareReplay(1) // Кэширование результата
      );
      this.cache.set(field, request$);
    }
    return this.cache.get(field)!;
  }
}

4. Виртуальный скроллинг для большого количества опций

Для p-dropdown с большим количеством опций используйте virtualScroll:

<p-dropdown
  [ngModel]="value"
  [options]="largeOptionsList"
  [virtualScroll]="true"
  [virtualScrollItemSize]="38"
  optionLabel="label"
  optionValue="value">
</p-dropdown>

5. Использование p-table с ленивой загрузкой

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

<p-table 
  [value]="data" 
  [lazy]="true" 
  (onLazyLoad)="loadDataLazy($event)"
  [loading]="loading"
  [totalRecords]="totalRecords">
</p-table>
loadDataLazy(event: LazyLoadEvent): void {
  this.loading = true;
  
  // Передаём фильтры на сервер
  this.dataService.getData({
    first: event.first,
    rows: event.rows,
    filters: event.filters
  }).subscribe(result => {
    this.data = result.data;
    this.totalRecords = result.total;
    this.loading = false;
  });
}

Заключение

Использование trackBy или @for с track — это простое, но очень эффективное решение проблемы избыточных перерисовок фильтров в таблицах PrimeNG. Особенно это критично при:

  • Динамическом управлении составом фильтров.
  • Загрузке опций для p-dropdown через HTTP.
  • Работе с большим количеством фильтров (более 5-6).
  • Использовании сложных компонентов фильтрации (p-calendar, p-multiSelect).

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