runtime load components or modules into a module in angular2
Asked Answered
L

1

3

I have an angular app that are built using Typescript and bundled together with webpack. Nothing unusual here. What i want to do is to allow plugins on runtime, which means that components and/or modules outside the bundle should be able to be registered in the app as well. So far I've tried to include another webpack bundle in index.html and using an implict array to push said module / component into that, and in my module import these.

See the imports are using an implict variable. This works for modules inside the bundle, but modules in the other bundle will not work.

@NgModule({
  imports: window["app"].modulesImport,
  declarations: [
      DYNAMIC_DIRECTIVES,
      PropertyFilterPipe,
      PropertyDataTypeFilterPipe,
      LanguageFilterPipe,      
      PropertyNameBlackListPipe      
  ],
  exports: [
      DYNAMIC_DIRECTIVES,
      CommonModule,
      FormsModule,
      HttpModule
  ]
})
export class PartsModule {

    static forRoot()
    {
        return {
            ngModule: PartsModule,
            providers: [ ], // not used here, but if singleton needed
        };
    }
}

I've also tried creating a module and a component using es5 code, like below, and push the same thing to my modules array:

var HelloWorldComponent = function () {

};

HelloWorldComponent.annotations = [
    new ng.core.Component({
        selector: 'hello-world',
        template: '<h1>Hello World!</h1>',
    })
];

window["app"].componentsLazyImport.push(HelloWorldComponent);

Both approaches result in the following error:

