12. Сервисы
🔧 Сервисы в Angular
Заголовок раздела «🔧 Сервисы в Angular»Представь, что компонент — это официант в ресторане 🍽️. Его задача — принять заказ и принести блюдо. Готовит же повар на кухне. Сервисы в Angular — это и есть повара: они содержат бизнес-логику, работают с данными и ничего не знают об интерфейсе.
🎯 Зачем нужны сервисы?
Заголовок раздела «🎯 Зачем нужны сервисы?»| Без сервисов ❌ | С сервисами ✅ |
|---|---|
| Логика в компонентах | Логика изолирована |
| Дублирование кода | Переиспользование |
| Сложное тестирование | Легко мокировать |
| Нет общего состояния | Синглтон-состояние |
| Компоненты знают всё | Разделение ответственности |
Сервисы используются для:
- 📡 Запросов к API — вся HTTP-логика живёт здесь
- 🗃️ Общего состояния — текущий пользователь, настройки, корзина
- 🔧 Утилит — логирование, форматирование, вспомогательные функции
- 🔔 Уведомлений — тосты, диалоги, алерты
📌 @Injectable() — создание сервиса
Заголовок раздела «📌 @Injectable() — создание сервиса»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/userng g s services/userCLI создаст user.service.ts и user.service.spec.ts (тест) автоматически.
🌍 providedIn: ‘root’ — синглтон на весь app
Заголовок раздела «🌍 providedIn: ‘root’ — синглтон на весь app»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: если сервис не используется ни в одном компоненте, он вообще не попадёт в итоговый бандл. Магия!
🏢 Все варианты providedIn
Заголовок раздела «🏢 Все варианты providedIn»// Синглтон на уровне корневого инжектора (по умолчанию)@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); }) ); }}💼 Реальные примеры сервисов
Заголовок раздела «💼 Реальные примеры сервисов»NotificationService — всплывающие уведомления
Заголовок раздела «NotificationService — всплывающие уведомления»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)); }}StorageService — обёртка над localStorage
Заголовок раздела «StorageService — обёртка над localStorage»@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; }}LoggingService — уровни логирования
Заголовок раздела «LoggingService — уровни логирования»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); }}🌐 HttpClient в сервисах (превью)
Заголовок раздела «🌐 HttpClient в сервисах (превью)»Обычно 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 — ему не место в компоненте.