Skip to main content

HttpClient та перехоплювачі в Angular

HttpClient - типізований HTTP-сервіс Angular, що обгортає кожен запит в RxJS Observable. Перехоплювачі (interceptors) - middleware-функції між твоїм кодом і мережею, які трансформують кожен запит і відповідь глобально без змін у конкретних сервісах.

Теорія

TL;DR

  • HttpClient схожий на пошту: ти даєш запит, вона бере на себе серіалізацію і доставку
  • Перехоплювач схожий на контрольний пункт: кожен пакет проходить через нього і може бути перевірений, змінений або зупинений
  • Головна різниця: HttpClient робить запити, перехоплювачі змінюють ці запити глобально
  • Перехоплювачі для спільних задач: auth-токени, логування, глобальна обробка помилок
  • Перехоплювачі виконуються по черзі; кожен може зупинити ланцюг, не викликавши next()

Швидкий приклад

typescript
// HttpClient: типізований GET-запит const users$ = this.http.get<User[]>('/api/users'); // Функціональний перехоплювач: автоматично додає токен до кожного запиту export const authInterceptor: HttpInterceptorFn = (req, next) => { const token = inject(AuthService).getToken(); if (!token) return next(req); const cloned = req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }); return next(cloned); // передаємо змінений запит далі }; // Результат: кожен HTTP-запит отримає токен без змін у сервісах

HttpRequest в Angular іммутабельний. Завжди використовуй clone(), щоб створити змінену копію.

Ключова різниця

HttpClient - це відправник запитів. Ти викликаєш .get(), .post(), .put() і отримуєш Observable назад. Перехоплювачі - це модифікатори: вони обробляють Observable до того, як запит пішов у мережу, і знову коли прийшла відповідь. Можна зареєструвати кілька перехоплювачів, і вони виконуються по черзі. Відповідь проходить через той самий ланцюг у зворотному напрямку.

Коли що використовувати

  • HttpClient напряму: будь-який конкретний HTTP-запит у сервісі чи компоненті
  • Auth-перехоплювач: додавати JWT-токен один раз, а не в кожному методі сервісу
  • Error-перехоплювач: глобально ловити 401/403 і перенаправляти на сторінку логіну
  • Logging-перехоплювач: відстежувати час запитів і помилки по всьому застосунку
  • Cache-перехоплювач: повертати збережені GET-відповіді без звернення до мережі
  • Retry-перехоплювач: автоматично повторювати невдалі запити з паузою

Таблиця порівняння

АспектHttpClientПерехоплювач
ПризначенняВиконує HTTP-запитиТрансформує запити/відповіді глобально
Область діїОдин запит за разУсі запити, що відповідають умовам
Виконується колиТи викликаєш .get(), .post() тощоДо кожного запиту і після кожної відповіді
Може змінити запитТак, через параметри викликуТак, для всіх запитів одразу
Може зупинити ланцюгНіТак - пропуск next() скасовує запит
Типовий варіантAPI-виклики в сервісахAuth, логування, помилки, кешування

Як працює ланцюг перехоплювачів

Коли ти викликаєш this.http.get(), Angular створює об'єкт HttpRequest і передає його через кожен зареєстрований перехоплювач по черзі. Кожен може клонувати запит, змінити його і передати далі через next(cloned). Останнім у ланцюгу стоїть справжній HTTP-обробник, що надсилає запит у мережу. Відповідь іде через той самий ланцюг у зворотному напрямку. Якщо якийсь перехоплювач не викликає next(), ланцюг обривається і жодного запиту не надсилається. Саме так cache-перехоплювачі повертають збережені дані без звернення до мережі.

Сучасний Angular (v15+) використовує функціональні перехоплювачі, зареєстровані через withInterceptors(). Класові перехоплювачі з implements HttpInterceptor ще працюють, але більше не є рекомендованим підходом.

typescript
// main.ts - реєстрація перехоплювачів bootstrapApplication(AppComponent, { providers: [ provideHttpClient( withInterceptors([ authInterceptor, // виконується першим loggingInterceptor, // виконується другим errorInterceptor, // виконується третім ]) ), ], });

Типові помилки

Забули повернути next() - запит не надсилається

