Wednesday, June 17, 2020

Angular Elements: Angular Elements without Zone.js

In this short article, I explain why excluding zone.js is a good idea and how to deal with the consequences. The sample I use for this can be found in my github repo. Please make sure to use the branch noop-zone.

Why zone.js might be a bad idea for Custom Elements

In general, we want our custom elements to be as small as possible in terms of bundle size. The upcoming ngIvy view engine will help a lot with this goal as it produces more tree-shakable code and hence allows Angular "blowing itself mostly away" during the compilation.

Another approach to shrink bundles is reusing Angular packages across several Angular Elements and the host application. After an enlightening discussion with Angular's Rob Wormald, I've created ngx-build-plus -- a simple CLI extension that helps to implement this idea.

However, in both cases we cannot get rid of zone.js which is used by Angular for change tracking since its first days. This library monkey patches a lot of browser objects to get informed about all events after which Angular needs to check the displayed components for changes.

While this provides convenience in Angular application, having such a dependency for a custom element is not desirable, especially when the hosting application is not Angular based: Not every consumer wants to monkey patch browser objects and in many cases zone.js is bigger than the custom element itself.

Getting rid of zone.js

Getting rid of zone.js is the easiest part. Just set configure the noop zone (no operation zone) when bootstrapping the Angular application:

platformBrowserDynamic() .bootstrapModule( AppModule, { ngZone: 'noop' }) .catch(err => console.log(err));

However, dealing with the consequences of removing zone.js isn't that easy as without this library we have to trigger change detection manually.

Triggering Change Detection manually

For my demonstrations, I use a simple Angular component that displays three numeric values:

@Component({ [...] }) export class ExternalDashboardTileComponent { @Input() a: number; @Input() b: number; @Input() c: number; more(): void { this.a = Math.round(Math.random() * 100); this.b = Math.round(Math.random() * 100); this.c = Math.round(Math.random() * 100); } }

It also provides a more method that updates those values. For the sake of simplicity, I use random numbers here.

The values are displayed in an table and the method is bound to the click event of a button:

<table class="table table-condensed"> <tr> <td>A</td> <td></td> </tr> <tr> <td>B</td> <td></td> </tr> <tr> <td>C</td> <td></td> </tr> </table> <button class="btn btn-default btn-sm" (click)="more()">More</button>

When using zone.js, Angular is automatically performing change detection after the click event and hence updating the bound values. But without zone.js Angular is not aware of the click event. This means, we have to trigger change detection by hand.

This can be accomplished by calling the markForCheck method of the current ChangeDetectorRef:

@Component({ [...] }) export class ExternalDashboardTileComponent { @Input() a: number; @Input() b: number; @Input() c: number; constructor(private cd: ChangeDetectorRef) { } more(): void { this.a = Math.round(Math.random() * 100); this.b = Math.round(Math.random() * 100); this.c = Math.round(Math.random() * 100); this.cd.markForCheck(); } }

As this is a very explicit approach, one can easily forget about calling the method at the right moment. Therefore I present an alternative in the next section.

Push-Pipe

A more declarative way for triggering change detection is using Observables. Every time a new value arises, a pipe can tell Angular to check for changes. While Angular comes with the async pipe for such cases, it also demands on zone.js.

What we need is a tuned async pipe. A prototypical (!) one comes from Fabian Wiles who is an active community member. He calls it push pipe.

To use it, we need to introduce an Observable. In my example, I put it directly into the component. In an more advanced case, it should be provided by a service instead. To be able to directly notify it, I'm using a BehaviorSubject too:

@Component({ [...] }) export class ExternalDashboardTileComponent implements OnInit { @Input() a: number; @Input() b: number; @Input() c: number; private statsSubject = new BehaviorSubject<Stats>(null); public stats$ = this.statsSubject.asObservable(); [...] }

To get along with just one Observable for all three values, I group them with a class Stats:

class Stats { constructor( readonly a: number, readonly b: number, readonly c: number ) { } }

After Angular created the component, we have to publish the three numeric values for the first time:

ngOnInit(): void { this.statsSubject.next(new Stats(this.a, this.b, this.c)); }

After each modification, we have to do the same:

more(): void { this.a = Math.round(Math.random() * 100); this.b = Math.round(Math.random() * 100); this.c = Math.round(Math.random() * 100); this.statsSubject.next(new Stats(this.a, this.b, this.c)); }

In the template, we can subscribe to the Observable with the new push pipe. In the next listing I'm using an ngIf for this. The as clause writes the received object into the stats template variable.

