Pull to refresh
347.09
TINKOFF
IT’s Tinkoff — просто о сложном

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

Reading time4 min
Views4.8K

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

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, и он станет вашим верным союзником в написании надежного гибкого кода.

Tags:
Hubs:
+26
Comments3

Articles

Information

Website
www.tinkoff.ru
Registered
Founded
Employees
over 10,000 employees
Location
Россия