amcdnl amcdnl - 16 days ago 6
AngularJS Question

Angular2 Dynamic Component Injection in Root

Question



I'm looking for the best approach for injecting a known/defined component into the root of an application and projecting
@Input()
options onto that component.

Requirement



This is necessary for creating things like modals/tooltips in the body of the application so that
overflow:hidden
/etc will not distort the position or cut it off completely.

Research



I've found that I can get the
ApplicationRef
's and then hackily traverse upwards and find the
ViewContainerRef
.

constructor(private applicationRef: ApplicationRef) {
}

getRootViewContainerRef(): ViewContainerRef {
return this.applicationRef['_rootComponents'][0]['_hostElement'].vcRef;
}


once I have that I can then call
createComponent
on the ref like:

appendNextToLocation<T>(componentClass: Type<T>, location: ViewContainerRef): ComponentRef<T> {
const componentFactory = this.componentFactoryResolver.resolveComponentFactory(componentClass);
const parentInjector = location.parentInjector;
return location.createComponent(componentFactory, location.length, parentInjector);
}


but now I've created the component but none of my
Input
properties are fulfilled. To achieve that I have to manually traverse over my options and set those on the result of
appendNextToLocation
's instance like:

const props = Object.getOwnPropertyNames(options);
for(const prop of props) {
component.instance[prop] = options[prop];
}


now I do realize you could do some DI to inject the options but that makes it not re-usable when trying to use as a normal component then. Heres what that looks like for reference:

let componentFactory = this.componentFactoryResolver.resolveComponentFactory(ComponentClass);
let parentInjector = location.parentInjector;

let providers = ReflectiveInjector.resolve([
{ provide: ComponentOptionsClass, useValue: options }
]);

childInjector = ReflectiveInjector.fromResolvedProviders(providers, parentInjector);

return location.createComponent(componentFactory, location.length, childInjector);


all that said, all of the above actually works but it feels tad hacky at times. I'm also concerned about lifecycle timing of setting the input properties like the above since it happens after its created.

Notable References




Answer

In 2.3.0, attachView was introduced which allows you to be able to attach change detection to the ApplicationRef, however, you still need to manually append the element to the root container. This is because with Angular2 the possibilities of environments its running could be web workers, universal, nativescript, etc so we need to explicitly tell it where/how we want to add this to the view.

Below is a sample service that will allow you to insert a component dynamically and project the Input's of the component automatically. This also works with ~2.2.0 applications as well.

import {
  ApplicationRef, ComponentFactoryResolver, ComponentRef, Injectable,
  Injector, ViewContainerRef, EmbeddedViewRef, Type
} from '@angular/core';

/**
 * Injection service is a helper to append components
 * dynamically to a known location in the DOM, most
 * noteably for dialogs/tooltips appending to body.
 * 
 * @export
 * @class InjectionService
 */
@Injectable()
export class InjectionService {
  private _container: ComponentRef<any>;

  constructor(
    private applicationRef: ApplicationRef,
    private componentFactoryResolver: ComponentFactoryResolver,
    private injector: Injector) {
  }

  /**
   * Gets the root view container to inject the component to.
   * 
   * @returns {ComponentRef<any>}
   * 
   * @memberOf InjectionService
   */
  getRootViewContainer(): ComponentRef<any> {
    if(this._container) return this._container;

    const rootComponents = this.applicationRef['_rootComponents'];
    if (rootComponents.length) return rootComponents[0];

    throw new Error('View Container not found! ngUpgrade needs to manually set this via setRootViewContainer.');
  }

  /**
   * Overrides the default root view container. This is useful for 
   * things like ngUpgrade that doesn't have a ApplicationRef root.
   * 
   * @param {any} container
   * 
   * @memberOf InjectionService
   */
  setRootViewContainer(container): void {
    this._container = container;
  }

  /**
   * Gets the html element for a component ref.
   * 
   * @param {ComponentRef<any>} componentRef
   * @returns {HTMLElement}
   * 
   * @memberOf InjectionService
   */
  getComponentRootNode(componentRef: ComponentRef<any>): HTMLElement {
    return (componentRef.hostView as EmbeddedViewRef<any>).rootNodes[0] as HTMLElement;
  }

  /**
   * Gets the root component container html element.
   * 
   * @returns {HTMLElement}
   * 
   * @memberOf InjectionService
   */
  getRootViewContainerNode(): HTMLElement {
    return this.getComponentRootNode(this.getRootViewContainer());
  }

  /**
   * Projects the inputs onto the component
   * 
   * @param {ComponentRef<any>} component
   * @param {*} options
   * @returns {ComponentRef<any>}
   * 
   * @memberOf InjectionService
   */
  projectComponentInputs(component: ComponentRef<any>, options: any): ComponentRef<any> {
    if(options) {
      const props = Object.getOwnPropertyNames(options);
      for(const prop of props) {
        component.instance[prop] = options[prop];
      }
    }

    return component;
  }

  /**
   * Appends a component to a adjacent location
   * 
   * @template T
   * @param {Type<T>} componentClass
   * @param {*} [options={}]
   * @param {Element} [location=this.getRootViewContainerNode()]
   * @returns {ComponentRef<any>}
   * 
   * @memberOf InjectionService
   */
  appendComponent<T>(
    componentClass: Type<T>, 
    options: any = {}, 
    location: Element = this.getRootViewContainerNode()): ComponentRef<any> {

    let componentFactory = this.componentFactoryResolver.resolveComponentFactory(componentClass);
    let componentRef = componentFactory.create(this.injector);
    let appRef: any = this.applicationRef;
    let componentRootNode = this.getComponentRootNode(componentRef);

    // project the options passed to the component instance
    this.projectComponentInputs(componentRef, options);

    // ApplicationRef's attachView and detachView methods are in Angular ^2.2.1 but not before.
    // The `else` clause here can be removed once 2.2.1 is released.
    if (appRef['attachView']) {
      appRef.attachView(componentRef.hostView);

      componentRef.onDestroy(() => {
        appRef.detachView(componentRef.hostView);
      });
    } else {
      // When creating a component outside of a ViewContainer, we need to manually register
      // its ChangeDetector with the application. This API is unfortunately not published
      // in Angular <= 2.2.0. The change detector must also be deregistered when the component
      // is destroyed to prevent memory leaks.      
      let changeDetectorRef = componentRef.changeDetectorRef;
      appRef.registerChangeDetector(changeDetectorRef);

      componentRef.onDestroy(() => {
        appRef.unregisterChangeDetector(changeDetectorRef);

        // Normally the ViewContainer will remove the component's nodes from the DOM.
        // Without a ViewContainer, we need to manually remove the nodes.
        if (componentRootNode.parentNode) {
          componentRootNode.parentNode.removeChild(componentRootNode);
        }
      });
    }

    location.appendChild(componentRootNode);

    return componentRef;
  }
}

Comments