Your options for building Angular Elements

with the CLI



Contents

In this article you will learn:

  • 📦 How to provide a single bundle for your Angular Elements
  • 🔼 How to use polyfills for legacy browsers
  • 🌿 How Ivy can help with bundle sizes (once it’s released) and where it cannot
  • 🔀 How differential serving can help (not only) with Angular Elements
  • 🍰 How to share dependencies between separately compiled Angular Elements

Source Code

Currently, Angular Elements officially supports exposing Angular Components as Web Components — or more precisely: as Custom Elements — within Angular projects. Upcoming versions will very likely also support exporting Web Components which can be used with other frameworks or VanillaJS. I’m using the term external web component for referring to this.

In this article, I provide several strategies you can use to provide external web components already today. Some of them will definitely benefit from the introduction of Ivy in some months and some of them address different aspects.

One more time, I want to thank Rob Wormald — the father of and master mind behind Angular Elements — for discussions that led to some of those inofficial solutions.

Initial Situation

The example used here is a variation of the dashboard tile component from my introduction to Angular Elements:

It can be found here and consists of a CLI workspace with two projects. One of them called dashboard-tile exposes a simple dashboard tile as an external component:

External Web Component

The code behind it is quite simple:

@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-${this.src}.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(); } }

 

In order to provide this component as a custom element when the Angular application starts up, the respective code is placed in the AppModule‘s ngDoBootstrap method:

@NgModule({ imports: [ HttpClientModule, BrowserModule ], declarations: [ ExternalDashboardTileComponent ], bootstrap: [], entryComponents: [ ExternalDashboardTileComponent ] }) export class AppModule { constructor(private ij: Injector) { } ngDoBootstrap() { const externalTileCE = createCustomElement(ExternalDashboardTileComponent, {injector:this.ij}); customElements.define(‘external-dashboard-tile’, externalTileCE); } }

 

Here, ngDoBootstrap is needed because the application does not have a bootstrap component. This is because I don’t want to bootstrap an ordinary Angular component but just register a custom element with the browser.

In theory, you should be able to call the web component directly within the index.html after exposing it that way.

<external-dashboard-tile src=”1″></external-dashboard-tile>

 

In practice, you get the following error when trying this out with the starter branch of the provided source code:

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

This is because Custom Elements are to be used with EcmaScript 2015 and above by definition. However, in order to support legacy browsers like Internet Explorer, TypeScript has to downlevel it to EcmaScript 5. Although this might change in the future, currently EcmaScript 5 is the default setting when creating an Angular project with the CLI. To tweak this, you can set the property target in your tsconfig.json to ES5.

Of course, this solution is not doable if you are in the unfortunate situation where you have to support Internet Explorer. A lot of my customers face this as a company policy and hence, they have to find a way to at least officially run it in this browser of Microsoft’s former days.

In this case, we need to go with two polyfills: One polyfill enables web components in browsers that DO NOT support custom elements; the other one enables using them together with EcmaScript 5 in browsers that DO support them.

Polyfills

In order to also support old browsers, I’ve decided to go with the polyfills in the @webcomponents/webcomponentsjs package. To load them with respect to the browser’s capabilities, I’ve copied them over to the assets folder and referenced them at the end of the polyfills.ts file in a classic way:

// If the browser supports Custom Elements we need this // if we downlevel to EcmaScript 5 (CE are define for ES2015+) if (window[‘customElements’]) { const script = document.createElement(‘script’); script.src = ‘./assets/webcomponentsjs/custom-elements-es5-adapter.js’; document.writeln(script.outerHTML); } if (!window[‘customElements’]) { const script = document.createElement(‘script’); script.src = ‘./assets/webcomponentsjs/bundles/webcomponents-sd-ce.js’; document.writeln(script.outerHTML); }

 

To automate this cumbersome task, I’ve written a schematic which is part of my community project ngx-build-plus. To install it, use ng add:

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

 

After that you can install the polyfills with an included schematic:

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

 

The called schematic creates an npm script which copies over the polyfills and executes it. It also updates the index.html with one of the above shown script tags. The other one goes to the end of your polyfills.ts file because it needs some other polyfills that are included there too.

After starting the solution (npm start) you should see something like this in Chrome:

To make this work with Internet Explorer, we also have to uncomment the imports for the respective polyfills in the polyfill.ts:

[…] /** IE9, IE10 and IE11 requires all of the following polyfills. **/ import ‘core-js/es6/symbol’; import ‘core-js/es6/object’; import ‘core-js/es6/function’; import ‘core-js/es6/parse-int’; import ‘core-js/es6/parse-float’; import ‘core-js/es6/number’; import ‘core-js/es6/math’; import ‘core-js/es6/string’; import ‘core-js/es6/date’; import ‘core-js/es6/array’; import ‘core-js/es6/regexp’; import ‘core-js/es6/map’; import ‘core-js/es6/weak-map’; import ‘core-js/es6/set’; […]

 

