- Angular Elements, Part I: A Dynamic Dashboard In Four Steps With Web Components
- Angular Elements, Part II: Lazy And External Web Components
- Angular Elements, Part III: Angular Elements without Zone.js
- Angular Elements, Part IV: Content Projection with Slots in Angular Elements (>=7)
- Angular Elements, Part V: Your Options For Building Angular Elements With The CLI
In the first part of this series, I demonstrated how Angular Elements can be used to inject components dynamically into a page. The example there focused on adding tiles to a dashboard as needed.
Here, I want to push that concept further by loading the Web Components only when they are actually needed. I'll cover two distinct techniques for this: Lazy Loading and loading external components.
The complete solution is available in my GitHub repo.
Comparing Lazy Loading with External Component Loading
You might be curious about the distinction between the two methods mentioned above: lazy loading and loading external components.
Lazy Loading requires the component and the host application to be compiled together as a single unit. This setup paves the way for optimizations like tree shaking, but it also restricts your flexibility because the host application must be aware of all potential web components ahead of time. In many ways, this is similar to the situation from the first article. Furthermore, you need to employ Angular's and the CLI's mechanisms for code splitting and deferred loading.
Alternatively, you can package your web component along with all its dependencies—such as @angular/core or @angular/elements—into a single, standalone bundle. This bundle can then be loaded into the host application on demand. While this approach leads to larger bundle sizes, it grants more adaptability since the host can load components that weren't known at build time. The upcoming ngIvy compiler is expected to significantly reduce the size of these bundles. Additionally, my straightforward CLI extension ngx-build-plus enables sharing common dependencies across different bundles. I'll delve into these options in a subsequent post.
Admittedly, what I refer to as "loading external components" is also a form of lazy loading. However, since it doesn't align with the lazy loading that Angular provides natively, I've chosen this term to clarify the difference.
Implementing Lazy Loading (Without the Router)
Angular has supported lazy loading since its inception. Low-level APIs exist for this purpose, and the router provides a convenient abstraction that simplifies its use. Yet, the router isn't a good fit for this particular example since we aren't dealing with routing, but rather with on-demand loading of dashboard tiles.
Instead, I'll use a relatively new feature from the CLI, available since version 6. This feature lets you designate specific modules that should be split into separate chunks during the build process. Afterward, you can use those low-level APIs to load these modules only when required.
To begin, you need to specify the module file(s) containing your web components in your angular.json:
"lazyModules": [
"src/app/lazy-dashboard-tile/lazy-dashboard-tile.module"
],
The code snippet below demonstrates how to use the NgModuleFactoryLoader to fetch the desired bundle:
@Injectable({
providedIn: 'root'
})
export class LazyDashboardTileService {
constructor(
private loader: NgModuleFactoryLoader,
private injector: Injector
) {
}
private moduleRef: NgModuleRef<any>;
load(): Promise<void> {
if (this.moduleRef) {
return Promise.resolve();
}
const path = 'src/app/lazy-dashboard-tile/lazy-dashboard-tile.module#LazyDashboardTileModule'
return this
.loader
.load(path)
.then(moduleFactory => {
this.moduleRef = moduleFactory.create(this.injector).instance;
console.debug('moduleRef', this.moduleRef);
})
.catch(err => {
console.error('error loading module', err);
});
}
}
For simplicity's sake, I'm ignoring potential race conditions here. As with the router, you must provide a string that includes both the module's filename and the name of its class. Once loaded, you instantiate the module by calling its create method.
After that, you could search this instance for components, services, and so on. However, doing so isn't straightforward due to limited APIs for this purpose. The good news is you don't need to when using web components: since they register directly with the browser, you just need to create HTML elements with the correct tag names. For instance, the following code creates a lazy-dashboard-tile element:
const tile = document.createElement('lazy-dashboard-tile');
tile.setAttribute('class', 'col-lg-4 col-md-3 col-sm-2');
tile.setAttribute('a', '100');
tile.setAttribute('b', '50');
tile.setAttribute('c', '25');
const content = document.getElementById('content');
content.appendChild(tile);
You must also ensure the web components get registered once the module is loaded. To achieve this, place the relevant code in the module's constructor:
@NgModule({
[…],
declarations: [
[…]
DashboardTileComponent
],
entryComponents: [
DashboardTileComponent
]
})
export class DashboardModule {
constructor(private injector: Injector) {
const tileCE = createCustomElement(DashboardTileComponent, { injector: this.injector });
customElements.define('dashboard-tile', tileCE);
}
}
Make sure to add the component not only to the module's declarations but also to its entryComponents array.
Loading External Components
To offer an external Web Component, you can begin by scaffolding a new Angular application and then ensure the Angular Element is registered upon startup. For this, I'll use the AppModule's ngDoBootstrap method:
@NgModule({
[…],
declarations: [
ExternalDashboardTileComponent
],
bootstrap: [],
entryComponents: [
ExternalDashboardTileComponent
]
})
export class AppModule {
constructor(private injector: Injector) {
}
ngDoBootstrap() {
const externalTileCE = createCustomElement(ExternalDashboardTileComponent, { injector: this.injector });
customElements.define('external-dashboard-tile', externalTileCE);
}
}
Notice that this example doesn't define a bootstrap component. This is intentional, as I want to register a web component rather than load an Angular Component at startup. To test this component, you can simply invoke the custom element directly within your index.html and run ng serve:
<external-dashboard-tile a="50" b="60" c="70">
</external-dashboard-tile>
To publish these web components, we need a single, self-contained bundle that a host application can load. However, the current CLI version typically produces multiple bundles. To work around this, we can utilize ngx-build-plus, which is a straightforward extension for the CLI:
npm i ngx-build-plus --save-dev
After installing it, modify the builder section in your application's angular.json to reference ngx-build-plus:
"builder": "ngx-build-plus:build",
Now, you can build your project with ng build --project ... --single-bundle. This new single-bundle flag, provided by ngx-build-plus, ensures you get a single self-contained main bundle. Although you might also see other bundles (for example, those containing external scripts or polyfills), all the code and libraries needed to run your web component—your code and its dependencies—are included in the main bundle.
In this example, I've set up a build task in my package.json to copy the resulting bundle into the host application's assets folder.
To load the component dynamically into the host, you only need a bit of DOM manipulation to create a script tag for the bundle and an element tag for the component itself:
// add script tag
const script = document.createElement('script');
script.src = 'assets/external-dashboard-tile.bundle.js';
document.body.appendChild(script);
// add web component
const tile = document.createElement('dashboard-tile');
tile.setAttribute('class', 'col-lg-4 col-md-3 col-sm-2');
tile.setAttribute('a', '100');
tile.setAttribute('b', '50');
tile.setAttribute('c', '25');
const content = document.getElementById('content');
content.appendChild(tile);
