Отложенная загрузка на уровне шаблонов в 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. Такое возможно при быстрой загрузке зависимостей, что сопровождается неким миганием.
Это ставит перед нами 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')
)
}
}
В результате пользователь получит улучшение визуальной индикации происходящего и более комфортный опыт работы.
Итак, 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 можно увидеть сообщение о загрузке в области видимости.
Отложенная загрузка нескольких компонентов
Написанный выше код императивен и прост. Нам осталось справиться с последней задачей: выполнить отложенную загрузку нескольких компонентов — в данном случае компонентов 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');
}
}
Как видите, для вставки компонентов в представление использован тот же контейнер/слот шаблона, поэтому в результате получаем следующее:
Обработка ошибок зависимостей определяется конкретным проектом — можете реализовать ее так, как считаете нужным.
Это все, что касается традиционного подхода. Объем работы впечатляет, не так ли? Исследуем теперь современный подход.
Отложенная загрузка на уровне шаблонов: современный подход
Посмотрим, как отложенная загрузка реализуется при использовании современного 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 находится в шаблоне во время написания кода, он должен быть импортирован в массив 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 работает только с автономными зависимостями.
Сами триггеры могут принимать параметры, как и блоки шаблонов. В данном сценарии триггер области видимости принимает опциональный параметр — элемент 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 можно увидеть шаблон загрузки в представлении.
Отложенная загрузка нескольких компонентов
Осталось выполнить последнюю задачу: отложенную загрузку более чем одного компонента. В целях демонстрации загрузим компоненты 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
, сколько нужно, не прилагая особых усилий, и при этом быть уверенным, что все работает идеально:
Вот и все, что касается современного подхода. И это далеко не все возможности, которые предлагает Deferrable Views. Для более полного руководства по Deferrable Views ознакомьтесь с официальной документацией Angular.
Заключение
Отложенная загрузка — это механизм оптимизации производительности, используемый в таких веб-фреймворках, как Angular. Отложенная загрузка на уровне маршрутов пользуется популярностью в сообществе Angular, чего нельзя сказать об отложенной загрузке на уровне шаблонов, хотя время доказало, что существующие API работают довольно исправно. Однако объем работы, задействование не только класса компонента, но и шаблона, масса императивного кода — все это не позволяет считать их удобными для разработчиков.
Deferrable Views, используемый в Angular 17 благодаря синтаксису шаблонов @block, представляет собой усовершенствованный, декларативный, шаблонно-ориентированный API. Он используется для отсрочки загрузки частей шаблона, которые, возможно, никогда не будут загружены. Все, что вам нужно сделать, — это определить, какая настройка такого API наилучшим образом соответствует вашему варианту использования.
Можете найти и поэкспериментировать с полным кодом здесь.