Перерисовка всех фильтров при изменении одного
Проблема
При работе с динамическими фильтрами в таблицах 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');
}
}Когда возникает проблема?
Проблема перерисовки возникает, когда:
- Динамическое управление видимостью фильтров — фильтры показываются/скрываются в зависимости от условий.
- Динамическое добавление/удаление фильтров — пользователь может настраивать набор фильтров.
- Загрузка конфигурации фильтров с сервера — состав фильтров определяется динамически.
В этих случаях использование trackBy или @for с track критично для производительности.
Сравнение производительности
| Сценарий | Без trackBy | С trackBy / @for |
|---|---|---|
| Добавление 1 фильтра из 6 | 6 перерисовок всех p-columnFilter + 6 загрузок опций для p-dropdown | 1 перерисовка нового фильтра + 1 загрузка опций |
| Удаление 1 фильтра из 6 | 5 перерисовок оставшихся фильтров + 5 повторных загрузок опций | 1 удаление DOM-элемента, остальные без изменений |
| Изменение порядка фильтров | 6 полных перерисовок | Только перестановка в DOM без перерисовки |
| Изменение конфигурации 1 фильтра | Все 6 фильтров пересоздаются | Только изменённый фильтр обновляется |
Рекомендации для QPalette и PrimeNG
- Используйте
trackByилиtrackпри динамическом управлении фильтрами вp-table. - Предпочитайте
@forв новых проектах на Angular 17+ (более современный синтаксис, обязательныйtrack). - Используйте уникальные идентификаторы для отслеживания (например,
filter.idилиfilter.field), а не индексы массива. - Объявляйте функции
trackByкак методы компонента, а не в шаблоне. - Применяйте иммутабельный подход при обновлении массивов фильтров (используйте spread-оператор или методы вроде
filter(),map()). - Кэшируйте опции для
p-dropdownна уровне компонента или сервиса, чтобы избежать повторных загрузок. - Используйте
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.