Лучший опыт

Отложенная загрузка на уровне шаблонов в Angular.

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

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

Новые API значительно расширяют возможности HTML-синтаксиса шаблонов. Один из этих API, названный Deferrable Views (откладываемые представления), доступный через блок @defer и пока находящийся в тестовой версии (в Developer Preview), заслуживает особого внимания.

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

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

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


Технические требования

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

  • Запуск отложенной загрузки при появлении контента в области просмотра.
  • Обработка состояния загрузки и ошибок соответствующим образом.
  • Решение проблемы мигания изображения.

Кроме этих базовых задач, выполним более сложные требования, а именно:

  • Раннее срабатывание отложенной загрузки с помощью другого триггерного элемента.
  • Отложенная загрузка нескольких частей шаблона (читай: компонентов).

Отложенная загрузка на уровне шаблонов: традиционный подход

До выхода 17-й версии в Angular использовались императивные 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 отображают список проектов и достижений. Поскольку описание компонента Details слишком длинное, компоненты Projects и Achievements изначально не видны пользователю  —  они находятся вне области просмотра.

Первоначальный вид страницы профиля пользователя

Такой контент, известный как “нижележащий” (below-the-fold),  —  идеальный кандидат для отложенной загрузки при работе по оптимизации первоначальной загрузки страницы и комплекта приложений.

Для начала реализуем отложенную загрузку только компонента 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 /> // ???? сюда помещается контент для ленивой загрузки

<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: [] // ???? не нужно импортировать
...
})
export class UserProfileComponent {
depsState$ = new BehaviorSubject<DepsLoadingState>('NOT_STARTED');
}

Как видите, компоненты удаляются из шаблона и не импортируются в массив imports метаданных компонента или NgModule. Определяем контейнер/слот (#contentSlot) в шаблоне (с помощью ng-template), куда будет вставляться контент в процессе отложенной загрузки.

Помимо этого, определяем (опять же, с помощью ng-template) шаблоны загрузки (loading) и ошибок (error), чтобы зеркально отразить состояние загружаемых зависимостей (отслеживается depsState$) в шаблоне.

Однако изначально ничего не происходит: код не запускает процесс загрузки.

Чтобы выполнить первую задачу  —  запустить загрузку в области просмотра,  —  нужно определить триггер. Он представляет собой не что иное, как действие, которое должно произойти, чтобы началась загрузка зависимостей шаблона. В данном случае триггерное действие  —  показ контента при его появлении в области просмотра. Но в исходном шаблоне контента нет. Поэтому нужно определить, что будет вызывать это действие.

Использование шаблона-заполнителя

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

...
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> // ???? элемент-триггер
</ng-template>
...
`
...

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

Теперь, когда элемент-триггер определен, осталось определить сам триггер, который запустит загрузку при вхождении элемента-триггера (заполнителя) в область просмотра.

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

@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();
}
}

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

@Component({
...
template: `
...
<div class="wrapper wrapper-xl">
...
<ng-template #placeholderTpl>
// ???? применение директивы к элементу-триггеру
<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, а затем обновим состояние отслеживания в соответствии с состоянием процесса загрузки для корректного отражения состояния в шаблоне. Поскольку логика загрузки находится в классе компонента, необходимо запросить контейнер/слот в шаблоне и затем использовать его для инстанцирования и вставки хоста загружаемого компонента в контейнер после загрузки, как показано ниже:

Отложенная загрузка компонента Projects с проблемой мигания изображения

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

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

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() {
// время после отрисовки шаблона загрузки
delay(1000).then(() => this.depsState$.next('IN_PROGRESS'));

const loadingDep = import("./user/projects/projects.component");
loadingDep.then(
c => {
// минимальное время сохранения отрисовки шаблона загрузки
delay(3000).then(() => {
this.contentSlot.createComponent(c.ProjectsComponent);

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

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

Отложенная загрузка компонента Projects с устраненной проблемой мигания

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

@Component({
...
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() {
// та же реализация, что и выше
}
}

Теперь, пока пользователь прокручивает страницу вниз и элемент-триггер (span) попадает в область просмотра, начинается загрузка зависимостей:

Ранний запуск компонента Projects с отложенной загрузкой

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

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

Написанный выше код императивен и прост. Нам осталось справиться с последней задачей: выполнить отложенную загрузку нескольких компонентов  —  в данном случае компонентов Achievements и Projects.

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

А вот второй способ практически никаких изменений не потребует  —  придется только настроить шаблон загрузки и шаблон-заполнитель, чтобы отразить загрузку обоих компонентов, и настроить логику загрузки в классе компонентов, чтобы управлять обеими зависимостями. Обратите внимание на использование статического метода 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');
}
}

Как видите, для вставки компонентов в представление использован тот же контейнер/слот шаблона, поэтому в результате получаем следующее:

Отложенная загрузка компонентов Projects и Achievements

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

Это все, что касается традиционного подхода. Объем работы впечатляет, не так ли? Исследуем теперь современный подход.

Отложенная загрузка на уровне шаблонов: современный подход

Посмотрим, как отложенная загрузка реализуется при использовании современного API. Для адекватности сравнения будем использовать тот же компонент UserProfile. Как ранее отмечалось, в Angular 17 появился усовершенствованный API, 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, а триггер определяется после как параметр в области просмотра (on viewport). Кроме того, декларативно определяются шаблоны заполнителя (placeholder), ошибок (error) и загрузки (loading), используя соответствующие именованию шаблона @блоки (без ng-templates), чтобы правильно отразить состояние процесса в шаблоне. При этом шаблон-заполнитель является элементом-триггером:

Отложенная загрузка компонента Projects при появлении контента в области просмотра

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

Отложенная загрузка компонента Projects без мигания изображения

Deferrable Views работает только с автономными зависимостями.

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

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

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

Загрузка зависимостей начинается, когда пользователь прокручивает страницу вниз и элемент-триггер (span) попадает в область просмотра, как показано ниже:

Раннее срабатывание отложенной загрузки Projects

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

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

Осталось выполнить последнюю задачу: отложенную загрузку более чем одного компонента. В целях демонстрации загрузим компоненты 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 {}

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

Совместная отложенная загрузка компонентов Projects и Achievements

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

Чтобы загрузить компоненты по отдельности, каждый из них нужно обернуть в свой блок @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, сколько нужно, не прилагая особых усилий, и при этом быть уверенным, что все работает идеально:

Отложенная загрузка компонентов Projects и Achievements по отдельности

Вот и все, что касается современного подхода. И это далеко не все возможности, которые предлагает Deferrable Views. Для более полного руководства по Deferrable Views ознакомьтесь с официальной документацией Angular.

Заключение

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

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

Можете найти и поэкспериментировать с полным кодом здесь.