brandonscript brandonscript - 4 years ago 89
Javascript Question

Consuming lazy-loaded Angular 2 components in a template constructed from an external JSON file

I'm building an angular components doc/demo site that enumerates a list of Angular components and a set of JSON documents with usage details and examples.

Here's an example of a basic component:

import { Component } from '@angular/core';

@Component({
selector: 'my-button',
template: `<button class="btn btn-default">I am a banana</button>`
})
export class Button {}


To start with, I'm using a pre-build script to autogenerate a
componentLoader
file that looks like this:

import { Route } from '@angular/router';
import { Button } from '../app/components/elements/button/button';

export const COMPONENT_ROUTES: Route[] = [{ path: 'button', component: Button }];
export const COMPONENT_DECLARATIONS: Array<any|any[]> = [Button];


I then load that into
app.module.ts
(removed other imports for brevity):

import { COMPONENT_ROUTES, COMPONENT_DECLARATIONS } from './componentLoader';
// ...
@NgModule({
...
declarations: [ ... ].concat(COMPONENT_DECLARATIONS)


This works fine; I can use the button in a view component:

import { Component } from '@angular/core';

@Component({
selector: 'button-test',
template: `
<my-button></my-button>
`
})
export class ButtonTest {}


Woo!

Now, I want need be able to enumerate a folder of JSON documents to look for details related to this component and how it's used. The JSON looks like this:

{
"name": "button",
"description": "An example of an 'I am a banana' button",
"exampleHTML": "<div><my-button></my-button></div>"
}


Notice how in
exampleHTML
I'm trying to use the component we loaded in the
componentLoader
.

To generate doc views, I built a docs service that takes an
id
param and searches for a matching JSON document in the /docs dir:

import {Injectable} from '@angular/core';

declare const require: any;

@Injectable()

export class DocsService {
constructor() { }
getDoc(id: string) {
const json: string = require('../docs/' + id + '.json');
return json;
}
}


Then the detail component imports the service and consumes the JSON doc component in the template:

import { Component } from '@angular/core';
import { ActivatedRoute, Params } from '@angular/router';
import { DocsService } from '../../services/docs.service';

declare const require: any;
const template: string = require('./componentDetail.html');

@Component({
selector: 'componentDetail',
template: `<div>Name: {{componentDoc.name}}</div>
Examples:<br><br><div [innerHTML]="componentExample"></div>`
})

export class ComponentDetail {

public componentDoc: any
private _componentExample: string
public get componentExample(): SafeHtml {
return this._sanitizer.bypassSecurityTrustHtml(this._componentExample);
}

constructor(
private route: ActivatedRoute,
private docsService: DocsService,
private _sanitizer: DomSanitizer
) {
this.route.params.forEach((params: Params) => {
if (params['id'] !== undefined) {
let id = params['id'];
this.componentDoc = this.docsService.getDoc(id)
this._componentExample = this.componentDoc.exampleHTML
} else { throw Error('Not found') }
});
}
}


The problem:

Perhaps because of the way these components are loaded, or because I'm not loading HTML into Angular's templating engine correctly, or because there's a black-box Webpack service that runs in the middle of the build chain, the template that should show the actual
button
component, is completely blank.

In the component detail view, I can manually set the template string to
<my-button></my-button>
and it works, but it doesn't when I try to load it from the JSON doc.

How can I get the detail component to actually render the Button component used in the JSON doc?

Answer Source

Instead of using the [innerHTML] directive you can instead attack the problem by creating dynamic components.

To do so you will need to use the Compiler.compileModuleAndAllComponentsAsync method to dynamically load a Module and its components.

First you will need to create a module that contains your applications components that you want to be able to use within the dynamic HTML.

Change your componentLoader to be a module like so

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Button } from '../../app/components/elements/button/button';

const COMPONENT_DECLARATIONS: Array<any|any[]> = [
    Button
];

@NgModule({ 
    declarations: COMPONENT_DECLARATIONS, 
    exports: COMPONENT_DECLARATIONS, 
    imports: [CommonModule] 
})
export class ComponentsModule {}

Create dynamicComponent.service.ts that will be used to generate the dynamic modules and components. The key to using components within the dynamically loaded HTML is how we will import the ComponentsModule within DynamicComponentModule.

import { 
  Component,
  Compiler,
  ModuleWithComponentFactories,
  ComponentFactory,  
  NgModule,
  Type
} from '@angular/core';
import { Injectable }                from '@angular/core';
import { CommonModule }              from '@angular/common';

import { ComponentsModule }          from '../components/components.module';

@Injectable()
export class DynamicComponentService {

  private componentFactoriesCache: ComponentFactory<any>[] = [];  

  constructor(private compiler: Compiler) { }

  public getComponentFactory(templateHtml: string): Promise<ComponentFactory<any>> {
    return new Promise<ComponentFactory<any>>((resolve) => {
        let factory = this.componentFactoriesCache[templateHtml];
        if ( factory ) { 
            resolve(factory);
        } else {
            let dynamicComponentType = this.createDynamicComponent(templateHtml);
            let dynamicModule = this.createDynamicModule(dynamicComponentType);

            this.compiler.compileModuleAndAllComponentsAsync(dynamicModule)
                .then((mwcf: ModuleWithComponentFactories<any>) => {
                    factory = mwcf.componentFactories.find(cf => cf.componentType === dynamicComponentType);
                    this.componentFactoriesCache[templateHtml] = factory;
                    resolve(factory);
            });
        }
     });
  }

  private createDynamicComponent(templateHtml: string): Type<NgModule> {
    @Component({
      template: templateHtml,
    })
    class DynamicDocComponent {}

    return DynamicDocComponent;
  }

  private createDynamicModule(dynamicComponentType: Type<Component>) {
    @NgModule({
      declarations: [dynamicComponentType],
      imports: [CommonModule, ComponentsModule]
    })
    class DynamicDocComponentModule {}

    return DynamicDocComponentModule;
  }

}

Import ComponentsModule and DynamicComponentService into app.module.ts

@NgModule({
    ...
    imports: [        
        ...
        ComponentsModule
    ],
    providers: [
        ...
        DynamicComponentService
    ]
    ...
})
export class AppModule {}

Finally the ComponentDetail is changed to use the new DynamicComponentService to get the ComponentFactory for the dynamic component.

Once you have the new ComponentFactory you can then use a view container to create and populate the component with the dynamic html.

import { 
  Component,  
  ComponentRef,
  ViewChild,   
  ViewContainerRef, 
  AfterContentInit,
  OnDestroy
} from '@angular/core';
import { ActivatedRoute, Params }    from '@angular/router';

import { DocsService }               from './services/docs.service';
import { DynamicComponentService }   from './services/dynamicComponent.service';

@Component({
  selector: 'componentDetail',
  template:  `<div>Name: {{componentDoc.name}}</div>
               Examples:<br><br><template #container></template>
  `
})

export class ComponentDetail implements AfterContentInit, OnDestroy {

  private componentRouteID: string;
  private componentDoc: any;
  private componentExampleHtml: string;
  private componentRef: ComponentRef<any>;

  @ViewChild('container', { read: ViewContainerRef }) 
  private container: ViewContainerRef;

  constructor(private route: ActivatedRoute,
              private docsService: DocsService,
              private dynamicComponentService: DynamicComponentService) {
      this.route.params.forEach((params: Params) => {
          this.componentRouteID = params['id'];
          if (this.componentRouteID ) {
              this.componentDoc = this.docsService.getDoc(this.componentRouteID);            
              this.componentExampleHtml = this.componentDoc.exampleHTML;
          } else {
             throw Error('Not found'); 
          }
        });
  }

  public ngAfterContentInit() {
    this.dynamicComponentService.getComponentFactory(this.componentExampleHtml).then((factory) => {
        this.componentRef = this.container.createComponent(factory);        
    });
  }

  public ngOnDestroy() {    
    this.componentRef.destroy();
  }
}
Recommended from our users: Dynamic Network Monitoring from WhatsUp Gold from IPSwitch. Free Download