Наводим порядок в загрузке данных 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['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. Кроме того, мы получаем единую точку для загрузки, кэширования, валидации и трансформации данных.


ссылка на оригинал статьи https://habr.com/ru/articles/922376/