typescript
// НЕПРАВИЛЬНО: клонуємо запит, але ніколи не передаємо його далі export const authInterceptor: HttpInterceptorFn = (req, next) => { const cloned = req.clone({ setHeaders: { 'X-Custom': 'value' } }); // Відсутнє: return next(cloned); }; // ПРАВИЛЬНО export const authInterceptor: HttpInterceptorFn = (req, next) => { const cloned = req.clone({ setHeaders: { 'X-Custom': 'value' } }); return next(cloned); };

Мутація запиту замість клонування

typescript
// НЕПРАВИЛЬНО: HttpRequest іммутабельний, ця операція нічого не змінює export const authInterceptor: HttpInterceptorFn = (req, next) => { req.headers.set('Authorization', 'Bearer token'); // жодного ефекту return next(req); // заголовки не змінились }; // ПРАВИЛЬНО: clone() створює новий запит із застосованими змінами export const authInterceptor: HttpInterceptorFn = (req, next) => { const cloned = req.clone({ setHeaders: { Authorization: 'Bearer token' } }); return next(cloned); };

Проковтнути помилку замість передачі її далі

typescript
// НЕПРАВИЛЬНО: компонент ніколи не дізнається, що запит провалився export const errorInterceptor: HttpInterceptorFn = (req, next) => { return next(req).pipe( catchError(() => of(null)) // помилка зникає ); }; // ПРАВИЛЬНО: обробляємо що потрібно, потім передаємо помилку далі export const errorInterceptor: HttpInterceptorFn = (req, next) => { return next(req).pipe( catchError((error: HttpErrorResponse) => { if (error.status === 401) inject(Router).navigate(['/login']); return throwError(() => error); }) ); };

Реєстрація класового перехоплювача в кількох модулях

typescript
// НЕПРАВИЛЬНО: якщо SharedModule імпортується в кількох місцях, // перехоплювач виконується кілька разів на один запит @NgModule({ providers: [ { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true } ] }) export class SharedModule {} // ПРАВИЛЬНО: використовуй функціональні перехоплювачі, // зареєстровані один раз через provideHttpClient(withInterceptors([...]))

Нескінченний цикл повторних запитів

typescript
// НЕПРАВИЛЬНО: повтор 401 без рефрешу токена зациклиться export const retryInterceptor: HttpInterceptorFn = (req, next) => { return next(req).pipe( retry(3) // повторює наосліп, включаючи 401 що ніколи не пройдуть ); }; // ПРАВИЛЬНО: обмежуй кількість спроб і конкретизуй умову export const retryInterceptor: HttpInterceptorFn = (req, next) => { return next(req).pipe( retry({ count: 3, delay: 1000 }) ); };

Де зустрічається в реальних проектах

  • NgRx-застосунки: перехоплювачі відправляють loading/success/error actions, щоб стор відображав стан HTTP-операцій
  • Firebase: перехоплювачі прикріплюють ID-токени і автоматично оновлюють їх коли закінчується термін дії
  • Багатосередовищні API: перехоплювачі додають базовий URL або версію (/v2/) без змін у коді сервісів
  • Sentry або Datadog: перехоплювачі ловлять HTTP-помилки і пересилають їх у моніторинг з повним контекстом запиту
  • Нормалізація відповідей API: перехоплювачі конвертують snake_case з бекенду в camelCase для фронтенду

На практиці найчастіший варіант - два окремих перехоплювачі: один для auth, інший для помилок. Тримати їх роздільно робить тестування і дебаг набагато простішими.

Питання для поглиблення

Q: Чому HttpClient повертає Observables, а не Promises?
A: Observable можна скасувати - відписка зупиняє запит у польоті. Promise не можна скасувати, він завжди дорезолвиться. До того ж з Observables ти отримуєш оператори retry(), debounceTime(), shareReplay(), які роблять HTTP-логіку значно гнучкішою.

Q: Що відбувається, якщо один перехоплювач у ланцюгу кидає помилку?
A: Помилка поширюється через catchError наступних перехоплювачів. Якщо жоден не перехопить - вона досягає підписки компонента. Порядок реєстрації має значення: якщо хочеш ловити помилки від усіх попередніх перехоплювачів, ставь error-interceptor останнім.

