DevGang
Авторизоваться

Отрисовка динамических компонентов по имени селектора в Ivy 

Angular дает нам механизм для динамического рендеринга компонентов через контейнер представления с помощью ComponentFactory. Для этого нам нужно знать тип компонента во время компиляции.

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

Когда нам это нужно

Обычно эта возможность полезна, когда мы определяем, какие компоненты отображать во время выполнения, на основе некоторых событий или действий пользователя. Может быть несколько вариантов использования:

  1. Компоненты, которые необходимо отобразить, известны через некоторый внешний источник, такой как метаданные JSON или ответ API.
  2. Микро-интерфейсы, основанные на iframe и взаимодействующие через межфреймовую связь (формат JSON), которая должна запускать рендеринг компонентов.
  3. Любые другие случаи, когда компонент, который будет отображаться, не известны заранее.

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

Чтобы иметь возможность отображать компоненты путем указания селектора компонентов, нам нужен способ получения доступа к ComponentFactory этих компонентов по запросу.

Получение фабрики компонентов с помощью селектора компонентов

До Ivy (версия 8) Angular имел механизм определения entryComponents в модулях. Добавление компонента в массив entryComponents сделает фабрики для этих динамических компонентов доступными во время выполнения. Это было необходимо для того, чтобы убедиться, что TreeShaking не удалит эти компоненты из конечного production пакета модуля.

Angular не имеет документированного способа получения доступа к фабрикам, но многие люди использовали недокументированный API, подобный этому, чтобы заставить его работать:

const module: NgModuleFactory<unknown> = loadMyModule();
const factoryResolver = module.componentFactoryResolver;

factoryResolver['_factories'].forEach(componentFactory => {
	// componentFactory.selector
});

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

Динамические компоненты по селектору с Ivy

Предлагаемый подход работает, предоставляя интерфейс, аналогичный entryComponents настраиваемому полю на уровне модуля:

@NgModule({
  imports: [CommonModule],
  declarations: [Dynamic1Component]
})
export class Child1Module extends BaseModule {
  dynamicComponents = [Dynamic1Component];

  constructor(componentFactoryResolver: ComponentFactoryResolver) {
    super(componentFactoryResolver);
  }
}

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

  1. Создайте базовый класс BaseModule, от которого должен расширяться каждый модуль, предоставляющий динамические компоненты.
  2. Добавьте все компоненты, которые должны быть доступны через селекторы компонентов, в виде массива в поле dynamicComponents
  3. Предоставить ComponentFactoryResolver базовому классу

Поскольку у нас есть ссылки на компоненты через dynamicComponents, они становятся частью создаваемых блоков и не удаляются как часть TreeShaking.

У нас также есть безопасность типов, чтобы убедиться, что если модуль расширяет BaseModule, dynamicComponents и ComponentFactoryResolver также определены.

Что делает BaseModule?

Поскольку нам нужен ComponentFactory для рендеринга компонента во время выполнения, мы создаем карту селекторов компонентов и их соответствующих экземпляров ComponentFactory для всех компонентов, указанных в поле dynamicComponents. Это делается на уровне модуля с помощью наследования:

export abstract class BaseModule {

  private selectorToFactoryMap: { [key: string]: ComponentFactory<any> } = null;
  
  protected abstract dynamicComponents: Type<any>[]; // similar to entryComponents

  constructor(protected componentFactoryResolver: ComponentFactoryResolver) { }

  public getComponentFactory(selector: string): ComponentFactory<any> {
    if (!this.selectorToFactoryMap) {
      // lazy initialisation
      this.populateRegistry();
    }
    return this.selectorToFactoryMap[selector];
  }

  private populateRegistry() {
    this.selectorToFactoryMap = {};
    if (
      Array.isArray(this.dynamicComponents) &&
      this.dynamicComponents.length > 0
    ) {
      this.dynamicComponents.forEach(compType => {
        const componentFactory: ComponentFactory<
          any
        > = this.componentFactoryResolver.resolveComponentFactory(compType);
        this.selectorToFactoryMap[componentFactory.selector] = componentFactory;
      });
    }
  }
}

Здесь у нас есть общедоступная функция getComponentFactory, которая принимает селектор компонентов и возвращает ComponentFactory для этого компонента.

У нас есть хэш-карта selectorToFactoryMap, которая содержит отображение селектора уровня модуля на сопоставление фабрики. Мы обеспечиваем ленивую инициализацию этой карты при первом запросе динамического компонента в этом модуле.

Использование

Мы создадим вспомогательный сервис, который будем внедрять и использовать там, где нам нужно получить компоненты для динамического рендеринга:

export class DynamicComponentService {
  constructor(private injector: Injector) {}

