Перейти к содержимому

12. Сервисы

Представь, что компонент — это официант в ресторане 🍽️. Его задача — принять заказ и принести блюдо. Готовит же повар на кухне. Сервисы в Angular — это и есть повара: они содержат бизнес-логику, работают с данными и ничего не знают об интерфейсе.


Без сервисов ❌С сервисами ✅
Логика в компонентахЛогика изолирована
Дублирование кодаПереиспользование
Сложное тестированиеЛегко мокировать
Нет общего состоянияСинглтон-состояние
Компоненты знают всёРазделение ответственности

Сервисы используются для:

  • 📡 Запросов к API — вся HTTP-логика живёт здесь
  • 🗃️ Общего состояния — текущий пользователь, настройки, корзина
  • 🔧 Утилит — логирование, форматирование, вспомогательные функции
  • 🔔 Уведомлений — тосты, диалоги, алерты

import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root' // 👈 Angular создаст один экземпляр на всё приложение
})
export class UserService {
private currentUser: User | null = null;
getUser(): User | null {
return this.currentUser;
}
setUser(user: User): void {
this.currentUser = user;
}
isLoggedIn(): boolean {
return this.currentUser !== null;
}
logout(): void {
this.currentUser = null;
}
}

Создать сервис через Angular CLI:

Окно терминала
ng generate service services/user
ng g s services/user

CLI создаст user.service.ts и user.service.spec.ts (тест) автоматически.


providedIn: 'root' — самый популярный вариант. Angular создаёт один экземпляр сервиса для всего приложения. HeaderComponent, DashboardComponent и ProfileComponent — все получат один и тот же объект.

