Angular: Показываем скелетон страницы за три шага

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Привет, меня зовут Олег. Я работаю в команде Тинькофф Бизнеса. В моей работе часто происходит ситуация, когда странице нужны данные с сервера. Пока мы запрашиваем эти данные, на странице надо что-нибудь показать. 

Но тут возникает проблема: в таком случае нам придется свой чистенький и красивый компонент превращать в страшненького квазисолидного уродца с примешанной логикой для индикации загрузки.Если вы сталкивались с подобными ситуациями, эта статья поможет вам все исправить за три простых шага.


Типичный случай выглядит примерно так:

@Component({
   template: `
       <ng-container *ngIf="!loading; else skeleton">
           <h1>Hello, {{ user.name }}</h1>
       </ng-container>
       <ng-template #skeleton>
           <h1>Loading, please wait...</h1>
       </ng-template>
   `,
})
export class UserComponent implements OnDestroy {
   subscription = Subscription.EMPTY;
   loading = true;
   user: User | null = null;

   constructor(usersService: UsersService) {
       this.subscription = usersService.getCurrentUser().subscribe(user => {
           this.user = user;
           this.loading = false;
       });
   }

   ngOnDestroy() {
       this.subscription.unsubscribe();
   }
}

Что тут у нас? 

  • Лишние свойства у класса — для отслеживания статуса загрузки.

  • Подписочки-отписочки, от которых хотелось бы отказаться.

  • Чрезмерно усложнившийся шаблон с дополнительным локальным шаблоном и условием для индикации статуса загрузки. 

Вдобавок этот обслуживающий код будет копироваться от компонента к компоненту. Уф-ф-ф, этот код явно с проблемами! Мы-то хотели просто улучшить видимую производительность, показав скелетон страницы пользователю, пока загружаются данные…

Может, получится отделить логику показа скелетона от самой страницы, поместить ее в отдельный компонент и переиспользовать? Давайте разберемся, как это можно исправить, сделав один дополнительный компонент, который будет показывать скелетон будущей страницы!

Шаг первый: определяемся, когда мы хотим показывать скелетон

Кроме проблем, описанных ранее, есть еще одна: если оставить все как есть, мы не сможем использовать гарды и резолверы, поскольку они отрабатывают до того, как компонент создается. Это значит, что мы не сможем показать скелетон, находящийся внутри страницы. К счастью, фреймворк предоставляет нам события о том, на каком этапе навигации мы сейчас находимся, и мы можем использовать это!

После некоторых размышлений я, кажется, пришел к единственному правильному выводу: показываем скелетон страницы с момента начала проверки на доступ к странице до момента завершения текущей навигации или ее отмены или ошибки в процессе навигации. 

Для воплощения этого замысла нам поможет документация, где описан порядок, в котором происходят события роутера. Запишем в конструкторе компонента:

const start = router.events.pipe(
    filter(event => event instanceof GuardsCheckStart),
);
const end = router.events.pipe(
    filter(
        event =>
            event instanceof NavigationEnd ||
            event instanceof NavigationCancel ||
            event instanceof NavigationError,
    ),
);

Шаг второй: получаем скелетон, соответствующий странице

Часто случается так, что у нескольких страниц одинаковое строение и, соответственно, одинаковые скелетоны. Поэтому наиболее удачным решением будет выделить скелетон страницы в отдельный компонент и переиспользовать его по необходимости. А о том, как его передавать и получать, я сейчас расскажу!

Как же нам получить скелетон, соответствующий странице, на которую происходит навигация? Очень просто. Мы положим скелетон рядом с самой страницей в конфигурации роута, вот так:

const route: Route = {
    path: '...',
    component: MyComponent,
    data: {
        skeleton: MySkeletonComponent,
    },
};

Положили, но как его достать при навигации? С этим нам опять поможет событие роутера, которое содержит информацию о странице, на которую переходит пользователь.

На первый взгляд кажется, что снимок будущего состояния роутера не содержит информации об активированной странице. Но, присмотревшись получше, можно получить нужные данные. Свойство .firstChild указывает не на первый роут в массиве роутов, а на первый активированный из них, то есть тот, на который мы переходим. Запишем в конструкторе компонента:

const skeleton = router.events.pipe(
    filter(event => event instanceof RoutesRecognized),
    map((event: RoutesRecognized) => {
        let route = event.state.root;

        while (route.firstChild) {
            route = route.firstChild;
        }

        const component = route?.routeConfig?.data?.skeleton;

        return component ? {component} : null;
    }),
);

