Всем привет! Сегодня хочу разобрать кейс, с которым сталкивается почти каждый Angular-разработчик на существующем проекте.
Часто в компонентах можно встретить такой код:
public user: User | null = null;
public posts: Post[] | null = null;
public stats: Stats | null = null;
constructor(private readonly apiService: ApiService) {}
public ngOnInit(): void {
this.apiService.getUser().subscribe((user) => this.user = user);
this.apiService.getPosts().subscribe((posts) => this.posts = posts);
this.apiService.getStats().subscribe((stats) => this.stats = stats);
}
Все загрузки данных у нас происходят в ngOnInit, и вот в чем беда: данные загружаются с разной скоростью. В итоге пользователи видят, как на месте блоков с данными появляются скелетоны или лоадеры, и потом контент показывается частями. Это может привести к смещению макета. Даже если интерфейс вроде бы нормальный, появление всплывающего контента все равно портит общее впечатление. Как это исправить? Можно использовать резолверы в Angular. Это такой сервис, который загружает данные перед тем, как активировать маршрут. Это еще и позволяет кэшировать данные, чтобы при повторном переходе они не загружались заново. Мы также можем использовать события маршрутизатора, чтобы сделать общий индикатор загрузки.
Давайте начнем с написания резолвера. Сначала определим DTO, который опишет структуру наших данных:
export interface ApiResolverDto {
user: User;
posts: Post[];
stats: Stats;
}
Теперь сам резолвер:
@Injectable({
providedIn: 'root'
})
export class ApiResolver implements Resolve<ApiResolverDto> {
constructor(private readonly apiService: ApiService) {}
resolve(): Observable<ApiResolverDto> {
return forkJoin({
user: this.apiService.getUser(),
posts: this.apiService.getPosts(),
stats: this.apiService.getStats()
});
}
}
Я использовал forkJoin из RxJS, который ожидает завершения всех Observable и возвращает объект с результатами, ключи которого соответствуют ключам в переданном объекте.
Затем мы используем написанный нами резолвер в конфигурации маршрута:
{
path: 'home',
loadComponent: () => import('./home/home.component').then((m) => m.HomeComponent),
resolve: {
data: ApiResolver
},
}
Теперь наш компонент будет выглядеть так:
export class MyPageComponent implements OnInit {
public user: User;
public posts: Post[];
public stats: Stats;
constructor(private readonly route: ActivatedRoute) {}
public ngOnInit(): void {
const data = this.route.snapshot.data['data'] as ApiResolverDto;
this.user = data.user;
this.posts = data.posts;
this.stats = data.stats;
}
}
Мы забираем загруженные нашим резолвером данные из route.snapshot.data. Это безопасно и не требует отписки, так как резолвер отрабатывает один раз при навигации на маршрут.
Глобальный индикатор загрузки
Показывать лоадер мы можем через флаг isLoading в главном компоненте приложения, подписавшись на события роутера. Важно не забыть отписаться, чтобы избежать утечек памяти.
@Component({
selector: 'app-root',
template: `
<div *ngIf="isLoading">Загрузка</div>
<router-outlet></router-outlet>
`
})
export class AppComponent implements OnInit, OnDestroy {
public isLoading = false;
private routerEventsSub: Subscription;
constructor(private readonly router: Router) {}
public ngOnInit(): void {
this.routerEventsSub = this.router.events.subscribe((event) => {
if (event instanceof ResolveStart) {
this.isLoading = true;
}
if (event instanceof ResolveEnd) {
this.isLoading = false;
}
});
}
public ngOnDestroy(): void {
this.routerEventsSub.unsubscribe();
}
}
Кэширование и валидация
Резолвер - идеальное место для кэширования. Вот пример с использованием localStorage:
resolve(): Observable<ApiResolverDto> {
const cacheKey = 'api-resolver-cache';
const cachedData = localStorage.getItem(cacheKey);
if (cachedData) {
return of(JSON.parse(cachedData));
}
return forkJoin({
user: this.apiService.getUser(),
posts: this.apiService.getPosts(),
stats: this.apiService.getStats()
}).pipe(
tap(data => {
localStorage.setItem(cacheKey, JSON.stringify(data));
})
);
}
Для инвалидации кэша в нашем примере достаточно удалить ключ по которму мы храним кэш:
localStorage.removeItem('api-resolver-cache')
Еще один плюс загрузки данных в резолверах это то, что здесь удобно проводить валидацию данных, например, с помощью Zod:
// Определяем схемы Zod для наших данных
const UserSchema = z.object({
id: z.string(),
name: z.string(),
});
const PostSchema = z.object({
id: z.string(),
title: z.string(),
body: z.string(),
});
const StatsSchema = z.object({
views: z.number(),
likes: z.number(),
});
const ApiResolverDtoSchema = z.object({
user: UserSchema,
posts: z.array(PostSchema),
stats: StatsSchema,
});
resolve(): Observable<ApiResolverDto> {
return forkJoin({
user: this.apiService.getUser(),
posts: this.apiService.getPosts(),
stats: this.apiService.getStats()
}).pipe(
map(data => {
return ApiResolverDtoSchema.parse(data);
}),
catchError((error) => {
console.error('Ошибка валидации данных Zod:', error);
this.router.navigate(['/error']);
return EMPTY;
})
);
}
Таким образом, используя резолвер, мы предзагружаем данные до активации маршрута, гарантируя, что весь необходимый контент будет готов к отображению. Это не только избавит наш UI от мигания, при подгрузке данных но и убирает CLS. Кроме того, мы получаем единую точку для загрузки, кэширования, валидации и трансформации данных.