Ленивая подгрузка библиотек из CDN в Angular

Kate

Administrator
Команда форума
Когда я интегрировал свое Angular-караоке с YouTube, мне попался официальный YouTube-компонент из Angular Material. В README прилагалась инструкция для подключения:

let apiLoaded = false;

@Component({
template: '<youtube-player videoId="PRQCAL_RMVo"></youtube-player>',
selector: 'youtube-player-example',
})
class YoutubePlayerExample implements OnInit {
ngOnInit() {
if (!apiLoaded) {
const tag = document.createElement('script');
tag.src = 'https://www.youtube.com/iframe_api';
document.body.appendChild(tag);
apiLoaded = true;
}
}
}

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

e46fda33b298aa2750ca9d5a017c09a3.png

Dependency Injection снова выручает​

Есть неплохой трюк для ленивой загрузки блока кода через токен, про который писал Reactive Fox:

У этого подхода есть один недостаток — он работает только с локальными файлами. Так не получится загрузить библиотеку с CDN. Но мы всё равно можем применить токен для похожего эффекта.

DI-токен отлично подходит для того, что нужно загрузить один раз во время первого запроса. Это хорошая альтернатива грязному подходу с глобальной переменной из примера выше. С помощью токена DOCUMENT мы также можем абстрагироваться от прямой манипуляции DOM, и наш код не упадет при серверном рендеринге (об этом я уже писал). Но сначала давайте добавим вспомогательный токен на CDN URL:

export const API_URL = new InjectionToken<string>('CDN URL of Youtube API', {
factory: () => 'https://www.youtube.com/iframe_api'
});
Библиотеки часто регистрируют себя на глобальном объекте window. Вот так будет выглядеть типовой токен на библиотеку:

export const API$ = new InjectionToken<Observable<Interface>>(
'A stream with third party library object',
{
factory: () => {
const documentRef = inject(DOCUMENT);
const script = documentRef.createElement('script');

script.src = inject(API_URL);
documentRef.body.appendChild(script);

return fromEvent(script, 'load').pipe(
map(() => documentRef.defaultView.libraryObject),
);
}
}
);
В случае YouTube API недостаточно просто дождаться загрузки скрипта. В документации написано, что нужно добавить колбэк onYouTubeIframeAPIReady на window и скрипт вызовет его, когда будет готов. Стоит заметить, что это произойдет в обход zone.js. То есть, даже если мы правильно отреагируем на вызов, Angular его не заметит и не запустит проверку изменений. Поэтому нужно обернуть вызов в NgZone.run:

export const API$ = new InjectionToken<Observable<YT>>(
'A stream with YT object',
{
factory: () => {
const documentRef = inject(DOCUMENT);
const zone = inject(NgZone);
const windowRef = documentRef.defaultView;
const script = documentRef.createElement('script');
const loaded$ = new ReplaySubject(1);

script.src = inject(API_URL);
documentRef.body.appendChild(script);

windowRef.onYouTubeIframeAPIReady = () => {
zone.run(() => loaded$.next(windowRef.YT));
};

return loaded$;
}
}
);
Это токен — Observable. В большинстве случаев удобнее работать с непосредственным значением. Поэтому давайте добавим последний токен на сам объект YT:

export const API = new InjectionToken<YT>('Youtube API object');

Работаем с написанным​

Первый способ превратить Observable в значение и работать с ним дальше — Resolver. Возьмем lazy-роут, который использует YouTube, и добавим специальный ресолвер, который возьмет наш токен и дождется загрузки. Так объект YT станет доступным в data нашего роута. Затем можно положить его в токен. Что-то подобное Hien Pham описывал в своей статье про уменьшение дублирования через DI.

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

export class WithYTDirective implements OnChanges {
@Input()
withYT: YT | null = null;

constructor(
private readonly templateRef: TemplateRef<{}>,
private readonly vcr: ViewContainerRef
) {}

ngOnChanges() {
if (this.withYT) {
this.vcr.createEmbeddedView(this.templateRef);
}
}
}
Поскольку шаблон создается только когда в withYT уже будет объект YT, мы можем добавить следующий провайдер:

export function extractAPI({ withYT }: WithYTDirective): YT {
return withYT;
}

@Directive({
selector: '[withYT]',
providers: [
{
provide: API,
deps: [WithYTDirective],
useFactory: extractAPI
}
]
})
export class WithYTDirective implements OnChanges {
// ...
}
Напомню, фабрика провайдера вызовется, только когда его запросят.
Теперь мы можем создать компонент YouTube, который просто берет API из DI. Описывать подробно такой компонент я не буду. Предположим, что он у нас есть. Вот так мы могли бы использовать его с нашей директивой:

@Component(
selector: 'my-component',
template: `<youtube-player *withYT="api$ | async"></youtube-player>`
)
export class MyComponent {
constructor(@Inject(API$) readonly api$: Observable<YT>) {}
}
Итоговый вариант можно посмотреть на StackBlitz.

Вместо заключения​

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

Уделите побольше времени, чтобы ближе познакомиться с Dependency Injection, и он станет вашим верным союзником в написании надежного гибкого кода.

 
Сверху