@Injectable({ providedIn: 'root' })
export class CartService {
private items: CartItem[] = [];
addItem(product: Product): void {
const existing = this.items.find(i => i.id === product.id);
if (existing) {
existing.quantity++;
} else {
this.items.push({ ...product, quantity: 1 });
}
}
removeItem(productId: number): void {
this.items = this.items.filter(i => i.id !== productId);
}
getTotal(): number {
return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
getCount(): number {
return this.items.reduce((sum, item) => sum + item.quantity, 0);
}
clear(): void {
this.items = [];
}
}

💡 Angular применяет tree-shaking: если сервис не используется ни в одном компоненте, он вообще не попадёт в итоговый бандл. Магия!


// Синглтон на уровне корневого инжектора (по умолчанию)
@Injectable({ providedIn: 'root' })
export class GlobalService {}
// Новый экземпляр для каждого lazy-loaded модуля
@Injectable({ providedIn: 'any' })
export class ModuleService {}
// Предоставляем в конкретном NgModule
@Injectable({ providedIn: FeatureModule })
export class FeatureService {}
// Предоставление в NgModule — старый стиль
@NgModule({
providers: [OrderService] // доступен всем компонентам модуля
})
export class OrderModule {}
// Предоставление в компоненте — новый экземпляр для каждого компонента!
@Component({
selector: 'app-form',
providers: [FormStateService], // 👈 Только этот компонент и его потомки
template: `...`
})
export class FormComponent {
constructor(private formState: FormStateService) {}
}

Представь базу знаний в офисе 📚. Она одна на всю компанию. Любой сотрудник (компонент) подходит к ней и берёт нужную информацию. Никто не создаёт свою копию — все работают с одним экземпляром.

// Это ОДИН объект UserService в памяти браузера
@Injectable({ providedIn: 'root' })
export class AuthService {
private _token: string | null = null;
private _user: User | null = null;
// HeaderComponent вызывает это
// ProfileComponent вызывает это
// DashboardComponent вызывает это
// — все читают ОДНО и то же состояние! 🎯
get user(): User | null { return this._user; }
get isAuthenticated(): boolean { return !!this._token; }
get token(): string | null { return this._token; }
login(credentials: LoginDto): Observable<void> {
return this.http.post<AuthResponse>('/api/auth/login', credentials).pipe(
tap(response => {
this._token = response.token;
this._user = response.user;
localStorage.setItem('token', response.token);
}),
map(() => undefined)
);
}
logout(): void {
this._token = null;
this._user = null;
localStorage.removeItem('token');
}
}

Сервисы могут зависеть от других сервисов — Angular DI разрешает всю цепочку зависимостей автоматически:

@Injectable({ providedIn: 'root' })
export class LoggingService {
private prefix = '[APP]';
log(message: string): void {
console.log(this.prefix, new Date().toISOString(), message);
}
error(message: string, err?: unknown): void {
console.error(this.prefix, '❌', message, err);
}
warn(message: string): void {
console.warn(this.prefix, '⚠️', message);
}
}
@Injectable({ providedIn: 'root' })
export class UserService {
// 👇 Angular сам найдёт и создаст эти зависимости
constructor(
private http: HttpClient,
private logger: LoggingService, // 👈 Сервис в сервисе!
private storage: StorageService // 👈 И ещё один
) {}
loadProfile(): Observable<User> {
this.logger.log('Loading user profile...');
return this.http.get<User>('/api/profile').pipe(
tap(user => {
this.logger.log('Profile loaded: ' + user.name);
this.storage.set('user', user);
}),
catchError(err => {
this.logger.error('Failed to load profile', err);
return throwError(() => err);
})
);
}
}

export interface Notification {
id: number;
type: 'success' | 'error' | 'info' | 'warn';
message: string;
duration?: number;
}
@Injectable({ providedIn: 'root' })
export class NotificationService {
private notifications$ = new BehaviorSubject<Notification[]>([]);
get all$(): Observable<Notification[]> {
return this.notifications$.asObservable();
}
success(message: string, duration = 3000): void {
this.show({ type: 'success', message, duration });
}
error(message: string, duration = 5000): void {
this.show({ type: 'error', message, duration });
}
private show(notification: Omit<Notification, 'id'>): void {
const id = Date.now();
const current = this.notifications$.getValue();
this.notifications$.next([...current, { ...notification, id }]);
// Авто-удаление через указанное время
setTimeout(() => this.dismiss(id), notification.duration ?? 3000);
}
dismiss(id: number): void {
const current = this.notifications$.getValue();
this.notifications$.next(current.filter(n => n.id !== id));
}
}
@Injectable({ providedIn: 'root' })
export class StorageService {
get<T>(key: string): T | null {
try {
const raw = localStorage.getItem(key);
return raw ? JSON.parse(raw) as T : null;
} catch {
return null;
}
}
set<T>(key: string, value: T): void {
localStorage.setItem(key, JSON.stringify(value));
}
remove(key: string): void {
localStorage.removeItem(key);
}
clear(): void {
localStorage.clear();
}
has(key: string): boolean {
return localStorage.getItem(key) !== null;
}
}
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
@Injectable({ providedIn: 'root' })
export class LoggingService {
private level: LogLevel = environment.production ? 'warn' : 'debug';
debug(message: string, data?: unknown): void {
if (this.shouldLog('debug')) console.debug('[DEBUG]', message, data ?? '');
}
info(message: string, data?: unknown): void {
if (this.shouldLog('info')) console.info('[INFO]', message, data ?? '');
}
warn(message: string, data?: unknown): void {
if (this.shouldLog('warn')) console.warn('[WARN]', message, data ?? '');
}
error(message: string, err?: unknown): void {
console.error('[ERROR]', message, err ?? '');
// В продакшене — отправка в Sentry/Bugsnag
}
private shouldLog(level: LogLevel): boolean {
const levels: LogLevel[] = ['debug', 'info', 'warn', 'error'];
return levels.indexOf(level) >= levels.indexOf(this.level);
}
}

Обычно HTTP-запросы делаются именно в сервисах, а не в компонентах:

@Injectable({ providedIn: 'root' })
export class ProductService {
private readonly apiUrl = environment.apiUrl + '/products';
constructor(private http: HttpClient) {}
getAll(): Observable<Product[]> {
return this.http.get<Product[]>(this.apiUrl);
}
getById(id: number): Observable<Product> {
return this.http.get<Product>(this.apiUrl + '/' + id);
}
create(product: Omit<Product, 'id'>): Observable<Product> {
return this.http.post<Product>(this.apiUrl, product);
}
update(id: number, changes: Partial<Product>): Observable<Product> {
return this.http.patch<Product>(this.apiUrl + '/' + id, changes);
}
delete(id: number): Observable<void> {
return this.http.delete<void>(this.apiUrl + '/' + id);
}
}

📖 Полный разбор HttpClient — в уроке 14. HttpClient


// ❌ Плохо: логика в компоненте
@Component({ template: `<li *ngFor="let u of users">{{ u.name }}</li>` })
export class BadComponent implements OnInit {
users: User[] = [];
// HTTP и бизнес-логика прямо в компоненте — нельзя переиспользовать и тяжело тестировать!
constructor(private http: HttpClient) {}
ngOnInit(): void {
this.http.get<User[]>('/api/users').subscribe(u => (this.users = u));
}
}
// ✅ Хорошо: тонкий компонент делегирует сервису
@Component({ template: `<li *ngFor="let u of users$ | async">{{ u.name }}</li>` })
export class GoodComponent {
// Компонент ТОЛЬКО отображает данные
users$ = this.userService.getAll();
constructor(private userService: UserService) {}
}

🎯 Принцип: Компонент управляет отображением, сервис управляет данными и логикой. Если метод не связан с DOM — ему не место в компоненте.