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

Рекомендации по использованию поллинга

Проблема

Использование поллинга (периодического опроса сервера для получения обновлений) в продуктах QPalette может приводить к серьёзным проблемам производительности из-за вкладочной архитектуры приложения.

Основные риски

  • Множественные вкладки — архитектура QPalette позволяет открывать потенциально бесконечное количество вкладок. Если каждая вкладка запускает свой поллинг, количество запросов к серверу растёт линейно с количеством вкладок.
  • Сайд-эффекты на загрузку данных — на обработчики поллинг-запросов могут быть установлены сайд-эффекты (обновление состояния, вызов других сервисов, перерисовка UI), которые снижают производительность системы.
  • Утечки памяти — незавершённые подписки на поллинг-запросы могут привести к утечкам памяти и накоплению неиспользуемых таймеров.
  • Избыточная нагрузка на сервер — десятки одновременно работающих вкладок могут генерировать сотни запросов в минуту, создавая значительную нагрузку на бэкенд.

Пример проблемы

import { Component, OnInit } from '@angular/core';
import { interval } from 'rxjs';
import { switchMap } from 'rxjs/operators';
 
@Component({
  selector: 'app-dashboard',
  templateUrl: './dashboard.component.html'
})
export class DashboardComponent implements OnInit {
  data: any[] = [];
 
  constructor(private dataService: DataService) {}
 
  ngOnInit(): void {
    // ❌ ПРОБЛЕМА: Поллинг запускается при каждом открытии вкладки
    // и не останавливается при её закрытии
    interval(5000) // Запрос каждые 5 секунд
      .pipe(switchMap(() => this.dataService.getData()))
      .subscribe(data => {
        this.data = data;
        // Сайд-эффекты могут снижать производительность
        this.updateCharts();
        this.recalculateStatistics();
        this.notifyOtherServices();
      });
  }
 
  // Множественные сайд-эффекты
  updateCharts(): void { /* ... */ }
  recalculateStatistics(): void { /* ... */ }
  notifyOtherServices(): void { /* ... */ }
}

Сценарий: Пользователь открывает 10 вкладок Dashboard → 10 × 12 запросов/мин = 120 запросов в минуту + множественные сайд-эффекты.

Решения

Решение 1: Отказ от поллинга в пользу Server-Sent Events (SSE)

Рекомендуется: Использовать SseService из библиотеки @qpalette/sse-client для получения обновлений в реальном времени без постоянных HTTP-запросов.

Преимущества SSE

  • Одно соединение вместо множества запросов.
  • Мгновенные обновления при изменении данных на сервере.
  • Меньшая нагрузка на сервер и клиент.
  • Автоматическое переподключение при обрыве связи.

Пример использования SseService

import { Component, OnInit, OnDestroy } from '@angular/core';
import { SseService } from '@qpalette/sse-client';
import { Subject, takeUntil } from 'rxjs';
 
@Component({
  selector: 'app-dashboard',
  templateUrl: './dashboard.component.html'
})
export class DashboardComponent implements OnInit, OnDestroy {
  data: any[] = [];
  private destroy$ = new Subject<void>();
 
  constructor(private sseService: SseService) {}
 
  ngOnInit(): void {
    // ✅ РЕШЕНИЕ: Использование SSE для получения обновлений
    this.sseService.open({
      url: '/api/dashboard/events',
      method: 'GET'
    })
      .pipe(takeUntil(this.destroy$))
      .subscribe({
        next: (message) => {
          // Обработка события с сервера
          if (message.event === 'data-update') {
            this.data = JSON.parse(message.data);
          }
        },
        error: (error) => {
          console.error('SSE connection error:', error);
        }
      });
  }
 
  ngOnDestroy(): void {
    // Автоматическое закрытие соединения при уничтожении компонента
    this.destroy$.next();
    this.destroy$.complete();
  }
}

📚 Документация: Подробнее о SseService см. в разделе Библиотеки → SseService.

Решение 2: Ограничение поллинга по времени