ncaught Error: Unexpected value 'ExtensionsModule' imported by the module 'PartsModule'. Please add a @NgModule annotation.
    at syntaxError (http://localhost:3002/dist/app.bundle.js:43864:34) [<root>]
    at http://localhost:3002/dist/app.bundle.js:56319:44 [<root>]
    at Array.forEach (native) [<root>]
    at CompileMetadataResolver.getNgModuleMetadata (http://localhost:3002/dist/app.bundle.js:56302:49) [<root>]
    at CompileMetadataResolver.getNgModuleSummary (http://localhost:3002/dist/app.bundle.js:56244:52) [<root>]
    at http://localhost:3002/dist/app.bundle.js:56317:72 [<root>]
    at Array.forEach (native) [<root>]
    at CompileMetadataResolver.getNgModuleMetadata (http://localhost:3002/dist/app.bundle.js:56302:49) [<root>]
    at CompileMetadataResolver.getNgModuleSummary (http://localhost:3002/dist/app.bundle.js:56244:52) [<root>]
    at http://localhost:3002/dist/app.bundle.js:56317:72 [<root>]
    at Array.forEach (native) [<root>]
    at CompileMetadataResolver.getNgModuleMetadata (http://localhost:3002/dist/app.bundle.js:56302:49) [<root>]
    at JitCompiler._loadModules (http://localhost:3002/dist/app.bundle.js:67404:64) [<root>]
    at JitCompiler._compileModuleAndComponents (http://localhost:3002/dist/app.bundle.js:67363:52) [<root>]

Please note that if i try with a component instead of a module, i put them in declarations instead, which results in the corresponding error for the components saying i need to add a @pipe/@component annotation instead.

I feel this should be doable, but i don't know what I'm missing. Im using [email protected]

update 11/05/2017

So i decided to take a step back from this and start from scratch. Instead of using webpack I decided to try with SystemJS instead as i found a core component in Angular. This time i got it working using the following component and service to insert components:

typebuilder.ts

import { Component, ComponentFactory, NgModule, Input, Injectable, CompilerFactory } from '@angular/core';
import { JitCompiler } from '@angular/compiler';
import {platformBrowserDynamic} from "@angular/platform-browser-dynamic";

export interface IHaveDynamicData { 
    model: any;
}

@Injectable()
export class DynamicTypeBuilder {

    protected _compiler : any;
         // wee need Dynamic component builder
    constructor() {
        const compilerFactory : CompilerFactory = platformBrowserDynamic().injector.get(CompilerFactory);
        this._compiler = compilerFactory.createCompiler([]);
    }

  // this object is singleton - so we can use this as a cache
    private _cacheOfFactories: {[templateKey: string]: ComponentFactory<IHaveDynamicData>} = {};

    public createComponentFactoryFromType(type: any) : Promise<ComponentFactory<any>> {
        let module = this.createComponentModule(type);
            return new Promise((resolve) => {
            this._compiler
                .compileModuleAndAllComponentsAsync(module)
                .then((moduleWithFactories : any) =>
                {
                    let _ = window["_"];
                    let factory = _.find(moduleWithFactories.componentFactories, { componentType: type });
                    resolve(factory);
                });
        });
    }

    protected createComponentModule (componentType: any) {
        @NgModule({
        imports: [
        ],
        declarations: [
            componentType
        ],
        })
        class RuntimeComponentModule
        {
        }
        // a module for just this Type
        return RuntimeComponentModule;
    }
}

Dynamic.component.ts

import { Component, Input, ViewChild, ViewContainerRef, SimpleChanges, AfterViewInit, OnChanges, OnDestroy, ComponentFactory, ComponentRef } from "@angular/core";
import { DynamicTypeBuilder } from "../services/type.builder";

@Component({
    "template": '<h1>hello dynamic component <div #dynamicContentPlaceHolder></div></h1>',
    "selector": 'dynamic-component'
})
export class DynamicComponent implements AfterViewInit, OnChanges, OnDestroy {

    @Input() pathToComponentImport : string;

    @ViewChild('dynamicContentPlaceHolder', {read: ViewContainerRef}) 
    protected dynamicComponentTarget: ViewContainerRef;
    protected componentRef: ComponentRef<any>;

    constructor(private typeBuilder: DynamicTypeBuilder) 
    {

    }  

    protected refreshContent() : void {
        if (this.pathToComponentImport != null && this.pathToComponentImport.indexOf('#') != -1) {
          let [moduleName, exportName] = this.pathToComponentImport.split("#");
          window["System"].import(moduleName)
            .then((module: any) => module[exportName])
            .then((type: any) => {
                this.typeBuilder.createComponentFactoryFromType(type)
                .then((factory: ComponentFactory<any>) =>
                {
                    // Target will instantiate and inject component (we'll keep reference to it)
                    this.componentRef = this
                        .dynamicComponentTarget
                        .createComponent(factory);

                    // let's inject @Inputs to component instance
                    let component = this.componentRef.instance;

                    component.model = { text: 'hello world' };

                    //...
                });
            });
      }
    }

    ngOnDestroy(): void {
    }

    ngOnChanges(changes: SimpleChanges): void {
    }

    ngAfterViewInit(): void {
        this.refreshContent();
    }

}

Now i can link to any given component like this:

<dynamic-component pathToComponentImport="/app/views/components/component1/extremely.dynamic.component.js#ExtremelyDynamicComponent"></dynamic-component>

Typescript config:

 {
  "compilerOptions": {
    "target": "es5",
    "module": "system",
    "moduleResolution": "node",
    "sourceMap": true,
    "emitDecoratorMetadata": true,
    "allowJs": true,
    "experimentalDecorators": true,
    "lib": [ "es2015", "dom" ],
    "noImplicitAny": true,
    "suppressImplicitAnyIndexErrors": true
  },
    "exclude": [
      "node_modules",
      "systemjs-angular-loader.js",
      "systemjs.config.extras.js",
      "systemjs.config.js"
  ]
}

And above my typescript config. So this works, however I'm not sure that I'm happy with using SystemJS. I feel like this should be possible with webpack as well and unsure if it's the way that TC compiles the files that webpack does not understand... I'm still getting the missing decorator exception if i try to run this code in a webpack bundle.

Best regards Morten

Lustreware answered 26/4, 2017 at 9:27 Comment(3)
Hello, i'm trying tout acheive something like that as well. Any Luck with webpack ? Are the module loaded from remote server?Rossy
I solved it by loading everything trough system JS and then i use a plugin for webpack that wraps the webpack bundle in a system js call.I will write a proper answer when i get the time. Right now i am swarmed with work so it will not be within the next two weeks i guess. But take a look at webpack-system-register plugin for webpack.Lustreware
@user2940226 Hello, would you mind to share result code, please? ThanksZeidman
L
2

So I was hammering through trying to find a solution. And in the end i did. Whether or not this is a hacky solution and there's a better way i don't know... For now, this is how i solved it. But i do hope there's a more modern solution in the future or just around the corner.

This solution is essentially a hybrid model of SystemJS and webpack. In your runtime you need to use SystemJS to load your app, and your webpack bundle needs to be consumable by SystemJS. To do this you need a plugin for webpack that makes this possible. Out of the box systemJS and webpack are not compatible as they use different module definitions. Not with this plugin though.

  1. In both your core app and your plugins, you need to install an extension for webpack called

"webpack-system-register".

I have version 2.2.1 of webpack and 1.5.0 of WSR. 1.1 In your webpack.config.js you need to add WebPackSystemRegister as the first element in your core.plugins like so:

config.plugins = [
  new WebpackSystemRegister({
    registerName: 'core-app', // optional name that SystemJS will know this bundle as. 
    systemjsDeps: [
    ]
  }) 
  //you can still use other plugins here as well
];

Since SystemJS is now used to load the app, you need a systemjs config as well. Mine looks like this.

(function (global) {
System.config({
paths: {
  // paths serve as alias
  'npm:': 'node_modules/'
},
// map tells the System loader where to look for things
map: {
  // our app is within the app folder
  'app': 'app',

  // angular bundles
  // '@angular/core': 'npm:@angular/core/bundles/core.umd.min.js',
  '@angular/core': '/dist/fake-umd/angular.core.fake.umd.js',
  '@angular/common': '/dist/fake-umd/angular.common.fake.umd.js',
  '@angular/compiler': 'npm:@angular/compiler/bundles/compiler.umd.min.js',
  '@angular/platform-browser': '/dist/fake-umd/angular.platform.browser.fake.umd.js',
  '@angular/platform-browser-dynamic': 'npm:@angular/platform-browser-dynamic/bundles/platform-browser-dynamic.umd.min.js',
  '@angular/http': '/dist/fake-umd/angular.http.fake.umd.js',
  '@angular/router': 'npm:@angular/router/bundles/router.umd.min.js',
  '@angular/forms': 'npm:@angular/forms/bundles/forms.umd.min.js',
  '@angular/platform-browser/animations': 'npm:@angular/platform-browser/bundles/platform-browser-animations.umd.min.js',
  '@angular/material': 'npm:@angular/material/bundles/material.umd.js',
  '@angular/animations/browser': 'npm:@angular/animations/bundles/animations-browser.umd.min.js',
  '@angular/animations': 'npm:@angular/animations/bundles/animations.umd.min.js',
  'angular2-grid/main': 'npm:angular2-grid/bundles/NgGrid.umd.min.js',      
  '@ng-bootstrap/ng-bootstrap': 'npm:@ng-bootstrap/ng-bootstrap/bundles/ng-bootstrap.js',            
  // other libraries
  'angular-in-memory-web-api': 'npm:angular-in-memory-web-api/bundles/in-memory-web-api.umd.js', 
  "rxjs": "npm:rxjs",          


},
// packages tells the System loader how to load when no filename and/or no extension
packages: {
  app: {
    defaultExtension: 'js',
    meta: {
      './*.html': {
        defaultExension: false,
      },
      './*.js': {
        loader: '/dist/configuration/systemjs-angular-loader.js'
      },
    }
  },
  rxjs: {
    defaultExtension: 'js'
  },
},
  });
 })(this);

I will get back to the map element later on in the answer, describing why angular is in there and how it is done. In your index.html you need to have your references kinda like this:

<script src="node_modules/systemjs/dist/system.src.js"></script> //system
<script src="node_modules/reflect-metadata/reflect.js"></script>
<script src="/dist/configuration/systemjs.config.js"></script> // config for system js
<script src="/node_modules/zone.js/dist/zone.js"></script>
<script src="/dist/declarations.js"></script> // global defined variables
<script src="/dist/app.bundle.js"></script> //core app
<script src="/dist/extensions.bundle.js"></script> //extensions app

For now, this allows us to run everything as we want. However there's a little twist to this, which is that you still run into the exceptions as described in the original post. To fix this (i still don't know why this happens though), we need to do a single trick in the plugin source code, that are created using webpack and webpack-system-register:

plugins: [
  new WebpackSystemRegister({
      registerName: 'extension-module', // optional name that SystemJS will know this bundle as. 
      systemjsDeps: [
        /^@angular/,
        /^rx/
      ]
  })

Code above uses webpack system register to exclude Angular and RxJs modules from the extension bundle. What is going to happen is that systemJS will run into angular and RxJs when importing the module. They are left out, so System will try to load them, using the map configuration of System.config.js. Now here comes the fun part.:

In the core app, in webpack i import all angular modules and expose them in a public variable. This can be done anywhere in your app, I've done it in main.ts. Example given below:

lux.bootstrapModule = function(module, requireName, propertyNameToUse) {
    window["lux"].angularModules.modules[propertyNameToUse] = module;
    window["lux"].angularModules.map[requireName] = module;
}

import * as angularCore from '@angular/core';
window["lux"].bootstrapModule(angularCore, '@angular/core', 'core');
platformBrowserDynamic().bootstrapModule(AppModule);

In our systemjs config we create a map like this, to let systemjs know where to load our depencenies (they are excluded in the extenion bundles, like described above):

'@angular/core': '/dist/fake-umd/angular.core.fake.umd.js',
'@angular/common': '/dist/fake-umd/angular.common.fake.umd.js',

So whenever systemjs stumples upon angular core or angular common, it is told to load it from the fake umd bundles I've defined. They look like this:

(function (root, factory) {
    if (typeof define === 'function' && define.amd) {
        // AMD
        define([], factory);
    } else if (typeof exports === 'object') {
        // Node, CommonJS-like
        module.exports = factory();
    }
}(this, function () {

    //    exposed public method
    return window["lux"].angularModules.modules.core;
}));

Eventually, using the runtime compiler, I can now use modules that are loaded externally:

So system can now be used in Angular to import and compile modules. This only needs to happen once per module. Unfortunately this prevents you from leaving out the runtime compiler which is quite heavy.

I have a service That can load modules and return factories, eventually giving you the ability to lazy load modules that are not know on transpile time in the core. This is great for software vendors like commerce platforms, CMS, CRM systems, or other where developers create plugins for those kind of systems without having the source code.

window["System"].import(moduleName) //module name is defined in the webpack-system-register "registerName"
            .then((module: any) => module[exportName])
            .then((type: any) => {
                let module = this.createComponentModuleWithModule(type);
                this._compiler.compileModuleAndAllComponentsAsync(module).then((moduleWithFactories: any) => {
                    const moduleRef = moduleWithFactories.ngModuleFactory.create(this.injector);

                    for (let factory of moduleWithFactories.componentFactories) {

                        if (factory.selector == 'dynamic-component') { //all extension modules will have a factory for this. Doesn't need to go into the cache as not used.
                            continue;
                        }

                        var factoryToCache = {
                            template: null,
                            injector: moduleRef.injector,
                            selector: factory.selector,
                            isExternalModule: true,
                            factory: factory,
                            moduleRef: moduleRef,
                            moduleName: moduleName,
                            exportName: exportName
                        }

                        if (factory.selector in this._cacheOfComponentFactories) {
                            var existingFactory = this._cacheOfComponentFactories[factory.selector]
                            console.error(`Two different factories conflicts in selector:`, factoryToCache, existingFactory)
                            throw `factory already exists. Did the two modules '${moduleName}-${exportName}' and '${existingFactory.moduleName}-${existingFactory.exportName}' share a component selector?: ${factory.selector}`;
                        }

                        if (factory.selector.indexOf(factoryToCache.exportName) == -1) {
                            console.warn(`best practice for extension modules is to prefix selectors with exportname to avoid conflicts. Consider using: ${factoryToCache.exportName}-${factory.selector} as a selector for your component instead of ${factory.selector}`);
                        }

                        this._cacheOfComponentFactories[factory.selector] = factoryToCache;
                    }
                })
                resolve();
            })

To sum it up:

  1. install webpack-system-register in both your core app and your extension modules
  2. exlude angular dependencies in your extension bundles
  3. in your core app expose angular dependencies in a global variable
  4. create a fake bundle per dependency by returning the exposed dependency
  5. in your systemjs map, add dependencies to be loaded in the fake js bundle
  6. runtime compiler in Angular can now be used to load modules that has been packaged with webpack using webpack-system-register
Lustreware answered 9/2, 2018 at 13:14 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.