Сервіси та впровадження залежностей в Angular
Сервіси та впровадження залежностей (dependency injection, DI) в Angular працюють у парі: сервіс - це @Injectable-клас зі спільною логікою, а DI доставляє екземпляри компонентам автоматично через параметри конструктора або функцію inject(), без жодного ручного new.
Теорія
TL;DR
- Сервіс (service) - звичайний клас з
@Injectable, де живе логіка, спільна для кількох компонентів: HTTP-запити, стан, валідація - DI передає екземпляри автоматично: ніякого
new ServiceName()у компонентах @Injectable({ providedIn: 'root' })створює один синглтон на весь застосунок, tree-shakable за замовчуванням- Інжектори ієрархічні: кореневий, модульний, компонентний; дочірні успадковують від батьківського
- Правило вибору: логіка потрібна в 2+ компонентах - виноси в сервіс; стан лише одного компонента - залишай у компоненті
Швидкий приклад
// 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:
providers: [{ provide: UserService, useValue: mockUserService }]Компонент не чіпаємо. DI з'явився заради тестованості та гнучкості, а не заради магії.
Як інжектор розв'язує залежності
Інжектори Angular утворюють дерево. Вгорі - кореневий інжектор, що створюється при завантаженні застосунку. Нижче - модульні, потім компонентні. Коли компонент запитує сервіс, Angular піднімається деревом, поки не знайде відповідний провайдер.
@Injectable({ providedIn: 'root' }) реєструє сервіс на рівні кореня. Один екземпляр, спільний для всіх. Lazy-завантажені модулі отримують окрему гілку дерева: сервіси, оголошені в providers: [] такого модуля, ізольовані в цій гілці. Провайдери на рівні компонента (providers: [SomeService] в @Component) створюють новий екземпляр для того компонента і його дочірніх, який знищується разом з компонентом.
Прапор TypeScript emitDecoratorMetadata забезпечує всю цю механіку. На етапі компіляції декоратори записують метадані типів параметрів у клас. Під час виконання інжектор читає design:paramtypes і використовує сам конструктор класу як токен. Жодних рядкових ідентифікаторів.
Три способи реєстрації сервісів
// 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-компонентів.
import { inject } from '@angular/core';
@Component({ selector: 'app-dashboard', template: '...' })
export class DashboardComponent {
private userService = inject(UserService);
private apiUrl = inject(API_URL);
// блок конструктора не потрібен
}Ін'єкція через конструктор повністю робоча і поширена в старіших кодових базах. Обидва підходи коректні. Команди зазвичай обирають один стиль на проект і дотримуються його.
Типові помилки
На практиці помилка з рівнем скопу зустрічається частіше за всі інші: сервіси, які мали б жити на рівні компонента, потрапляють у root, і стан починає витікати між представленнями, де він мав бути ізольованим.
Забули зареєструвати сервіс:
@Injectable() // немає providedIn - ніде не зареєстровано
export class DataService {}
// NullInjectorError: No provider for DataService!Виправлення: @Injectable({ providedIn: 'root' }) для синглтона, або додати до providers: [] потрібного модуля чи компонента.
Обійти DI через new:
@Component({...})
export class MyComponent {
private service = new DataService(/* що тут передавати? */);
}DataService потребує HttpClient. Довелося б самостійно його інстанціювати разом із усім HTTP-стеком. Нетестовано. Використовуй ін'єкцію через конструктор або inject().
Циклічна залежність:
// 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, які провайдери потрібно розв'язати і передати аргументами у фабричну функцію.
Приклади
Базовий: ін'єкція через конструктор з обробкою помилок
// 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()
// 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
// 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-модуля.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.