February 22

Ленивая загрузка в шаблонах Angular

Работая с Angular можно заметить, что шаблоны являются важной частью приложения. Благодаря гибкости шаблона и поддерживаемым им декларативным API, мы можем создавать современные и динамичные веб-приложения. В Angular 17 было введено множество функций, и одной из важных является новый синтаксис шаблона, известный как синтаксис @-notation, что привело к введению нескольких новых API в шаблон.

Эти API дополняют синтаксис HTML шаблона, выделяясь среди них блок с именем "Deferrable Views доступный через блок @defer.

Несмотря на то, что Deferrable Views находятся в состоянии превью, они уже дополняют шаблоны Angular встроенным декларативным механизмом, который позволяет разработчикам, указывать, какие части шаблона должны быть лениво загружены позже, когда это потребуется.

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

Что будет в примере?

Для начала определим пункты, которые мы хотим реализовать:

  1. Запустить отложенную загрузку при появлении в области видимости.
  2. Обрабатывать состояния загрузки и ошибки соответственно.
  3. Решить проблему мерцания контента.
  4. Предварительный запуск отложенной загрузки с использованием другого элемента-триггера.
  5. Отложенная загрузка нескольких частей шаблона (компонентов).

Приступим.

Классический подход

До Angular 17 существовали императивные API, которые позволяли динамически создавать компоненты, директивы, каналы и любые связанные CSS - и были тесно связаны с тем, как Angular внутренне обрабатывает создание компонентов и управляет шаблонами и представлениями. Зависимости, которые планировались для ленивой загрузки, не должны были присутствовать в шаблоне, и мы сильно полагались на использование динамической загрузки JavaScript для асинхронной загрузки их соответствующих модулей во время выполнения.

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

В качестве отправной точки будет компонент UserProfile:

@Component({
  ...
  template: `
    <div class="wrapper">
      <app-details></app-details>
    </div>

    <div class="wrapper wrapper-xl">
      <app-projects></app-projects>
      <app-achievements></app-achievements>
    </div>

    ...
  `,
  imports: [ProjectsComponent, AchievementsComponent]
  ...
})
export class UserProfileComponent {}

Компонент Details показывает довольно длинный текст, тогда как компоненты Projects и Achievements отображают список проектов и достижений. Из-за того, что описание очень длинное, эти два компонента изначально не видны пользователю, они находятся за пределами видимой области экрана.

Все что находится вне зоны видимости идеально подходит для ленивой загрузки. Для начала мы будем лениво загружать только компонент Projects и убедимся, что выполняем основные задачи, которые мы определили ранее. Используя классические API, вот как будет выглядеть начальный вид шаблона:

type DepsLoadingState = 'NOT_STARTED' | 'IN_PROGRESS' | 'COMPLETE' | 'FAILED';

@Component({
  ...
  template: `
    <div class="wrapper">
      <app-details></app-details>
    </div>

    <div class="wrapper wrapper-xl">
      <ng-template #contentSlot /> // 👈 insert lazily loaded content here 

      <ng-container *ngIf="depsState$ | async as state">
        <ng-template *ngIf="state == 'IN_PROGRESS'" [ngTemplateOutlet]="loadingTpl"></ng-template>
        <ng-template *ngIf="state == 'FAILED'" [ngTemplateOutlet]="errorTpl"></ng-template>
      </ng-container>

      <ng-template #loadingTpl>
        <app-projects-skeleton />
      </ng-template>

      <ng-template #errorTpl>
        <p>Oops, something went wrong!</p>
      </ng-template>
    </div>
    ...
  `,
  imports: [] // 👈 no need to import
  ...
})
export class UserProfileComponent {
  depsState$ = new BehaviorSubject<DepsLoadingState>('NOT_STARTED');
}

В этом коде есть практически все, что необходимо за исключением триггера стейта.

Создадим Placeholder

Чтобы иметь начальное содержимое на экране, добавим временный шаблон, который, когда появится во viewport, запустит загрузку нужного контента. Этот временный шаблон известен как шаблон-заполнитель :