Шаг третий: катим в прод

Осталось только сложить первые два шага — и практически все готово. Вот код, который получился у меня, допишем его в конструктор компонента:

this.skeleton = skeleton.pipe(
    switchMap(skeleton =>
        skeleton
            ? concat(
                  start.pipe(
                      mapTo(skeleton),
                      takeUntil(end),
                  ),
                  of(null),
              )
            : of(null),
    ),
);

И добавим вот такой код в шаблон компонента:

<ng-container *ngIf="skeleton | async as config else content">
    <ng-container
        *ngComponentOutlet="config.component"
    ></ng-container>
</ng-container>

<ng-template #content>
    <ng-content></ng-content>
</ng-template>

Круто, правда?

Шаг назад: шаг, который существует вопреки

Было бы еще круче, если бы все сразу работало так, как хочется. Я столкнулся с тем, что некоторые наши роуты находятся в отдельных, ленивых модулях, которые загружаются по необходимости. Поэтому и скелетоны, связанные с этими роутами декларируются в этих модулях (инжекторах ленивых модулей), а это значит, что эти компоненты невозможно создать в корневом модуле (корневом инжекторе). Если декларировать их в корневом модуле, это будет увеличивать размер основного бандла. Это не есть хорошо.

Чтобы решить эту проблему, нам надо добраться до инжектора ленивого модуля, для этого придется залезть под капот фреймворка, посмотреть, как создаются ленивые модули и куда записывается информация об этом. К счастью, это место не менялось с 2017 года. Мы можем воспользоваться этой информаций и слегка изменить код под эти требования:

function getRouteInjector(route: Route | null): Injector | null {
    return (route as InternalRoute)?._loadedConfig?.module?.injector || null
}
 
const skeleton = router.events.pipe(
    filter(event => event instanceof RoutesRecognized),
    map((event: RoutesRecognized) => {
        let route = event.state.root;
        let injector = getRouteInjector(route.routeConfig);

        while (route.firstChild) {
            route = route.firstChild;
            injector = getRouteInjector(route.routeConfig) || injector;
        }

        const component = route?.routeConfig?.data?.skeleton;

        return component ? {component, injector} : null;
    }),
);

И подправим шаблон:

<ng-container *ngIf="skeleton | async as config else content">
    <ng-container
        *ngComponentOutlet="config.component; injector: config.injector"
    ></ng-container>
</ng-container>

<ng-template #content>
    <ng-content></ng-content>
</ng-template>

Я понимаю, что это решение выглядит не очень, но другого выхода я не нашел. Если есть идеи, как это сделать не залезая под капот фреймворка, не стесняйтесь — пишите комментарий!

Вывод

Теперь у нас есть чистая страница пользователя, которая содержит код только для отображения страницы пользователя, в ней нет обслуживающего кода для индикации загрузки пользователя с сервера и у которой единственная ответственность это отображать страницу пользователя. И есть отдельный, чистый скелетон этой страницы с минимальным количеством зависимостей, единственная ответственность которого — отображаться, пока загружаются данные, нужные для страницы.

Весь представленный код я собрал и выложил в небольшую библиотечку: вот тут ее можно поддержать на GitHub, а вот тут скачать из npm. Она весит ≈1,5 кб, так что можете смело добавлять в свой проект.

Источник: https://habr.com/ru/company/tinkoff/blog/544948/


Интересные статьи

Интересные статьи

Расскажу на примере Timepad (сервиса для организаторов ивентов), который зарабатывает комиссии с продаж билетов и апсейле. Вместо некоторых цифр, которые нельзя найти с помощью внешних источников...
Но если для интернет-магазина, разработанного 3–4 года назад «современные» ошибки вполне простительны потому что перед разработчиками «в те далекие времена» не стояло таких задач, то в магазинах, сдел...
Однажды, в понедельник, мне пришла в голову мысль — "а покопаюсь ка я в новом ядре" (новым относительно, но об этом позже). Мысль не появилась на ровном месте, а предпосылками для нее стали: ...
Как быстро определить, что на отдельно взятый сайт забили, и им никто не занимается? Если в подвале главной страницы в копирайте стоит не текущий год, а старый, то именно в этом году опека над са...
Сегодня мы поговорим о перспективах становления Битрикс-разработчика и об этапах этого пути. Статья не претендует на абсолютную истину, но даёт жизненные ориентиры.