<div class="content" *ngIf="stats$ | push as stats"> <div style="height:200px;"> <br> <table class="table table-condensed"> <tr> <td>A</td> <td></td> </tr> <tr> <td>B</td> <td></td> </tr> <tr> <td>C</td> <td></td> </tr> </table> <button class="btn btn-default btn-sm" (click)="more()">More</button> </div> </div>

Also, we can switch to OnPush now, as we are just relying on Observables and Immutables:

@Component({ [...], changeDetection: ChangeDetectionStrategy.OnPush }) export class ExternalDashboardTileComponent implements OnInit { [...] }

Angular Elements, Lazy and External Web Components

In this article, I'm going one step further: I'll extend the shown example by loading the Web Components on demand. For this, I demonstrate two approaches: Lazy Loading and loading external components.

The solution can be found in my GitHub repo.

Lazy Loading vs. Loading External Components

Perhaps you are wondering what the differences between to two outlined approaches -- lazy loading and loading external components -- are.

Lazy Loading demands on compiling the component and its hosting application together. This allows for optimizations like tree shaking but also limits your possibilities as the application needs to know all possible web components in advance. It's more or less like what you know from the first article. In addition, you have to leverage Angular's and the CLI's features for code splitting and lazy loading.

On the other side, you could also put your web component and all libraries it depends on -- like @angular/core or @angular/elements -- into one self-contained bundle. After this, you can load this bundle in your host application. This increases bundle sizes but also gives you more flexibility as the host can dynamically load components not known at build time. The upcoming ngIvy compiler will help a lot with shrinking such bundles to a minimum. Also, my simple CLI extension ngx-build-plus allows to share common dependencies between different bundles. I will talk about those options in a later blog post.

Of course, what I'm calling "loading external components" here, is also lazy loading. But as it is not the kind of lazy loading Angular provides out of the box, I've decided to use this paraphrase.

Implementing Lazy Loading (without the Router)

Lazy Loading is baked into Angular since its first days. There are low level APIs for it and the router provides a nice abstraction that makes this concept easy to use. However, in the example shown here, using the router is not beneficial because this is not about loading routes but loading some tiles into a dashboard on demand.

That's why I'm leveraging a quite new feature the CLI provides since version 6. Using it, you can point to specific modules which are split off during bundling. After this, you can make use of the mentioned low level APIs to load those modules on demand.

To get started, reference the module file(s) with your web components in your angular.json:

"lazyModules": [ "src/app/lazy-dashboard-tile/lazy-dashboard-tile.module" ],

The next listing shows how you can leverage the NgModuleFactoryLoader to load the 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 the sake of simplicity, I'm not taking care of every possible race condition. As with the router, you have to provide a string with both, the filename of the module as well as the name of the module class. After loading it, you have to instantiate the module with create.

After this, you could search this instance for components, services etc. However, this is not easy due to the lack of respective APIs. The good message is that you don't have to this when going with web components: As they directly register with the browser, all you need is to create html elements with the right names. For instance, the next listing 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);

Also, you have to make sure that the web components are registered when the module is loaded. To achieve that, you could put the necessary code into 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); } }

Don't forget to put the component in question not only into the module's declarations section but also into its entryComponents array.

Loading External Components

For providing an external Web Component, you can just scaffold a new Angular application and make sure the Angular Element is registered when it starts up. For this, I'm using 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); } }

Please also note that this example doesn't define an bootstrap component. The reason is, I don't want to load an Angular Component on startup but just register a web component. To test this component, just call the web component directly in your index.html and ng serve your project:

<external-dashboard-tile a="50" b="60" c="70"> </external-dashboard-tile>

For publishing our web components, we need one self-contained bundle that can be loaded into a host application. However, the current version of the CLI always creates several bundles. To solve this, we can use ngx-build-plus -- a simple extension for the CLI:

npm i ngx-build-plus --save-dev

After installing it, update your application's builder section within the angular.json file so that it points to ngx-build-plus:

"builder": "ngx-build-plus:build",

Now, you can build your project using ng build --project ... --single-bundle. The new flag single-bundle which is provided by ngx-build-plus, makes sure you end up with one self-contained main bundle. In addition, you might also get other bundles, e. g. bundles with external scripts or polyfills. But everything you directly need to run your web component -- your code and the libraries it depends on -- ends up in the main bundle.

In the example shown here, I'm using a build task in my package.json for coping over this bundle into the host's assets folder.

To dynamically load the web component into the host, you just need some DOM manipulations to create a respective script tag as well as a 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);

Free hosting web sites and features -2024

  Interesting  summary about hosting and their offers. I still host my web site https://talash.azurewebsites.net with zero cost on Azure as ...