Starting Point

The demonstration project for this discussion is a variation of the dashboard tile component featured in my earlier Angular Elements introduction.

The code is available in a repository containing a CLI workspace with two distinct projects. One project, named dashboard-tile, demonstrates how to expose a straightforward dashboard tile as an external component:

External Web Component

The underlying implementation is straightforward:

@Component({ // selector: 'app-external-dashboard-tile', templateUrl: './external-dashboard-tile.component.html', styleUrls: ['./external-dashboard-tile.component.css'] }) export class ExternalDashboardTileComponent implements OnInit { @Input() src: number = 1; a: number; b: number; c: number; constructor(private http: HttpClient) { } ngOnInit(): void { this.load(); } load() { this.http.get(/assets/stats-<span class="hljs-subst">${this.src}</span>.json).subscribe( data => { this.a = data['a']; this.b = data['b']; this.c = data['c']; } ); } more() { this.src++; if (this.src > 3) { this.src = 1; } this.load(); } }

To register this component as a custom element during application startup, the necessary logic resides in the AppModule's ngDoBootstrap method:

@NgModule({ imports: [ HttpClientModule, BrowserModule ], 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); } }

The use of ngDoBootstrap is necessary here because there's no bootstrap component defined. Instead of initializing a conventional Angular component, we're simply registering a custom element with the browser.

After this setup, you'd expect to be able to use the web component directly in the index.html file.

<external-dashboard-tile src="1"></external-dashboard-tile>

However, attempting this with the starter branch of the provided source code produces the following error:

Failed to construct 'HTMLElement': Please use the 'new' operator, this DOM object constructor cannot be called as a function.

Your options for building Angular Elements — figure 2

This error occurs when the compilation target is set to EcmaScript 5, typically done to support older browsers such as Internet Explorer 11. The root cause is that Custom Elements are designed to work with EcmaScript 2015 and newer versions.

Consequently, two distinct polyfills are required: one for modern browsers that anticipate EcmaScript 2015+ Custom Elements, and another for legacy browsers like Internet Explorer 11.

Differential loading, a feature introduced in Angular CLI 8, enables the simultaneous creation of EcmaScript 5 and EcmaScript 2015 bundles. More details on this approach will be covered in a subsequent section.

Polyfill Strategies

To provide support for older browsers, I've opted for the polyfills available in the @webcomponents/webcomponentsjs package. These are loaded by adding them as script entries in the angular.json file:

[...], "scripts": [ { "bundleName": "polyfill-webcomp-es5", "input": "node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js" }, { "bundleName": "polyfill-webcomp", "input": "node_modules/@webcomponents/webcomponentsjs/bundles/webcomponents-sd-ce-pf.js" } ], [...]

If your compilation target is es2015 or higher, the first script can be excluded.

When the build runs, this configuration generates two additional bundles: polyfill-webcomp-es5.js and polyfill-webcomp.

Streamlining Polyfill Setup

To eliminate the manual effort of adding polyfills, I've developed a schematic included in my community project called ngx-build-plus. Installation is done via ng add:

ng add ngx-build-plus --project dashboard-tile

Following installation, an included schematic handles the polyfill setup:

ng g ngx-build-plus:wc-polyfill --project dashboard-tile

It's important to remember that if you intend to use a custom element within an Angular Component, you must reference the CUSTOM_ELEMENTS_SCHEMA in the relevant modules:

@NgModule({ declarations: [ AppComponent ], imports: [ BrowserModule ], providers: [], schemas: [CUSTOM_ELEMENTS_SCHEMA], bootstrap: [AppComponent] }) export class AppModule { }

After launching the solution with npm start, Chrome should display something similar to this:

Your options for building Angular Elements — figure 3

Angular Elements provides an alternative, more lightweight polyfill that gets registered in your angular.json when you install it via ng add @angular/elements. This option, however, is only suitable for browsers that support EcmaScript 2015 and newer. Therefore, if Internet Explorer support isn't a requirement, this lighter polyfill is the recommended choice.

Implementing Differential Loading

To take advantage of differential loading, set your tsconfig.json to target EcmaScript 2015+ and confirm that the browserslist file in your workspace root includes at least one EcmaScript 5 browser (which is typically the case). This setup prompts the CLI to produce two bundle versions: one for EcmaScript 5 and another for EcmaScript 2015.

With newer browsers now receiving EcmaScript 2015 code, the custom-elements-es5-adapter.js script mentioned earlier becomes unnecessary.

Bundle Creation

Now, we'll generate a bundle for our web component using the standard build command:

ng build --prod

This process results in multiple (!) bundles:

Your options for building Angular Elements — figure 4

While such an output is fine for a typical single-page application, it's excessive for a simple web component. In our scenario, a single, self-contained bundle would be far more suitable.

My previously mentioned ngx-build-plus project offers a convenient workaround with its --single-bundle option:

ng build --prod --single-bundle

Executing this command yields a single bundle named main, instead of separate main, vendor, and runtime files:

Your options for building Angular Elements — figure 5

The most recent version of ngx-build-plus continues to generate polyfills, styles, and scripts as well. These are useful for testing, but you'd typically exclude them from distribution since the consuming application likely has its own versions.

A commonly seen alternative to --single-bundle involves manually merging the four bundles into a single file. This approach, however, fails when multiple such meta-bundles are involved. The issue stems from webpack's use of a global variable, which gets overwritten when several separately compiled bundles are used together.

Examining the bundle sizes reveals they're far too large for such a simple web component. This is due to the inclusion of Angular, RxJS, and other libraries—or at least the portions that escaped tree-shaking. Moreover, compiling multiple bundles separately means each one carries its own copy of these libraries:

Your options for building Angular Elements — figure 6

This is precisely where Ivy becomes relevant.

The Role of Ivy

Starting with Angular 9, the new Ivy compiler becomes the default. It enhances Angular's tree-shakability and compiles the UI portion of components into code that closely resembles direct DOM operations. As a result, typical web components stand to benefit significantly from Ivy, requiring only a minimal part of Angular in their final bundles.

Under ideal circumstances, two independently generated Angular Elements bundles would resemble this:

Your options for building Angular Elements — figure 7

They would contain solely their component code and a very small Angular runtime remnant. As stated, this is the best-case scenario!

However, despite Ivy's considerable potential, we shouldn't anticipate miracles. Minko Gechev, now a member of the Angular Team, offered this perspective on Twitter:

Ivy will enable new features in Angular, which will come gradually, and it may reduce your app size but do not expect wonders - it will not make your JS disappear. I'd strongly recommend to not wait for ivy but instead, shrink JavaScript bundles today https://angular.io/guide/lazy-loading-ngmodules

Specifically, if your components rely heavily on libraries beyond the UI code, Ivy offers limited assistance. To put it differently, it can't eliminate the portions of packages like @angular/forms or @angular/common/http that you're using.

In such situations, it becomes necessary to find a method for sharing these dependencies among separately built bundles. This leads us to an approach discussed in the next section.

Sharing Library Dependencies

To share libraries like @angular/common/http, which is used in our example Angular Element, we could load them into the website's global scope and subsequently reference them within our web component bundles:

Your options for building Angular Elements — figure 8

This pattern was commonplace in the past. Consider the jQuery era: you'd load jQuery and jQuery UI once, and your jQuery widget bundles would simply reference them.

Yet, Angular projects are typically built into several bundles that are only aware of each other; external code can't easily access their contents.

To address this, Rob Wormald from the Angular team came up with an innovative idea: modify the build process so that the resulting bundles expect shared libraries in the global scope rather than embedding them. Making this possible requires a mechanism to place Angular and its dependencies there.

Fortunately, the Angular package format mandates that Angular libraries also be exposed as UMD bundles. These bundles handle the registration under window.ng.core, window.ng.common, and so on.

Since this process involves many manual steps, I've automated it with another schematic:

ng g ngx-build-plus:externals --project dashboard-tile

To compile the entire solution, use the npm script that ngx-build-plus generated:

npm run build:dashboard-tile:externals

After compilation, navigate to your dist directory and test the result:

npm i -g live-server cd dist cd dashboard-tile live-server

Understanding the Mechanism

Let's delve into what just occurred. The schematic we ran created a partial webpack configuration that specifies where shared libraries can be located on the browser's window object.

const webpack = require('webpack'); module.exports = { "externals": { "rxjs": "rxjs", "@angular/core": "ng.core", "@angular/common": "ng.common", "@angular/common/http": "ng.common.http", "@angular/platform-browser": "ng.platformBrowser", "@angular/platform-browser-dynamic": "ng.platformBrowserDynamic", "@angular/compiler": "ng.compiler", "@angular/elements": "ng.elements", // Uncomment and add to scripts in angular.json if needed // "@angular/router": "ng.router", // "@angular/forms": "ng.forms" } }

During compilation, the CLI deliberately excludes these dependencies from your bundles. Instead, it generates references to them, pointing to locations like window.ng.core or window.ng.common.

To ensure Angular and RxJS are available on the window object, the schematic also adds references to the appropriate UMD bundles in the scripts section of angular.json:

"scripts": [ [...] "node_modules/rxjs/bundles/rxjs.umd.js", "node_modules/@angular/core/bundles/core.umd.js", "node_modules/@angular/common/bundles/common.umd.js", "node_modules/@angular/common/bundles/common-http.umd.js", "node_modules/@angular/compiler/bundles/compiler.umd.js", "node_modules/@angular/elements/bundles/elements.umd.js", "node_modules/@angular/platform-browser/bundles/platform-browser.umd.js", "node_modules/@angular/platform-browser-dynamic/bundles/platform-browser-dynamic.umd.js" ]

Concluding Thoughts

Building web components requires a different set of strategies compared to constructing full-scale SPAs. Ivy will significantly reduce bundle sizes when your project consists primarily of UI code, while also improving overall tree-shakability.

For dependency sharing, the concept of externals proves useful. The ngx-build-plus community project facilitates this along with creating a unified bundle and installing polyfills for older browsers.

Furthermore, differential loading ensures that only browsers requiring polyfills receive them, while modern browsers benefit from leaner, more optimized EcmaScript 2015+ bundles.