Skip to main content

Сервіси та впровадження залежностей в Angular

Сервіси та впровадження залежностей (dependency injection, DI) в Angular працюють у парі: сервіс - це @Injectable-клас зі спільною логікою, а DI доставляє екземпляри компонентам автоматично через параметри конструктора або функцію inject(), без жодного ручного new.

Теорія

TL;DR

  • Сервіс (service) - звичайний клас з @Injectable, де живе логіка, спільна для кількох компонентів: HTTP-запити, стан, валідація
  • DI передає екземпляри автоматично: ніякого new ServiceName() у компонентах
  • @Injectable({ providedIn: 'root' }) створює один синглтон на весь застосунок, tree-shakable за замовчуванням
  • Інжектори ієрархічні: кореневий, модульний, компонентний; дочірні успадковують від батьківського
  • Правило вибору: логіка потрібна в 2+ компонентах - виноси в сервіс; стан лише одного компонента - залишай у компоненті

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

typescript
// user.service.ts import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; @Injectable({ providedIn: 'root' }) // один екземпляр на весь застосунок export class UserService { constructor(private http: HttpClient) {} // Angular сам передає HttpClient getUsers(): Observable<User[]> { return this.http.get<User[]>('/api/users'); } } // user-list.component.ts @Component({ selector: 'app-user-list', template: '...' }) export class UserListComponent implements OnInit { constructor(private userService: UserService) {} // Angular резолвить це ngOnInit() { this.userService.getUsers().subscribe(users => this.users = users); } }

UserService ніде не викликає new HttpClient(). Компонент ніколи не викликає new UserService(). Інжектор Angular читає типи параметрів конструктора під час виконання і передає готові екземпляри. Ось і вся ідея.

Навіщо потрібен DI

Без DI компонент, якому потрібні HTTP-запити, або жорстко прошивав би HTTP-налаштування всередині себе, або сам створював UserService. Замінити це в тесті означало б переписувати компонент. З DI достатньо одного рядка в TestBed.configureTestingModule:

typescript
providers: [{ provide: UserService, useValue: mockUserService }]

Компонент не чіпаємо. DI з'явився заради тестованості та гнучкості, а не заради магії.

Як інжектор розв'язує залежності

Інжектори Angular утворюють дерево. Вгорі - кореневий інжектор, що створюється при завантаженні застосунку. Нижче - модульні, потім компонентні. Коли компонент запитує сервіс, Angular піднімається деревом, поки не знайде відповідний провайдер.

@Injectable({ providedIn: 'root' }) реєструє сервіс на рівні кореня. Один екземпляр, спільний для всіх. Lazy-завантажені модулі отримують окрему гілку дерева: сервіси, оголошені в providers: [] такого модуля, ізольовані в цій гілці. Провайдери на рівні компонента (providers: [SomeService] в @Component) створюють новий екземпляр для того компонента і його дочірніх, який знищується разом з компонентом.

Прапор TypeScript emitDecoratorMetadata забезпечує всю цю механіку. На етапі компіляції декоратори записують метадані типів параметрів у клас. Під час виконання інжектор читає design:paramtypes і використовує сам конструктор класу як токен. Жодних рядкових ідентифікаторів.

Три способи реєстрації сервісів

typescript
// 1. Кореневий синглтон - найпоширеніший, tree-shakable @Injectable({ providedIn: 'root' }) export class AuthService {} // 2. Компонентний скоп - новий екземпляр для піддерева компонента @Component({ selector: 'app-checkout', providers: [CartService] // ізольований, знищується разом з компонентом }) export class CheckoutComponent {} // 3. InjectionToken для значень, що не є класами export const API_URL = new InjectionToken<string>('API_URL'); // реєстрація providers: [{ provide: API_URL, useValue: 'https://api.example.com' }] // споживання private apiUrl = inject(API_URL);

Типи провайдерів

ПровайдерЩо робитьТипове застосування
useClassПідставляє інший класЗамінити ConsoleLogger на FileLogger
useValueНадає статичне значенняКонстанти конфігурації, feature flags
useFactoryСтворює екземпляр через функціюПотрібна конфігурація під час виконання
useExistingПсевдонім для іншого токенаПерейменування сервісу зі збереженням зворотної сумісності

