company_banner

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

  • Tutorial

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

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


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

@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 кб, так что можете смело добавлять в свой проект.

TINKOFF
IT’s Tinkoff — просто о сложном

Comments 5

    +2

    Из мухи сделали слона). Ваш код порождает ограничения и излишние сложности

      0
      У себя на проекте пришел к другому варианту.
      Скелетон/лоадер нужно показывать в основном пока работает поток, поэтому были написаны специальные пайп, сервис и директива для отслеживания всего этого дела. Рабтоает это примерно так:
      Выводим лоадер на кнопку после отправки данных на бек:
      <button loadWhen="user.creating" (click)="createUser()">Create User</button>
      

      createUser():void {
        this.usersService.create().pipe(tapLoadWhen('user.creating')).subscribe()
      }
      


      Такое же можно сделать и для скелетона, суть в том, что-бы строка в директиве loadWhen совпадала со строкой в пайпе taploadWhen.

      Если интересно что там под капотом — могу как-то написать статью, так как подобного подхода не видел в других местах и считаю это очень удобным.
        0
        Напишите или ссылочкой на реалзацию поделитесь в stackblitz
        +1
        Интересный вариант, но мне тоже показался слишком сложным для такой простой вещи.
        Мы, например, используем структурную директиву, работающую по-принципу ngIf, только вместо удаления элемента рисуем скелетон.

        <span *skeletonFor="username$ | async as username">{{ username }}</span>
          0

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

          Only users with full accounts can post comments. Log in, please.