Наводим порядок в загрузке данных Angular с помощью резолверов

Всем привет! Сегодня хочу разобрать кейс, с которым сталкивается почти каждый 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['pageData'] 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. Кроме того, мы получаем единую точку для загрузки, кэширования, валидации и трансформации данных.

2 комментария