Функція inject()

Angular 14 додав inject() як альтернативу ін'єкції через конструктор. Вона працює в ініціалізаторах полів і добре підходить для standalone-компонентів.

typescript
import { inject } from '@angular/core'; @Component({ selector: 'app-dashboard', template: '...' }) export class DashboardComponent { private userService = inject(UserService); private apiUrl = inject(API_URL); // блок конструктора не потрібен }

Ін'єкція через конструктор повністю робоча і поширена в старіших кодових базах. Обидва підходи коректні. Команди зазвичай обирають один стиль на проект і дотримуються його.

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

На практиці помилка з рівнем скопу зустрічається частіше за всі інші: сервіси, які мали б жити на рівні компонента, потрапляють у root, і стан починає витікати між представленнями, де він мав бути ізольованим.

Забули зареєструвати сервіс:

typescript
@Injectable() // немає providedIn - ніде не зареєстровано export class DataService {} // NullInjectorError: No provider for DataService!

Виправлення: @Injectable({ providedIn: 'root' }) для синглтона, або додати до providers: [] потрібного модуля чи компонента.

Обійти DI через new:

typescript
@Component({...}) export class MyComponent { private service = new DataService(/* що тут передавати? */); }

DataService потребує HttpClient. Довелося б самостійно його інстанціювати разом із усім HTTP-стеком. Нетестовано. Використовуй ін'єкцію через конструктор або inject().

Циклічна залежність:

typescript
// user.service.ts constructor(private auth: AuthService) {} // auth.service.ts constructor(private user: UserService) {} // A потребує B, B потребує A // Cannot instantiate cyclic dependency!

Виправлення: виноси спільну логіку в третій сервіс. Або роби ліниву ін'єкцію через inject() всередині методу, а не при ініціалізації класу.

Випадкове перевизначення провайдера: якщо два провайдери реєструються для одного токена в одному інжекторі, останній виграє без жодного попередження. Корисно для навмисного мокування, але може дивувати при злитті модулів.

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

  • HttpClient - сам по собі DI-сервіс, надається через provideHttpClient() у standalone-застосунках
  • MatDialog з Angular Material - кореневий сервіс для відкриття модальних вікон
  • Store з NgRx ін'єктується в будь-який компонент або effect, що читає стан
  • Firestore з AngularFire використовує ту саму схему для роботи з базою даних
  • У NX monorepo-проектах генератор одразу додає providedIn: 'root' до кожного нового сервісу

Питання на співбесіді

Q: Яка різниця між providedIn: 'root' і providers: [] на рівні модуля?
A: providedIn: 'root' реєструє один екземпляр на весь застосунок і є tree-shakable: якщо сервіс ніхто не ін'єктує, він не потрапляє в бандл. providers: [] на рівні модуля обмежує сервіс інжектором цього модуля; при lazy-завантаженні кожен модуль отримує свій екземпляр, якщо оголошує провайдер у себе.

Q: Як Angular зіставляє параметри конструктора з провайдерами без рядкових ідентифікаторів?
A: TypeScript з прапором emitDecoratorMetadata: true записує інформацію про типи параметрів у метадані design:paramtypes класу. Інжектор читає їх під час виконання і використовує сам конструктор класу як токен.

Q: Як працює ієрархія інжекторів при lazy-завантаженні модуля?
A: Lazy-модуль отримує дочірній інжектор, що успадковує від кореневого. Сервіси, оголошені в providers: [] lazy-модуля, ізольовані в цій гілці. Сервіс з providedIn: 'root' завжди один і той самий екземпляр, незалежно від місця ін'єкції.

Q: Як мокувати сервіс у юніт-тестах?
A: Передай мок через providers: [{ provide: UserService, useValue: { getUsers: () => of([]) } }] у TestBed.configureTestingModule. Компонент отримає мок замість реального сервісу. Щоб отримати той самий екземпляр у тілі тесту, використовуй TestBed.inject(UserService).

Q: (Senior) Коли варто використовувати useFactory з deps замість useClass?
A: Коли екземпляру сервісу потрібні значення, недоступні на рівні визначення класу. Наприклад, логер із префіксом, унікальним для кожного feature-модуля, або HTTP-інтерцептор, що читає конфігурацію, передану при bootstrap. Масив deps вказує Angular, які провайдери потрібно розв'язати і передати аргументами у фабричну функцію.

Приклади

Базовий: ін'єкція через конструктор з обробкою помилок

typescript
// user.service.ts import { Injectable } from '@angular/core'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { Observable, throwError } from 'rxjs'; import { catchError } from 'rxjs/operators'; @Injectable({ providedIn: 'root' }) export class UserService { private usersUrl = 'https://jsonplaceholder.typicode.com/users'; constructor(private http: HttpClient) {} getUsers(): Observable<User[]> { return this.http.get<User[]>(this.usersUrl).pipe( catchError((error: HttpErrorResponse) => throwError(() => new Error(`Помилка завантаження: ${error.message}`)) ) ); } } // user-list.component.ts @Component({ selector: 'app-user-list', template: ` <ul *ngIf="users.length"> <li *ngFor="let user of users">{{ user.name }}</li> </ul> <p *ngIf="error">{{ error }}</p> ` }) export class UserListComponent implements OnInit { users: User[] = []; error = ''; constructor(private userService: UserService) {} ngOnInit() { this.userService.getUsers().subscribe({ next: (users) => this.users = users, error: (err) => this.error = err.message }); } } // Завантажує з JSONPlaceholder API, показує імена або повідомлення про помилку

Сервіс обробляє HTTP-помилки в одному місці. Кожен компонент, що викликає getUsers(), отримує однакову обробку помилок без дублювання catchError.

Середній рівень: компонентний скоп сервісу з inject()

typescript
// form-state.service.ts - скопований до піддерева checkout-компонента @Injectable() // без providedIn - буде надано на рівні компонента export class FormStateService { private formData: Partial<Order> = {}; update(data: Partial<Order>) { this.formData = { ...this.formData, ...data }; } get(): Partial<Order> { return this.formData; } } // checkout.component.ts @Component({ selector: 'app-checkout', template: '<app-address-form /><app-payment-form />', providers: [FormStateService] // новий екземпляр, знищується разом з компонентом }) export class CheckoutComponent { private state = inject(FormStateService); } // address-form.component.ts - дочірній компонент отримує той самий екземпляр @Component({ selector: 'app-address-form', template: '...' }) export class AddressFormComponent { private state = inject(FormStateService); // той самий екземпляр, що в CheckoutComponent }

FormStateService ділиться станом між CheckoutComponent і його дочірнім AddressFormComponent, але інша сесія оформлення замовлення в іншому місці застосунку отримає зовсім окремий екземпляр. Ось компонентний скоп інжектора на практиці.

Просунутий рівень: factory provider з InjectionToken

typescript
// logger.service.ts @Injectable({ providedIn: 'root' }) export class LoggerService { prefix = ''; log(msg: string) { console.log(`${this.prefix}${msg}`); } } // feature.module.ts import { InjectionToken, NgModule } from '@angular/core'; import { LoggerService } from './logger.service'; export const FEATURE_LOGGER = new InjectionToken<LoggerService>('FeatureLogger'); @NgModule({ providers: [{ provide: FEATURE_LOGGER, useFactory: (rootLogger: LoggerService) => { const logger = new LoggerService(); logger.prefix = '[Feature] '; return logger; // окремий екземпляр з власним префіксом }, deps: [LoggerService] // бере кореневий LoggerService як аргумент фабрики }] }) export class FeatureModule {} // feature.component.ts @Component({ template: '<p>Перевір консоль</p>' }) export class FeatureComponent { constructor(@Inject(FEATURE_LOGGER) private logger: LoggerService) { this.logger.log('Завантажено'); // виводить "[Feature] Завантажено" } } // Кореневий LoggerService скрізь виводить без префікса

Фабрика створює окремий логер для feature-модуля. Вона отримує LoggerService з батьківського інжектора через deps і кастомізує власну копію. Кореневий логер не змінюється. Саме так Angular Material та інші бібліотеки адаптують поведінку для кожного lazy-модуля.

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

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

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

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