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

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

let apiLoaded = false;

@Component({
  template: '',
  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 мы можем лучше! Давайте разберемся, как можно применить имеющиеся инструменты для ленивой подгрузки библиотеки.

image-loader.svg

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

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

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

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

export const API_URL = new InjectionToken('CDN URL of Youtube API', {
  factory: () => 'https://www.youtube.com/iframe_api'
});

Библиотеки часто регистрируют себя на глобальном объекте window. Вот так будет выглядеть типовой токен на библиотеку:

export const API$ = new InjectionToken>(
  '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>(
  '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('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: ``
)
export class MyComponent {
  constructor(@Inject(API$) readonly api$: Observable) {}
}

Итоговый вариант можно посмотреть на StackBlitz.

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

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

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

© Habrahabr.ru