...
template: `
  ...
  <ng-container *ngIf="depsState$ | async as state">
        <ng-template *ngIf="state == 'NOT_STARTED'" 
          [ngTemplateOutlet]="placeholderTpl">
        </ng-template>
        ...
  </ng-container>

  <ng-template #placeholderTpl>
    <p>Projects List will be rendered here...</p> // 👈 trigger element
  </ng-template>
  ...
`
...


Placeholder, также известный как элемент-триггер, определен как ng-template, потому что он будет удален после того, как инициирует загрузку зависимостей шаблона.

Когда элемент-триггер на месте, остается только определить триггер, который запускает загрузку, когда Placeholder появляется во зоне просмотра.

Для этой цели используем веб-API IntersectionObserver. Инкапсулируем логику в директиве, которая генерирует событие каждый раз, когда применяется к элементу (элемент-триггер) входит во viewport, а затем прекращает отслеживать/наблюдать за элементом:

@Directive({
    selector: '[inViewport]',
    standalone: true
})
export class InViewportDirective implements AfterViewInit, OnDestroy {
    private elRef = inject(ElementRef);

    @Output()
    inViewport: EventEmitter<void> = new EventEmitter();

    private observer!: IntersectionObserver;

    ngAfterViewInit() {
        this.observer = new IntersectionObserver((entries) => {
            const entry = entries[entries.length - 1];
            if (entry.isIntersecting) {
                this.inViewport.emit();
                this.observer.disconnect();
            }
        });

        this.observer.observe(this.elRef.nativeElement)
    }

    ngOnDestroy(): void {
        this.observer.disconnect();
    }
}

После события управление переходит на уровень компонента:

@Component({
  ...
  template: `
    ...
    <div class="wrapper wrapper-xl">
      ...
      <ng-template #placeholderTpl>
        // 👇 apply directive to the trigger element
        <p (inViewport)="onViewport()">
            Projects List will be rendered here...
        </p> 
      </ng-template>
      ...
    </div>
    ...
  `,
  imports: [InViewportDirective]
  ...
})
export class UserProfileComponent {
  @ViewChild('contentSlot', { read: ViewContainerRef }) 
  contentSlot!: ViewContainerRef;

  depsState$ = new BehaviorSubject<DepsLoadingState>('NOT_STARTED');

  onViewport() {
    this.depsState$.next('IN_PROGRESS');

    const loadingDep = import("./user/projects/projects.component");
    loadingDep.then(
      c => {
        this.contentSlot.createComponent(c.ProjectsComponent);
        this.depsState$.next('COMPLETE');
      },
      err => this.depsState$.next('FAILED')
    )
  }
}

Для асинхронной загрузки компонента используем функцию динамического импорта JavaScript, а затем обновим состояние отслеживания в соответствии с состоянием процесса загрузки.


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

Теперь поработаем со временем выполнения.

function delay(timing: number) {
  return new Promise<void>(res => {
    setTimeout(() => {
      res()
    }, timing);
  })
}

@Component({...})
export class UserProfileComponent {
  @ViewChild('contentSlot', { read: ViewContainerRef }) 
  contentSlot!: ViewContainerRef;

  depsState$ = new BehaviorSubject<DepsLoadingState>('NOT_STARTED');

  onViewport() {
    // time after the loading template will be rendered
    delay(1000).then(() => this.depsState$.next('IN_PROGRESS'));

    const loadingDep = import("./user/projects/projects.component");
    loadingDep.then(
      c => {
        // minimum time to keep the loading template rendered
        delay(3000).then(() => {
          this.contentSlot.createComponent(c.ProjectsComponent);

          this.depsState$.next('COMPLETE')
        });
      },
      err => this.depsState$.next('FAILED')
    )
  }
}


Таким образом, пользователь обязательно увидит наш скелетон.


Все 3 пункта выполнены. Более сложным, но часто встречающимся требованием, является случай, когда мы хотим, чтобы загрузка началась немного раньше - до того, как прокрутка пользователя заставит Placeholder попасть во зону просмотра. Это означает, что триггер отличается от заполнителя, и для этой цели должен использоваться другой элемент выше в шаблоне, как показано ниже:

@Component({
  ...
  template: `
     ...
    // 👇 trigger element somewhere above in the template
    <span (inViewport)="onViewport()"></span>

    <div class="wrapper wrapper-xl">
      <ng-template #contentSlot /> // 

      <ng-container *ngIf="depsState$ | async as state">
        <ng-template *ngIf="state == 'NOT_STARTED'" [ngTemplateOutlet]="placeholderTpl"></ng-template>
        <ng-template *ngIf="state == 'IN_PROGRESS'" [ngTemplateOutlet]="loadingTpl"></ng-template>
        <ng-template *ngIf="state == 'FAILED'" [ngTemplateOutlet]="errorTpl"></ng-template>
      </ng-container>

      <ng-template #placeholderTpl>
        <p>Projects List will be rendered here...</p>
      </ng-template>
      ...
    </div>
    ...
  `,
  imports: [InViewportDirective]
  ...
})
export class UserProfileComponent {
  ...
  onViewport() {
    // same implementation as above
  }
}

Ленивая загрузка нескольких компонентов

Для демонстрации компонент Achievements должен быть готов к загрузке наряду с компонентом Projects.

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

Если же мы решим выбрать второй вариант, то практически никаких изменений не потребуется - вам нужно будет настроить шаблон загрузки и заполнителя, чтобы отражать загрузку обоих компонентов, и настроить логику загрузки в классе компонентов для управления обоими зависимостями. Обратите внимание на использование статического метода Promise.AllSettled для обработки динамической загрузки нескольких зависимостей:

function loadDeps() {
  return Promise.allSettled(
    [
      import("./user/projects/projects.component"),
      import("./user/achievements/achievements.component")
    ]
  );
}

@Component({
  template: `
    <div class="wrapper wrapper-xl">
      <ng-template #contentSlot />

      <ng-template #placeholderTpl>
        <p (inViewport)="onViewport()">
            Projects and Achievements will be rendered here...
        </p>
      </ng-template>

      <ng-template #loadingTpl>
        <h2>Projects</h2>
        <app-projects-skeleton />

        <h2>Achievements</h2>
        <app-achievements-skeleton />
      </ng-template>
      ...
    </div>
  `,
})
export class UserProfileComponent {
  ...
  async onViewport() {
    await delay(1000);
    this.depsState$.next('IN_PROGRESS');

    const [projectsLoadModule, achievementsLoadModule] = await loadDeps();
    if (projectsLoadModule.status == "rejected" || achievementsLoadModule.status == "rejected") {
      this.depsState$.next('FAILED');
      return;
    }

    await delay(3000);

    this.contentSlot.createComponent(projectsLoadModule.value.ProjectsComponent);
    this.contentSlot.createComponent(achievementsLoadModule.value.AchievementsComponent);

    this.depsState$.next('COMPLETE');
  }
}

Довольно много работы, не так ли? Теперь давайте посмотрим на современный подход.

Современный подход к реализации

Теперь давайте посмотрим, как новый API может решить нашу задачу. Для сохранения согласованности мы будем использовать тот же компонент UserProfile, чтобы иметь возможность сравнить его с классическими API.

Как указано во введении статьи, в Angular 17 были введены Отложенные представления (Deferrable Views), новый API, который переносит реализацию с разработчиков на фреймворк.

@Component({
  ...
  imports: [... ProjectsComponent],
  template: `
    <div class="wrapper">
       <app-details />
    </div>

    <div class="wrapper wrapper-xl">
       @defer (on viewport) {
         <app-projects />
       } @placeholder {
         <p>Projects will be rendered here...</p>
       } @loading {
          <app-projects-skeleton />
       } @error {
          <p>Oops, something went wrong!</p>
       }
    </div>
  `
})
export class UserProfileComponent {}

Получился простой и декларативный код. Здесь нет императивной работы по управлению состоянием и асинхронной загрузке, что позволяет создать класс компонента без кода. Содержимое для ленивой загрузки указывается внутри блока @defer, а триггер определяется как параметр после него (при появлении в области видимости).

Но есть еще одна маленькая деталь. Поскольку компонент Project присутствует в шаблоне, его необходимо импортировать в массив imports метаданных компонента только для того, чтобы зависимости шаблона были обнаружены и доступны. Однако компилятор об этом знает, поэтому всё работает так, как должно. Так же вернулась проблема мерцания по тем же причинам, что и раньше. Как и прежде, чтобы исправить это, вам необходимо согласовать момент, когда отображается либо заглушка, либо шаблон загрузки. Для этого блок @loading принимает два дополнительных параметра: minimum и after, как показано ниже:

@Component({
  ...
  template: `
    ...
    <div class="wrapper wrapper-xl">
       @loading (after 1s; minimum 3s) {
          <app-projects-skeleton />
       }
    </div>
    ...
  `
})
export class UserProfileComponent {}

Параметры, передаваемые в блок @loading, контролируют, когда и как долго он должен отображаться на экране. В нашем случае параметры указывают, что блок @loading должен отображаться через одну секунду после начала процесса загрузки и оставаться видимым как минимум три секунды:

Deferrable Views работают только с standalone-компонентами

Триггеры сами способны принимать параметры, как и блоки шаблона. В нашем сценарии viewport-триггер принимает DOM-элемент, который выступает в качестве триггера. Это позволяет начать загрузку зависимостей до того, как пользователь прокрутит страницу до области, где будет отображен загруженный контент:

@Component({
  ...
  imports: [ProjectsComponent],
  template: `
    ...
    <span #triggerEl></span>
    ...

    <div class="wrapper wrapper-xl">
       @defer (on viewport(triggerEl)) {
         <app-projects />
       } 
       ...
    </div>
  `
})
export class UserProfileComponent {}

Загрузка зависимостей начинается когда пользователь прокручивает страницу вниз, и триггер (span) попадает в viewport.

Современный подход к реализации ленивой загрузки 2х компонентов

Остался последний пункт для выполнения: ленивая загрузка более чем одного компонента. Для демонстрации компонент Achievements готов к загрузке наряду с компонентом Projects.

Как и в случае с классическим подходом, есть два пути: загружать их отдельно или вместе. С новым API блока @defer оба эти варианта легко реализовать.

Совместная загрузка

Чтобы загрузить компоненты вместе, компонент Achievements должен быть импортирован imports метаданных компонента, а затем вставлен в блок @defer, как показано ниже:

@Component({
  ...
  imports: [... ProjectsComponent, AchievementsComponent],
  template: `
     ...

    <div class="wrapper wrapper-xl">
       @defer (on viewport) {
         <app-projects />
         <app-achievements />
       } @placeholder () {
         <p>Projects and Achievements will be rendered here...</p>
       } @loading (after 1s; minimum 3s) {
          <h2>Projects</h2>
          <app-projects-skeleton />

          <h2>Achievements</h2>
          <app-achievements-skeleton />
       } @error {
          <p>Oops, something went wrong!</p>
       }
    </div>
  `
})
export class UserProfileComponent {}

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

Загрузка по-отдельности

Чтобы загружать компоненты по отдельности, каждый из них должен быть обернут в свой блок @defer и определить другие связанные блоки:

@Component({
  ...
  imports: [... ProjectsComponent, AchievementsComponent],
  template: `
     ...

    <div class="wrapper wrapper-xl">
       @defer (on viewport) {
          <app-projects  />
       } @placeholder () {
          <p>Projects will be rendered here...</p>
       } @loading (after 1s; minimum 3s) {
          <h2>Projects</h2>
          <app-projects-skeleton />
       } @error {
          <p>Oops, something went wrong!</p>
       }

       @defer (on viewport) {
          <app-achievements />
       } @placeholder () {
          <p>Achievements will be rendered here...</p>
       } @loading (after 1s; minimum 3s) {
          <h2>Achievements</h2>
          <app-achievements-skeleton />
       } @error {
          <p>Oops, something went wrong!</p>
       }
    </div>
  `
})
export class UserProfileComponent {}

По сравнению с классическим подходом требуется гораздо меньше кода. С минимальными условиями можно добавить столько блоков @defer.

Заключение

Ленивая загрузка — это метод оптимизации производительности, применяемый в Angular. Angular 17 предоставляет современный, декларативный, шаблонно-ориентированный API через синтаксис шаблонных блоков @block, который представляет отложенные представления. Этот API можно использовать для отложенной загрузки частей шаблона, которые могут быть загружены только в будущем. Вам просто нужно определить, какой из этих API лучше всего подходит для вашего конкретного случая использования.

Исходный код доступен по ссылке

Это перевод оригинального материала