Советы и рекомендации
Оптимизация фронтенда
Оптимизация фильтров таблиц
Избыточные запросы при изменении значений лукапа

Избыточные запросы при изменении значений лукапа

Проблема

При использовании компонента 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, каждое изменение значения (включая промежуточные состояния при вводе текста) вызывает обработчик.

Значение лукапа может изменяться так:

  1. Пустое значение → объект пуст или null
  2. Ввод текста → объект с частично заполненными полями
  3. Выбор из списка → полный объект с 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 раз и значительно улучшает пользовательский опыт.