Q: Як пропустити перехоплювач для конкретних запитів?
A: Додай кастомний заголовок при запиті і перевіряй його в перехоплювачі. Наприклад: req.clone({ setHeaders: { 'X-Skip-Auth': 'true' } }). У перехоплювачі: if (req.headers.has('X-Skip-Auth')) return next(req);

Q: Чи можуть перехоплювачі змінювати тіло відповіді?
A: Так. Використовуй map() після next() і перевіряй тип події: map(event => event instanceof HttpResponse ? event.clone({ body: transform(event.body) }) : event). Для потокових відповідей - наприклад файлів - тіло недоступне одразу, тому такий сценарій потребує окремої обробки.

Q: (Senior) Як реалізувати дедуплікацію запитів - запобігти повторним викликам до одного ендпоінта протягом 100 мс?
A: Зберігай активні запити в Map<string, Observable> з ключем по URL. Коли той самий URL приходить повторно в межах часового вікна - повертай закешований Observable через shareReplay(1) замість нового виклику. Видаляй запис після завершення або помилки.

Приклади

Базовий: HttpClient із типізованими запитами та обробкою помилок

typescript
@Injectable({ providedIn: 'root' }) export class UserService { private http = inject(HttpClient); getUsers(): Observable<User[]> { return this.http.get<User[]>('/api/users').pipe( retry(2), catchError((error: HttpErrorResponse) => { if (error.status === 404) return of([]); return throwError(() => error); }) ); } createUser(user: CreateUserDto): Observable<User> { return this.http.post<User>('/api/users', user); } searchUsers(query: string): Observable<User[]> { return this.http.get<User[]>('/api/users', { params: { q: query, limit: '10' } }); } } // HttpClient автоматично серіалізує і десеріалізує JSON. // Дженерик <User[]> дає типову безпеку на рівні відповіді.

retry(2) повторює запит двічі перш ніж здатись. Для 404 сервіс повертає порожній масив замість помилки - поширений патерн, коли відсутній ресурс не є помилковим станом для UI.

Середній: Auth-перехоплювач з рефрешем токена

typescript
export const authInterceptor: HttpInterceptorFn = (req, next) => { const auth = inject(AuthService); const router = inject(Router); // Пропускаємо логін без токена if (req.url.includes('/auth/login')) return next(req); const token = auth.getToken(); if (!token) { router.navigate(['/login']); return throwError(() => new Error('No token')); } const cloned = req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }); return next(cloned).pipe( catchError((error: HttpErrorResponse) => { if (error.status === 401) { // Токен протух - намагаємось оновити один раз return auth.refreshToken().pipe( switchMap((newToken) => { const retried = req.clone({ setHeaders: { Authorization: `Bearer ${newToken}` } }); return next(retried); }), catchError(() => { router.navigate(['/login']); return throwError(() => error); }) ); } return throwError(() => error); }) ); }; // Компонент, що зробив запит, ніколи не дізнається про рефреш. // Ротація токена повністю прозора для решти застосунку.

Просунутий: Cache-перехоплювач із TTL

typescript
@Injectable({ providedIn: 'root' }) export class HttpCacheService { private cache = new Map<string, { body: any; timestamp: number }>(); private readonly TTL = 5 * 60 * 1000; // 5 хвилин get(url: string): any | null { const entry = this.cache.get(url); if (!entry || Date.now() - entry.timestamp > this.TTL) return null; return entry.body; } set(url: string, body: any): void { this.cache.set(url, { body, timestamp: Date.now() }); } } export const cacheInterceptor: HttpInterceptorFn = (req, next) => { const cache = inject(HttpCacheService); if (req.method !== 'GET') return next(req); const cached = cache.get(req.url); if (cached) { return of(new HttpResponse({ body: cached, status: 200 })); } return next(req).pipe( tap((event) => { if (event instanceof HttpResponse) { cache.set(req.url, event.body); } }) ); }; // Для закешованих URL ланцюг обривається тут. // next() не викликається - жодного мережевого запиту.

Компонент отримує об'єкт HttpResponse ідентичний за структурою реальній відповіді, тому жодної спеціальної обробки на стороні виклику не потрібно.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?