If you also need support for animations, you have to remove some additional comments and install additional npm packages. Just follow the information provided by the comments in the polyfills.ts file.

Please note, that you need to reference the CUSTOM_ELEMENTS_SCHEMA in your respective modules if you want to use a custom element within an Angular Component:

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

 

Angular Elements comes with an alternative polyfill that is registered within your angular.json when installing it with ng add @angular/elements. This one is far more lightweight than the one I’m using here. However, it just can be used with browsers supporting EcmaScript 2015 and above. Hence, when you don’t need to target Internet Explorer, this one should be prefered.

Build

Now, let’s create a bundle for our web component using ng build:

ng build –prod

 

This gives us 4 (!) bundles:

While this is ok for an ordinary SPA, it’s far too much for a simple web component. In our case, having just one self-contained bundle would be better.

My above mentioned community project ngx-build-plus provides a simple solution for this with its --single-bundle switch:

ng build –prod –single-bundle

 

After running this, we get one and only one bundle as wished:

An alternative to --single-bundle you see sometimes is manually copying the four bundles into one file. Unfortunately, this does not work if you have more than one such meta-bundle. The reason is that webpack is exposing a global variable and this would get overwritten when using several such bundles that have been compiled separately.

When you look at the bundle sizes, you immediately realize that they are far to huge for such a simple web component. That’s because they include Angular, RxJS and other libs — at least the parts of it that have not been tree-shaken off. It’s even worse: If you compile several bundles separately, each of them get a copy of those libraries:

This is where Ivy comes in.

Ivy

Beginning with Angular 8 we will get the new Ivy compiler. In this version, it will be hidden behind a flag. It makes Angular more tree-shakable and compiles the UI part of components down to code which is quite close to the DOM. For this reason, typical web components will benefit a lot from Ivy and the resulting bundle won’t need much of Angular.

In the best case, two separately generated bundles with Angular Elements will look like this:

They just contain their component code and a very tiny remainder of Angular which acts as the runtime. As mentioned: in the best case!

However, while Ivy has a lot of potential, we should not expect wonders like Minko Gechev who is now part of the Angular Team told us at 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

Especially, if our components contain lots of libraries besides UI code, Ivy will not help much. Or to put it in another way: It cannot make the used parts of things like @angular/forms or @angular/common/http disappear.

In this case, we very likely need to find a way to share such dependencies among separately built bundles. This leads to an idea presented in one of the following sections. But first, let’s talk about a quick win which is called differential serving.

Differential Serving

Something which is still annoying is the fact, we need a polyfill even for browsers that DO support custom elements if we want to support ES5-browsers like Internet Explorer. This issue can be solved with differential serving. That means, we are creating two sets of bundles: One set is EcmaScript 2015+ based and indented for modern browsers and the other one is EcmaScript 5 based and loaded into Internet Explorer.

By doing this, we can also ship more optimized bundles to the modern browsers: They don’t need to contain all the polyfilly and they don’t need to be downleveled to ES5 which makes them smaller.

If you have implemented the externals idea described in the two previous sections, switch now back to your former branch. Externals and Differential Serving cannot be used together for now.

In some future version, the Angular CLI will very likely support differential serving and beginning with Angular CLI 7.3 it already supports conditional polyfill loading. Until it fully supports differential serving, we can use my community project ngx-build-modern which is nothing else than a plugin for ngx-build-plus:

ng add ngx-build-modern –project dashboard-tile

 

After adding it with ng add, we get a polyfills.modern.ts file alongside the already known polyfills.ts. As the name implies, the former one will be used for modern browsers and the existing one will be used for legacy products like Internet Explorer.

Now, we can finally remove the polyfill for browsers that DO support web components as they get now EcmaScript 2015+ served.

Unfortunately, when debugging we currently get only ES 5, so the following hack is necessary. I’ve placed it at the end of my polyfills.ts before loading the other web component polyfills:

if (window[‘customElements’] && !environment.production) { document.write(‘<script src=”/assets/custom-elements/src/native-shim.js”></script>’) }

 

Also, for modern (ES2015+) browsers which do not support Web Components yet (like Edge), we need to load the polyfills in the index.html file:

<script> if (!window[‘customElements’] && “noModule” in HTMLScriptElement.prototype) { const script = document.createElement(‘script’); script.src = ‘./assets/webcomponentsjs/bundles/webcomponents-sd-ce.js’; document.writeln(script.outerHTML); } </script>

 

To build everything, switch to your project’s root and run the following npm script created by the ngx-build-modern schematic before:

npm run build:modern

 

If you switch to the folder dist/dashboard-tile you should see two sets of bundles:

As you see here, the modern bundles are about 88 KB smaller. They contain less polyfills and are more compact due to not having the need of downleveling to ES5. In general you will see: the bigger the project, the bigger the difference between modern and legacy bundles.

The index.html contains the necessary markup to load the right set of bundles. For loading modern bundles it uses this pattern:

<script type=“module” src=“[…].modern.ec2944dd8b20ec099bf3.js”></script>

 

Because of using type="module" this is only respected by modern browsers.

The bundles for legacy browsers are loaded that way:

<script type=“text/javascript” src=“main.legacy.d9ef9dc1b2c49e5eb049.js” nomodule=“”>

 

The nomodule attribute makes modern browsers to ignore it.

To test your solution, run a local web server within the folder with the built application, e. g. live-server which can be installed via npm (npm i -g live-server).

Sharing Libraries

This and the next section describe a quite advanced and inofficial process to share libraries between separately compiled Angular Elements. Make sure you really need this solution before implementing it.

In order to share libraries like @angular/common/http which is used in our above shown Angular Element, we could load them into the browser’s global scope and reuse them in our web component bundles:

This is something that was quite usual some years ago. Think about using jQuery. We needed to load jQuery and jQuery UI once and the bundles with our jQuery widgets just referenced them.

However, Angular projects are normally built into several bundles that only know each other and other bundles cannot easily access their code.

To solve this issue, Angular’s Rob Wormald came up with a interesting idea: Let’s tweak the build process so that the generated bundles expect the shared libraries not to be part of them but are located within the global scope. In order to make this possible, we need to find a way to put Angular and its dependencies there.

Fortunately, the Angular packages format prescribes to expose Angular libraries also as UMD bundles and they do this job. In the case of Angular itself, they register themselves at window.ng.core, window.ng.common, etc.

This involves a lot of manual steps I’ve automated with another schematic. Because this changes a lot of things, I’ve created an own branch for this in my demo project. It’s called externals.

If you want to try it out by yourself, I would also branch the starter branch for this:

git branch -c demo

 

Than, call this command:

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

 

To compile everything, use this npm script generated by ngx-build-plus:

npm run build:dashboard-tile:externals

 

After this, you can switch to your dist folder and try out your solution:

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

 

If you want to prepare an Angular application that hosts such a custom element, you can use the --host switch. E. g. the demo project contains a playground-app which could be prepared using

ng g ngx-build-plus:externals –project playground-app –host

 

You can find such an application in the externals branch.

This command is more or less doing the same as the command without --host. However, it does not switch to a single bundle or turn off hashes in file names.

There is also an npm script copy:ce which copies over the dashboard-tile bundle to the playground-app where it can be dynamically loaded.

To dynamically load the dashboard tile, it creates a script tag at runtime.

Behind the covers

Now, let’s talk about what happened here. The schematic we’ve executed created a partial webpack configuration, which defines where the shared libraries can be found within 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”, “@angular/router”: “ng.router”, “@angular/forms”: “ng.forms” } }

 

If you npm install a newer version of Angular, just run the following script it also inserted into your package.json:

npx-build-plus:copy-assets

 

In addition, the schematic also copied over a lot of UMD bundles into the assets folder and it referenced them within the index.html:

<!– core-js for legacy browsers Consider only loading for IE –> <script src=“./assets/core-js/core.js”></script> <!– Zone.js Consider excluding zone.js when creating custom Elements by using the noop zone. If you load zone.js with an additional bundle, delete this line. –> <script src=“./assets/zone.js/zone.js”></script> <!– Rx –> <script src=“./assets/rxjs/rxjs.umd.js”></script> <!– Angular Packages –> <script src=“./assets/core/bundles/core.umd.js”></script> <script src=“./assets/common/bundles/common.umd.js”></script> <script src=“./assets/common/bundles/common-http.umd.js”></script> <script src=“./assets/elements/bundles/elements.umd.js”></script> <!– Just needed for prod mode –> <script src=“./assets/platform-browser/bundles/platform-browser.umd.js”></script>

 

As an alternative, you can also consider to put those UMD-bundles into one or several meta-bundles.

Conclusion

There are several strategies for building web components and they differ from those normally used for building full blown SPAs. Ivy will help a lot with reducing the bundle sizes if your project mainly contains UI code. Besides this, it also improves tree-shakability in general. For sharing libraries you can use externals. The community project ngx-build-plus helps with this and with creating a single bundle. It also helps with installing polyfills for legacy browsers. In addition, differential serving makes sure that only browsers which needs the polyfills get them. It also makes sure that modern browsers get smaller and more optimized EcmaScript 2015+ bundles.

Aktuelle News aus
unserer Welt!

Nur einen Schritt entfernt!

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

Jetzt anfragen!