Если использование SSE невозможно, ограничьте время работы поллинга.

import { Component, OnInit, OnDestroy } from '@angular/core';
import { interval, Subject, timer } from 'rxjs';
import { switchMap, takeUntil, take } from 'rxjs/operators';
 
@Component({
  selector: 'app-notifications',
  templateUrl: './notifications.component.html'
})
export class NotificationsComponent implements OnInit, OnDestroy {
  notifications: any[] = [];
  private destroy$ = new Subject<void>();
 
  constructor(private notificationService: NotificationService) {}
 
  ngOnInit(): void {
    // ✅ Поллинг работает только 5 минут
    const maxDuration$ = timer(5 * 60 * 1000); // 5 минут
 
    interval(10000) // Каждые 10 секунд
      .pipe(
        takeUntil(maxDuration$), // Остановка через 5 минут
        takeUntil(this.destroy$), // Остановка при уничтожении компонента
        switchMap(() => this.notificationService.getNotifications())
      )
      .subscribe(notifications => {
        this.notifications = notifications;
      });
  }
 
  ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

Решение 3: Ограничение по количеству запросов

import { Component, OnInit, OnDestroy } from '@angular/core';
import { interval, Subject } from 'rxjs';
import { switchMap, takeUntil, take } from 'rxjs/operators';
 
@Component({
  selector: 'app-status-monitor',
  templateUrl: './status-monitor.component.html'
})
export class StatusMonitorComponent implements OnInit, OnDestroy {
  status: string = 'pending';
  private destroy$ = new Subject<void>();
 
  constructor(private statusService: StatusService) {}
 
  ngOnInit(): void {
    // ✅ Максимум 30 запросов (5 минут × 6 запросов/мин)
    interval(10000)
      .pipe(
        take(30), // Остановка после 30 запросов
        takeUntil(this.destroy$),
        switchMap(() => this.statusService.getStatus())
      )
      .subscribe(status => {
        this.status = status;
        
        // Остановка поллинга при получении финального статуса
        if (status === 'completed' || status === 'failed') {
          this.destroy$.next();
        }
      });
  }
 
  ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

Решение 4: Завершение поллинга при закрытии вкладки

Критически важно: Всегда завершайте поллинг в ngOnDestroy и используйте оператор takeUntil.

import { Component, OnInit, OnDestroy } from '@angular/core';
import { interval, Subject } from 'rxjs';
import { switchMap, takeUntil } from 'rxjs/operators';
 
@Component({
  selector: 'app-data-viewer',
  templateUrl: './data-viewer.component.html'
})
export class DataViewerComponent implements OnInit, OnDestroy {
  data: any;
  private destroy$ = new Subject<void>();
 
  constructor(private dataService: DataService) {}
 
  ngOnInit(): void {
    interval(5000)
      .pipe(
        // ✅ Завершение при уничтожении компонента
        takeUntil(this.destroy$),
        switchMap(() => this.dataService.getData())
      )
      .subscribe(data => {
        this.data = data;
      });
  }
 
  ngOnDestroy(): void {
    // ✅ Обязательное завершение подписки
    this.destroy$.next();
    this.destroy$.complete();
    console.log('Polling stopped');
  }
}

Решение 5: Остановка предыдущего поллинга при старте нового

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

import { Injectable, OnDestroy } from '@angular/core';
import { interval, Subject, Observable } from 'rxjs';
import { switchMap, takeUntil, shareReplay } from 'rxjs/operators';
 
@Injectable({
  providedIn: 'root'
})
export class PollingService implements OnDestroy {
  private destroy$ = new Subject<void>();
  private pollingStream$: Observable<any> | null = null;
 
  constructor(private dataService: DataService) {}
 
