🌿📦🅰️ Web Components with Angular Ivy in 6 Steps

The combination of Ivy and Web Components/ Custom Elements allows us to provide framework agnostic components via optimized bundles.

Source Code

This articles shows:

  • 🌿️ Wrapping an Ivy component as a Web Component/ Custom Elements
  • 📦 Producing small and optimized bundles with Ivy and the CLI

Intro and Motivation

The combination of Ivy — the new Angular Compiler — and Web Components/ Custom Elements is very tempting as it allows us to use our existing Angular knowledge to provide framework agnostic components via optimized bundles.

While Angular Elements makes exposing Angular Components as Web Components/ Custom Elements very easy, it does not support Ivy yet. Saying this, we have also to take into account that Ivy is currently in a quite early stage: Beginning with Angular 8 it’s an opt-in so that we can try it out and with version 9 which arrives in fall 2019 it shall be backwards compatible and on by default.

As Angular’s Rob Wormald told us in his talk at ngconf 2019, there are plans to bridge the gap between Ivy and Elements. He also showed us, how the tasks this solution will perform can be done manually today.

In this article, I’m showing in 6 steps how to implement this idea. For this, I have to leverage parts of Ivy that still have been private when writing this. As “Ivy Elements” will automate these steps, I would not consider this to be a final solution. However, many of my customers and workshop attendies want to start already now or at least build a proof of concept. For these situations, the solution outlined here will come in handy.

The example used here is a simple component displaying data about a flight:

The source code I’m using here, can be found in my GitHub account.

Step 1: Create an Angular project with Ivy support

First, we need an Angular project that is configured to use Ivy. Angular CLI 8 which has been available as an release candidate (rc.3) when writing this comes with an --enable-ivy switch for this:

ng new ivy-project --enable-ivy

Basically, this switch adds the following section to your tsconfig.app.json for enabling Ivy:

"angularCompilerOptions": {
    "enableIvy": true
}

To get Ivy during debugging, we need to use the --aot flag when calling ng serve:

ng serve --aot

Step 2: Provide an Angular Component

In this step we provide an ordinary Angular component that will be exposed as a Web Component/ Custom Element later:

import { ɵdetectChanges } from '@angular/core';
[...]

@Component({
  templateUrl: './flight.component.html'
})
export class FlightComponent {

  @Input() flight: Flight;
  @Input() selected: boolean;
  @Output() selectedChange = new EventEmitter<boolean>();
  
  toggle() {
    this.selected = !this.selected;
    this.selectedChange.emit(this.selected);

    // Calling change detection for
    // zone-less change detection
    ɵdetectChanges(this);
  }

}

Perhaps you’ve seen the one aspect that is not usual here: The component is triggering change detection manually.

While the current version of Angular leverages zone.js to find out when to update data bindings, the Angular team is investigating possibilities for making zone.js optional. There are several reasons why this is beneficial: One reason is bundle size. We don’t want to ship 100+ KB of zone.js (10+ KB compressed) alongside a 10 KB date time picker (you can replace data time picker with any custom element you want to provide).

Also, there are issues with zone.js and native async/await which we’ll get when directly compiling to EcmaScript 2017. As most people are down leveling their code to ES5 or ES2015 nowadays, this is currently not a big show stopper. Another aspect you might not like with zone.js is that it does a lot of magic in the background.

To trigger change detection, I’m using the ɵdetectChanges function. You can tell by the starting character ɵ that it is currently still part of Ivy’s private API.

Step 3: Hand-write a Custom Element wrapper for your Angular Component

Now, we can wrap our Angular component as a custom element. Currently, this needs to be done by hand while “Ivy Elements” will automate this task in the future.

To use the Custom Element API provided by almost all modern browsers, we just need to subclass HTMLElement:

export class FlightInfoElement extends HTMLElement {
    [...]
}

Within the element’s constructor, we can make Ivy to render our Angular component:

export class FlightInfoElement extends HTMLElement {
  private comp: FlightComponent;

  constructor() {
    super();
    this.comp = ɵrenderComponent(FlightComponent, { host: this });
  }
  
  [...]
}

For this, this example leverages Ivy’s ɵrenderComponent function which is currently private too. The passed host is the DOM element that will represent the component. In our case the Custom Element itself is the host, hence we pass just this.

Now, we have to connect the component’s properties and events to respective counterparts of the custom element. In the case of our event we need to subscribe for it and raise it again as an event of the custom element:

export class FlightInfoElement extends HTMLElement {
  private comp: FlightComponent;

  constructor() {
    super();
    this.comp = ɵrenderComponent(FlightComponent, { host: this });

    this.comp.selectedChange.subscribe(eventInfo => this.dispatchEvent(new CustomEvent('selected-change', { detail: eventInfo })));
  }
  
  [...]
}

For the properties we have to introduce getters and setters:

get flight(): Flight {
  return this.comp.flight;
}

