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

Комментарии (2)


  1. radtie
    27.06.2025 09:43

    Честно говоря, на момент публикации советы не то чтобы актуальные, и отражают не самый лучший стиль разработки современных angular-приложений.

    1. Классы-резолверы считается устаревшим и данные можно вязать сразу на инпуты, минуя работу с маршрутами в компоненте:

    {
      path: 'home',
      loadComponent: () => import('./home/home.component').then((m) => m.HomeComponent),
      resolve: {
        user: () => inject(ApiService).getUser(),
        posts: () => inject(ApiService).getPosts()
      }
    }
    export class HomeComponent {
      @Input() user: User;
    
      posts = input<Post[]>();
    }

    2 Вынося за скобки саму идею использования глобального лоадера, в его реализации есть несколько спорных моментов:

    • логика лоадера захламряет AppComponent

    • отписка и "утечка памяти"

      • AppComponent существует весь жизненный цикл типичного приложения и его ngOnDestroy ни разу не сработает

      • Даже если перестраховаться, нам не нужно подписываться/отписываться, есть же takeUntilDestroyed

      • Сама концепция subscribe -> update property -> unsubscribe в данном случае не самая изящная

    function setupIsLoading() {
      return inject(Router).events.pipe(
        filter((e) => e instanceof ResolveStart || e instanceof ResolveEnd),
        map((e) => e instanceof ResolveStart)
      );
    }
    
    @Component({
      selector: 'app-root',
      template: `
        @if (isLoading$ | async) {
          Загрузка
        }
        <!-- или даже как сигнал -->
        @if (isLoading()) {
          Загрузка
        }
        <router-outlet></router-outlet>
      `,
    })
    export class AppComponent {
      protected readonly isLoading$ = setupIsLoading();
    
      protected readonly isLoading = toSignal(this.isLoading$);
    }


  1. AlekseyVY Автор
    27.06.2025 09:43

    @radtie, спасибо за подробный и полезный комментарий! Очень признателен за конкретный отзыв. 

    Вы совершенно правы, это важные замечания. Функциональные резолверы (в v14) и прямой байдинг на инпуты (из v16) заметно упрощают код в Angular. 

    В статье я намеренно использовал более старый подход с классами, чтобы понятнее показать, как работает резолвер для начинающих. Но для современных проектов ваш пример - лучшее решение. 

    Полностью согласен с вами и по поводу лоадера. Ваш способ с отдельной функцией и toSignal чище, реактивнее и лучше. Спасибо за замечание про ngOnDestroy в AppComponent - об этом часто забывают. Еще раз спасибо! Ваш комментарий отлично дополняет статью.