  /**
   * Запускает поллинг. Если поллинг уже активен, останавливает предыдущий.
   * 
   * @param intervalMs - Интервал поллинга в миллисекундах.
   * @returns Observable с данными поллинга.
   */
  startPolling(intervalMs: number = 5000): Observable<any> {
    // ✅ Остановка предыдущего поллинга
    this.stopPolling();
 
    // Создание нового destroy$ для нового поллинга
    this.destroy$ = new Subject<void>();
 
    // Создание общего потока для всех подписчиков
    this.pollingStream$ = interval(intervalMs).pipe(
      takeUntil(this.destroy$),
      switchMap(() => this.dataService.getData()),
      shareReplay(1) // Один запрос для всех подписчиков
    );
 
    return this.pollingStream$;
  }
 
  /**
   * Останавливает активный поллинг.
   */
  stopPolling(): void {
    if (this.destroy$) {
      this.destroy$.next();
      this.destroy$.complete();
    }
    this.pollingStream$ = null;
  }
 
  ngOnDestroy(): void {
    this.stopPolling();
  }
}

Использование PollingService

import { Component, OnInit, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { PollingService } from './polling.service';
 
@Component({
  selector: 'app-dashboard',
  templateUrl: './dashboard.component.html'
})
export class DashboardComponent implements OnInit, OnDestroy {
  data: any;
  private componentDestroy$ = new Subject<void>();
 
  constructor(private pollingService: PollingService) {}
 
  ngOnInit(): void {
    // ✅ Поллинг управляется централизованно
    // Если другая вкладка уже запустила поллинг, он будет остановлен
    this.pollingService.startPolling(5000)
      .pipe(takeUntil(this.componentDestroy$))
      .subscribe(data => {
        this.data = data;
      });
  }
 
  ngOnDestroy(): void {
    this.componentDestroy$.next();
    this.componentDestroy$.complete();
    // Поллинг останавливается в сервисе
  }
}

Решение 6: Минимизация сайд-эффектов

Тщательно контролируйте сайд-эффекты в подписках на поллинг-запросы.

import { Component, OnInit, OnDestroy } from '@angular/core';
import { interval, Subject } from 'rxjs';
import { switchMap, takeUntil, distinctUntilChanged, tap } from 'rxjs/operators';
 
@Component({
  selector: 'app-optimized-dashboard',
  templateUrl: './optimized-dashboard.component.html'
})
export class OptimizedDashboardComponent implements OnInit, OnDestroy {
  data: any[] = [];
  private destroy$ = new Subject<void>();
 
  constructor(
    private dataService: DataService,
    private analyticsService: AnalyticsService
  ) {}
 
  ngOnInit(): void {
    interval(5000)
      .pipe(
        takeUntil(this.destroy$),
        switchMap(() => this.dataService.getData()),
        // ✅ Обновление только при реальном изменении данных
        distinctUntilChanged((prev, curr) => 
          JSON.stringify(prev) === JSON.stringify(curr)
        ),
        // ✅ Лёгкие сайд-эффекты в tap
        tap(data => {
          // Только логирование, без тяжёлых операций
          console.log('Data updated', data.length);
        })
      )
      .subscribe(data => {
        this.data = data;
        // ✅ Минимальная обработка в subscribe
        // Тяжёлые операции вынесены в отдельные методы с debounce
      });
  }
 
  // ✅ Тяжёлые операции вызываются по событиям пользователя,
  // а не на каждый поллинг
  onUserInteraction(): void {
    this.recalculateStatistics();
    this.analyticsService.trackEvent('user_interaction');
  }
 
  private recalculateStatistics(): void {
    // Тяжёлые вычисления
  }
 
  ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

Checklist: Безопасное использование поллинга

При внедрении поллинга проверьте:

  • Рассмотрена альтернатива SSE — можно ли использовать SseService вместо поллинга?
  • Установлен takeUntil с destroy$ — поллинг останавливается при уничтожении компонента?
  • Реализован ngOnDestroy — вызываются destroy$.next() и destroy$.complete()?
  • Ограничение по времени или количеству — есть ли автоматическая остановка поллинга?
  • Контроль множественных экземпляров — предотвращено ли создание нескольких поллингов для одной задачи?
  • Минимизированы сайд-эффекты — используется ли distinctUntilChanged и минимальная обработка в subscribe?
  • Оптимальный интервал — не слишком ли частые запросы (рекомендуется не чаще 1 раза в 5-10 секунд)?
  • Условная остановка — останавливается ли поллинг при достижении целевого состояния?
  • Обработка ошибок — есть ли корректная обработка ошибок сети?
  • Мониторинг в production — отслеживается ли количество активных поллингов?

Сравнение подходов

ХарактеристикаПоллингSSE (SseService)
Количество соединенийN запросов/минуту × M вкладок1 соединение на приложение
Задержка обновленияДо интервала поллинга (5-30 сек)Мгновенно (<1 сек)
Нагрузка на серверВысокаяНизкая
Нагрузка на клиентСредняя-ВысокаяНизкая
Сложность реализацииПростаяСредняя
Управление жизненным цикломТребует вниманияАвтоматическое
Поддержка переподключенияТребует реализацииВстроенная
Использование памятиРастёт с количеством подписокСтабильное

Рекомендации по интервалам поллинга

Тип данныхРекомендуемый интервалОбоснование
Уведомления30-60 секундНекритичные обновления, лучше использовать SSE
Статус фоновой задачи5-10 секундОграничить по количеству запросов или завершению задачи
Дашборд с метриками10-30 секундДанные не требуют мгновенного обновления
Критичные данныеНе рекомендуетсяИспользовать SSE или WebSocket

Антипаттерны

❌ Поллинг без takeUntil

// ПЛОХО: Подписка не завершается
ngOnInit(): void {
  interval(5000)
    .pipe(switchMap(() => this.service.getData()))
    .subscribe(data => this.data = data);
}

❌ Множественные поллинги в одном компоненте

// ПЛОХО: 3 независимых поллинга
ngOnInit(): void {
  interval(5000).pipe(switchMap(() => this.service.getData1()))...
  interval(5000).pipe(switchMap(() => this.service.getData2()))...
  interval(5000).pipe(switchMap(() => this.service.getData3()))...
}

Решение: Объединить запросы или использовать разные интервалы.

❌ Тяжёлые сайд-эффекты в subscribe

// ПЛОХО: Тяжёлые операции на каждый поллинг
.subscribe(data => {
  this.data = data;
  this.recalculateEverything(); // Тяжёлая операция
  this.updateAllCharts(); // Перерисовка всех графиков
  this.notifyAllServices(); // Множественные вызовы
});

❌ Слишком частый поллинг

// ПЛОХО: Запросы каждую секунду
interval(1000) // 60 запросов/минуту на вкладку!

Миграция с поллинга на SSE

Шаг 1: Установите библиотеку

npm install @qpalette/sse-client

Шаг 2: Замените поллинг на SSE

До (поллинг):

interval(5000)
  .pipe(
    takeUntil(this.destroy$),
    switchMap(() => this.http.get('/api/notifications'))
  )
  .subscribe(data => this.notifications = data);

После (SSE):

this.sseService.open({ url: '/api/notifications/stream' })
  .pipe(takeUntil(this.destroy$))
  .subscribe(message => {
    if (message.event === 'notification') {
      this.notifications = JSON.parse(message.data);
    }
  });

Шаг 3: Адаптируйте бэкенд

Реализуйте SSE endpoint на сервере, который отправляет события при изменении данных вместо ответа на каждый запрос.

Заключение

Поллинг — удобный, но ресурсоёмкий способ получения обновлений. В условиях вкладочной архитектуры QPalette необходимо:

  • Предпочитать SSE — использовать SseService для критичных и часто обновляемых данных.
  • Ограничивать поллинг — по времени, количеству запросов и жизненному циклу компонента.
  • Управлять жизненным циклом — обязательно завершать подписки в ngOnDestroy.
  • Минимизировать сайд-эффекты — избегать тяжёлых операций в обработчиках поллинга.
  • Мониторить производительность — отслеживать количество активных поллингов в production.

Правильное использование поллинга или его замена на SSE значительно снижает нагрузку на систему и улучшает пользовательский опыт.