set flight(flight: Flight) {
  this.comp.flight = flight;
  ɵdetectChanges(this.comp);
}

get selected(): boolean {
  return this.comp.selected;
}

set selected(selected: boolean) {
  this.comp.selected = selected;
  ɵdetectChanges(this.comp);
}

Please note that that setters also trigger change detection by calling ɵdetectChanges.

Finally, we should create attributes for all properties that hold primitive values. For the sake of demonstration, I’ve also done this for the complex flight property:

// Tell the browser to observe these attributes
static get observedAttributes() {
    return ['selected', 'flight'];
}

[...]

// Delegate to respective property when attribute changes
attributeChangedCallback(name, oldValue, newValue) {
    switch (name) {
      case 'selected':
        // string to boolean
        this.selected = newValue === 'true';
        break;
      case 'flight':
        // Normally, complex properties are not mapped
        // to attributes. This is just for demonstration
        // purposes.
        this.flight = JSON.parse(newValue);
        break;
    }
}

Step 4: Register the Custom Element with the browser

To register our Custom Element with the browser, we need to pass it to customElements.define. I’ve placed this code into the main.ts:

// platformBrowserDynamic().bootstrapModule(AppModule)
//   .catch(err => console.error(err));

customElements.define('flight-info', FlightInfoElement);

Also, I’ve commented out the traditional code for bootstrapping the whole Angular application as I just want to provide a custom element. This will result in smaller bundle sizes.

Step 5: Call Custom Element/ Web Component

Now, we can try out our custom element/ web component. For this, just place the following code into your index.html:

<body>
  
  <flight-info id="info" flight='{ "id": 1, "from": "Here", "to": "There", "date": "now"}' selected="true"></flight-info>

  <script>
    const elm = document.getElementById('info');
    elm.addEventListener('selected-change', e => console.debug('event', e));
  </script>

</body>

After starting the development web server (ng serve --aot -o), we should see our web component in action:

Step 6: Compiling your web component

To get an optimized bundle for our consumers, we can now perform a production build:

ng build --prod

If configured right, the CLI will perform differential loading which means it generates ES5 bundles for legacy browsers (we all know of whom I’m speaking about) and ES2015 for modern ones:

To get one self-contained bundle instead of five for each compilation target, you can leverage my CLI extension ngx-build-plus:

ng add ngx-build-plus@^8.0.0-rc
ng build --prod --single-bundle

Please make sure to get the right version of ngx-build-plus fitting to your CLI version. During RC phase, we’ll need to go with the version ^8.0.0-rc as shown here, for instance.

Building our web component with ngx-build-plus looks like this:

More about ngx-build-plus and other options for bundling Angular Elements can be found in my article here.

As you can see, the ES2015 version of the resulted self contained bundle has just 58KB KB. If we compress it with brotli, just 18 KB are left as the extraction of the respective directory listening shows:

18.345 main-es2015.2b1d94d01898fb8c15d7.js.br
19.026 main-es5.ca289b744d789019b9ef.js.br

Further information

Using other components

To call other components, directives, or pipes, make sure they are connected via a module — either directly or indirectly. In my case, I’ve put both, the wrapped FlightComponent and the called TitleComp into the same module:

@NgModule({
  imports: [CommonModule],
  declarations: [FlightComponent, TitleComp]
})
export class FlightModule {
}

Using other services

Unfortunately, the approach outlined in the last section does not work for global services. However, you can use tree-shakable providers instead. As an alternative, you can also register them within @Component(..., providers: [...], ...) or you could pass an respective injector to renderComponent:

The following example shows an injector that registers the HttpClient and its dependencies:

import { Injector } from "@angular/core";
import { HttpHandler, HttpXhrBackend, HttpClient } from '@angular/common/http';

export const injector: Injector = Injector.create({
    name: 'root',
    providers: [
        { provide: HttpClient, deps: [HttpHandler] },
        { provide: HttpHandler, useValue: new HttpXhrBackend({ build: () => new XMLHttpRequest() }) }
    ]
  });

To pass this injector to renderComponent, we use its parameter object:

ɵrenderComponent(FlightComponent, { host: this, injector });

As APIs like HttpClient increase your bundle size, I’d try to use native browser APIs instead for web components. We also have to keep in mind that Ivy cannot help much with such APIs: While it down levels UI-based code to code that is very close to the DOM, it cannot make our APIs disappear.

Not everything works yet

As we are using private APIs, we cannot expect everything to work. E. g. at the time of writing, Angular’s FormsModule does not work together with renderComponent.

Conclusion

One big advantage of using Ivy is the relatively small bundle size we get although the web component bases upon Angular. Angular Elements for Ivy will automate some of the manual steps that have been performed here. Because of this and because of the usage of APIs that are still private, the solution presented is rather for early adopters who need to get started now.

Aktuelle News aus
unserer Welt!

Nur einen Schritt entfernt!

Stellen Sie noch heute Ihre Anfrage,
wir beraten Sie gerne!

Jetzt anfragen!