Избыточные запросы при изменении значений лукапа
Проблема
При использовании компонента q-lookup в качестве фильтра для таблиц или других компонентов часто возникает проблема избыточной загрузки данных: каждый ввод символа или частичное изменение значения инициирует повторную загрузку данных из источника.
Типичный сценарий проблемы
import { Component, OnInit } from '@angular/core';
import { FormControl } from '@angular/forms';
@Component({
selector: 'app-customers-table',
templateUrl: './customers-table.component.html'
})
export class CustomersTableComponent implements OnInit {
customerFilter = new FormControl(null);
customers: any[] = [];
constructor(private dataService: DataService) {}
ngOnInit(): void {
// ❌ ПРОБЛЕМА: Загрузка данных при любом изменении значения лукапа
this.customerFilter.valueChanges.subscribe(customer => {
// Запрос выполняется при каждом нажатии клавиши в лукапе
this.loadCustomers(customer);
});
}
loadCustomers(customer: any): void {
// При вводе "Иван Иванов" в лукапе будет выполнено ~11 запросов:
// "", "И", "Ив", "Ива", "Иван", "Иван ", и т.д.
this.dataService.getCustomers({ customerId: customer?.id })
.subscribe(data => {
this.customers = data;
});
}
}Последствия:
- Множественные запросы — при вводе имени "Иван Иванов" (11 символов) будет выполнено 11+ запросов к серверу.
- Частичные данные — запросы с неполными данными (например, только с введённым текстом без id) возвращают некорректные результаты.
- Нагрузка на сервер — избыточные запросы создают ненужную нагрузку на бэкенд.
- Плохой UX — таблица "мигает" при каждом изменении, данные постоянно перезагружаются.
Почему это происходит?
Компонент q-lookup работает как обычный FormControl. При использовании подписки на valueChanges, каждое изменение значения (включая промежуточные состояния при вводе текста) вызывает обработчик.
Значение лукапа может изменяться так:
- Пустое значение → объект пуст или
null - Ввод текста → объект с частично заполненными полями
- Выбор из списка → полный объект с
idи всеми атрибутами
Решения
Решение 1: Отслеживание изменения конкретного атрибута
Вместо реагирования на любое изменение значения лукапа отслеживайте изменение только требуемого атрибута, например id.
import { Component, OnInit, OnDestroy } from '@angular/core';
import { FormControl } from '@angular/forms';
import { Subject } from 'rxjs';
import { takeUntil, distinctUntilChanged, map } from 'rxjs/operators';
@Component({
selector: 'app-customers-table',
templateUrl: './customers-table.component.html'
})
export class CustomersTableComponent implements OnInit, OnDestroy {
customerFilter = new FormControl(null);
customers: any[] = [];
private destroy$ = new Subject<void>();
constructor(private dataService: DataService) {}
ngOnInit(): void {
// ✅ РЕШЕНИЕ: Отслеживание только изменения id
this.customerFilter.valueChanges
.pipe(
// Извлекаем только id из объекта лукапа
map(customer => customer?.id || null),
// Игнорируем повторяющиеся значения id
distinctUntilChanged(),
takeUntil(this.destroy$)
)
.subscribe(customerId => {
// Запрос выполняется только при изменении id
this.loadCustomers(customerId);
});
}
loadCustomers(customerId: number | null): void {
this.dataService.getCustomers({ customerId })
.subscribe(data => {
this.customers = data;
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}Результат: Запрос выполняется только при фактическом выборе клиента из списка (когда меняется id), а не при каждом введённом символе.
Решение 2: Использование debounceTime для текстовых полей
Если необходимо отслеживать изменения текстового поля (например, fullName), используйте debounceTime для задержки выполнения запроса.
import { Component, OnInit, OnDestroy } from '@angular/core';
import { FormControl } from '@angular/forms';
import { Subject } from 'rxjs';
import { takeUntil, debounceTime, distinctUntilChanged, map, filter } from 'rxjs/operators';
@Component({
selector: 'app-customers-table',
templateUrl: './customers-table.component.html'
})
export class CustomersTableComponent implements OnInit, OnDestroy {
customerFilter = new FormControl(null);
customers: any[] = [];
private destroy$ = new Subject<void>();
constructor(private dataService: DataService) {}
ngOnInit(): void {
// ✅ Комбинированное решение: debounce + отслеживание конкретного поля
this.customerFilter.valueChanges
.pipe(
// Извлекаем fullName для поиска
map(customer => customer?.fullName || ''),
// Игнорируем пустые значения
filter(fullName => fullName.length >= 3),
// Задержка 500 мс после последнего ввода
debounceTime(500),
// Игнорируем повторяющиеся значения
distinctUntilChanged(),
takeUntil(this.destroy$)
)
.subscribe(fullName => {
this.loadCustomers(fullName);
});
}
loadCustomers(fullName: string): void {
this.dataService.getCustomers({ fullName })
.subscribe(data => {
this.customers = data;
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}Результат: Запрос выполняется только через 500 мс после того, как пользователь прекратил вводить текст, и только если введено минимум 3 символа.
Решение 3: Отслеживание события selectedItem
Используйте событие (selectedItem) компонента лукапа вместо подписки на valueChanges.
import { Component } from '@angular/core';
@Component({
selector: 'app-customers-table',
templateUrl: './customers-table.component.html'
})
export class CustomersTableComponent {
customers: any[] = [];
constructor(private dataService: DataService) {}
// ✅ Обработка только финального выбора элемента
onCustomerSelected(customer: any): void {
if (customer?.id) {
this.loadCustomers(customer.id);
}
}
loadCustomers(customerId: number): void {
this.dataService.getCustomers({ customerId })
.subscribe(data => {
this.customers = data;
});
}
}Шаблон:
<q-lookup
formControlName="customer"
lookupField="fullName"
caption="Клиент"
(selectedItem)="onCustomerSelected($event)"
remoteWidgetComponentUrl="customersui:customerslookup"
widgetComponentSelector="cmp-customers-lookup"
displayMode="modal">
</q-lookup>
<p-table [value]="customers">
<!-- Конфигурация таблицы -->
</p-table>Результат: Загрузка данных происходит только при явном выборе элемента из списка лукапа, а не при вводе текста.
Решение 4: Комбинированный подход с множественными фильтрами
Если в форме несколько фильтров-лукапов, объедините их изменения в один запрос.
import { Component, OnInit, OnDestroy } from '@angular/core';
import { FormGroup, FormControl } from '@angular/forms';
import { Subject, combineLatest } from 'rxjs';
import { takeUntil, debounceTime, distinctUntilChanged, map } from 'rxjs/operators';
@Component({
selector: 'app-orders-table',
templateUrl: './orders-table.component.html'
})
export class OrdersTableComponent implements OnInit, OnDestroy {
filtersForm = new FormGroup({
customer: new FormControl(null),
product: new FormControl(null),
status: new FormControl(null)
});
orders: any[] = [];
private destroy$ = new Subject<void>();
constructor(private dataService: DataService) {}
ngOnInit(): void {
// ✅ Отслеживание изменений всех фильтров с объединением
combineLatest([
this.filtersForm.get('customer')!.valueChanges.pipe(
map(customer => customer?.id || null),
distinctUntilChanged()
),
this.filtersForm.get('product')!.valueChanges.pipe(
map(product => product?.id || null),
distinctUntilChanged()
),
this.filtersForm.get('status')!.valueChanges.pipe(
distinctUntilChanged()
)
])
.pipe(
// Задержка для группировки быстрых изменений
debounceTime(300),
takeUntil(this.destroy$)
)
.subscribe(([customerId, productId, status]) => {
this.loadOrders({ customerId, productId, status });
});
}
loadOrders(filters: any): void {
this.dataService.getOrders(filters)
.subscribe(data => {
this.orders = data;
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}Результат: Один запрос выполняется при изменении любого из фильтров, с задержкой 300 мс для группировки изменений.
Сравнение подходов
| Подход | Когда использовать | Преимущества | Недостатки |
|---|---|---|---|
| Отслеживание id | Фильтр по конкретной записи БО | Нет лишних запросов, точная фильтрация | Не подходит для поиска по текстовым полям |
| debounceTime | Поиск по текстовым полям | Снижает количество запросов | Небольшая задержка в UX |
| selectedItem событие | Простая фильтрация без автозаполнения | Максимально простая реализация | Требует явного выбора из списка |
| combineLatest | Множественные фильтры | Один запрос для всех фильтров | Более сложная реализация |
Рекомендации
Для фильтров по бизнес-объектам
Используйте отслеживание id — это наиболее эффективный способ:
this.filterControl.valueChanges
.pipe(
map(value => value?.id || null),
distinctUntilChanged()
)
.subscribe(id => this.loadData(id));Для поисковых фильтров
Используйте debounceTime с минимальной длиной строки:
this.searchControl.valueChanges
.pipe(
map(value => value?.searchField || ''),
filter(text => text.length >= 3),
debounceTime(500),
distinctUntilChanged()
)
.subscribe(text => this.search(text));Для множественных фильтров
Объедините их через combineLatest с debounceTime:
combineLatest([
this.filter1.valueChanges.pipe(map(v => v?.id)),
this.filter2.valueChanges.pipe(map(v => v?.id))
])
.pipe(debounceTime(300))
.subscribe(([id1, id2]) => this.loadData(id1, id2));Checklist: Оптимизация фильтров-лукапов
- Определён ключевой атрибут — выбрано поле для отслеживания (обычно
id)? - Используется
map— значение лукапа преобразуется к нужному атрибуту? - Добавлен
distinctUntilChanged— дублирующиеся значения игнорируются? - Установлен
debounceTime— для текстовых полей есть задержка? - Проверка на пустые значения — используется
filterдля игнорирования коротких строк? - Реализован
ngOnDestroy— подписки корректно завершаются? - Минимум запросов — при тестировании количество запросов соответствует ожидаемому?
Антипаттерны
❌ Подписка на valueChanges без операторов
// ПЛОХО: Запрос на каждое изменение
this.customerFilter.valueChanges.subscribe(customer => {
this.loadData(customer);
});❌ Отслеживание всего объекта
// ПЛОХО: Изменения промежуточных полей вызывают запросы
this.customerFilter.valueChanges
.pipe(distinctUntilChanged())
.subscribe(customer => {
this.loadData(customer);
});Проблема: distinctUntilChanged сравнивает объекты по ссылке, поэтому каждое изменение считается новым.
❌ Слишком короткая задержка debounceTime
// ПЛОХО: 50 мс — слишком мало для реальной оптимизации
this.searchFilter.valueChanges
.pipe(debounceTime(50))
.subscribe(text => this.search(text));Решение: Используйте 300-500 мс для комфортного UX и реальной оптимизации.
Пример: Полная реализация
import { Component, OnInit, OnDestroy } from '@angular/core';
import { FormGroup, FormControl } from '@angular/forms';
import { Subject } from 'rxjs';
import { takeUntil, distinctUntilChanged, map } from 'rxjs/operators';
@Component({
selector: 'app-optimized-table',
templateUrl: './optimized-table.component.html'
})
export class OptimizedTableComponent implements OnInit, OnDestroy {
filtersForm = new FormGroup({
customer: new FormControl(null),
dateFrom: new FormControl(null),
dateTo: new FormControl(null)
});
data: any[] = [];
loading = false;
private destroy$ = new Subject<void>();
constructor(private dataService: DataService) {}
ngOnInit(): void {
this.initializeFilters();
this.loadInitialData();
}
private initializeFilters(): void {
// ✅ Оптимизированная подписка на изменение фильтра-лукапа
this.filtersForm.get('customer')!.valueChanges
.pipe(
// Отслеживаем только id клиента
map(customer => customer?.id || null),
// Игнорируем повторяющиеся значения
distinctUntilChanged(),
takeUntil(this.destroy$)
)
.subscribe(customerId => {
this.applyFilters();
});
// Подписка на изменение других фильтров
this.filtersForm.get('dateFrom')!.valueChanges
.pipe(
distinctUntilChanged(),
takeUntil(this.destroy$)
)
.subscribe(() => this.applyFilters());
this.filtersForm.get('dateTo')!.valueChanges
.pipe(
distinctUntilChanged(),
takeUntil(this.destroy$)
)
.subscribe(() => this.applyFilters());
}
private loadInitialData(): void {
this.applyFilters();
}
private applyFilters(): void {
this.loading = true;
const filters = {
customerId: this.filtersForm.get('customer')?.value?.id || null,
dateFrom: this.filtersForm.get('dateFrom')?.value || null,
dateTo: this.filtersForm.get('dateTo')?.value || null
};
this.dataService.getData(filters)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (data) => {
this.data = data;
this.loading = false;
},
error: (error) => {
console.error('Error loading data:', error);
this.loading = false;
}
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}Шаблон:
<form [formGroup]="filtersForm">
<div class="filters-container">
<q-lookup
formControlName="customer"
lookupField="fullName"
caption="Клиент"
remoteWidgetComponentUrl="customersui:customerslookup"
widgetComponentSelector="cmp-customers-lookup"
displayMode="modal">
</q-lookup>
<p-calendar
formControlName="dateFrom"
placeholder="Дата с"
[showIcon]="true">
</p-calendar>
<p-calendar
formControlName="dateTo"
placeholder="Дата по"
[showIcon]="true">
</p-calendar>
</div>
</form>
<p-table
[value]="data"
[loading]="loading"
[lazy]="true">
<!-- Конфигурация таблицы -->
</p-table>Заключение
Оптимизация фильтров-лукапов критически важна для производительности приложения. Ключевые принципы:
- Отслеживайте только нужные атрибуты — используйте
mapдля извлеченияidили других конкретных полей. - Используйте
distinctUntilChanged— избегайте дублирующихся запросов. - Применяйте
debounceTime— для текстовых полей добавляйте задержку 300-500 мс. - Завершайте подписки — всегда используйте
takeUntilиngOnDestroy. - Группируйте запросы — для множественных фильтров объединяйте изменения через
combineLatest.
Правильная реализация фильтров-лукапов снижает количество запросов к серверу в 10-20 раз и значительно улучшает пользовательский опыт.