  getComponentBySelector(
    componentSelector: string,
    moduleLoaderFunction: () => Promise<any>
  ): Promise<ComponentRef<unknown>> {
    return this.getModuleFactory(moduleLoaderFunction).then(moduleFactory => {
      const module = moduleFactory.create(this.injector);
      if (module.instance instanceof BaseModule) {
        const compFactory: ComponentFactory<
          any
        > = module.instance.getComponentFactory(componentSelector);
        return compFactory.create(module.injector, [], null, module);
      } else {
        throw new Error('Module should extend BaseModule to use "string" based component selector');
      }
    });
  }

  async getModuleFactory(
    moduleLoaderFunction: () => Promise<NgModuleFactory<any>>
  ) {
    const ngModuleOrNgModuleFactory = await moduleLoaderFunction();
    let moduleFactory;
    if (ngModuleOrNgModuleFactory instanceof NgModuleFactory) {
      // AOT
      moduleFactory = ngModuleOrNgModuleFactory;
    } else {
      // JIT
      moduleFactory = await this.injector
        .get(Compiler)
        .compileModuleAsync(ngModuleOrNgModuleFactory);
    }
    return moduleFactory;
  }
}

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

  1. Загружает модуль и проверяет, расширяется он BaseModule или нет
  2. Создает экземпляр модуля и вызывает метод getComponentFactory, предоставляемый BaseModule, для получения фабрики компонентов. Теперь baseModule инициализирует карту и заполняет все селекторы и сопоставление ComponentFactory для модуля.

Мы будем использовать вспомогательный сервис следующим образом:

  1. У нас есть контейнер-заполнитель div в нашем шаблоне, где нам нужен динамический компонент. Получаем его через ViewContainerRef.
  2. Мы используем DynamicComponentService для компонента ComponentRef с селектором 'app-dynamic1', который является частью файла child1.module.
  3. Вставляем ComponentRef внутрь нашего контейнера div

Вот код, демонстрирующий подход:

@ViewChild("container", { read: ViewContainerRef, static: true })
  container: ViewContainerRef;

  constructor(private componentService: DynamicComponentService) {}

  addDynamicComponent() {
    this.componentService
      .getComponentBySelector("app-dynamic1", () =>
        import("./child1/child1.module").then(m => m.Child1Module)
      )
      .then(componentRef => {
        this.container.insert(componentRef.hostView);
      });
  }

Входные параметры компонента

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

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

export interface DynamicComponentInputs { [k: string]: any; };

@Component({
  selector: 'app-dynamic-selector',
  template: `
  <ng-container #componentContainer></ng-container>
  `
})
export class DynamicSelectorComponent implements OnDestroy, OnChanges {
  @ViewChild('componentContainer', { read: ViewContainerRef, static: true })
  container: ViewContainerRef;

  @Input() componentSelector: string;
  @Input() moduleLoaderFunction;
  @Input() inputs: DynamicComponentInputs;

  public component: ComponentRef<any>;

  constructor(private componentService: DynamicComponentService) { }

  async ngOnChanges(changes: SimpleChanges) {
    if (changes.componentSelector) {
      await this.renderComponentInstance();
      this.setComponentInputs();
    } else if (changes.inputs) {
      this.setComponentInputs();
    }
  }

  ngOnDestroy() {
    this.destroyComponentInstance();
  }

  private async renderComponentInstance() {
    this.destroyComponentInstance();

    this.component = await this.componentService.getComponentBySelector(this.componentSelector, this.moduleLoaderFunction);
    this.container.insert(this.component.hostView);
  }

  private setComponentInputs() {
    if (this.component && this.component.instance && this.inputs) {
      Object.keys(this.inputs).forEach(p => (this.component.instance[p] = this.inputs[p]));
    }
  }

  private destroyComponentInstance() {
    if (this.component) {
      this.component.destroy();
      this.component = null;
    }
  }
}

Ограничения

Мы также должны помнить, что это накладывает некоторые ограничения.

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

2. Ошибки во время выполнения.
Во время компиляции будет сложно найти ошибки. Если компонент не представлен модулем как динамический компонент, во время выполнения будут обнаружены исключения. Хотя так было даже при использовании entryComponents.

Вывод

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

Я лично использовал этот подход в проектах, где у нас есть интеграции на основе iframe, а диалоговые окна визуализируют компоненты динамически, получая у компонента имя селектора с помощью API window.postMessage.

Песочница

Источник:

#Angular
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

Поделитесь своим опытом, расскажите о новом инструменте, библиотеке или фреймворке. Для этого не обязательно становится постоянным автором.

Попробовать

Оплатив хостинг 25$ в подарок вы получите 100$ на счет

Получить