DEV Community

icyerasor
icyerasor

Posted on

Angular Elements - Modularized

Angular-webcomp-elements?

So you want to start using web components, mainly custom elements with angular (10) == Angular Elements. If you don't, you might check out this video and book by Michael Geers. There's a boatload of tutorials out there explaining how to do this, including still targeting ES5 as a compilation target (to support that still not completely dead IE5 👿).

Getting started 👨‍💻

The tutorials usually end with something like..

...and then you concatenate the outputs into a single file, include the resulting awesome-element.js in an arbitrary index.html, use your custom -Tag et voilà: custom elements FTW 🎉.

You follow the steps to create a component within your existing project and everything works.
The next thing you might be wondering however is if that awesome-element.js file really needs to be 8-10 MB(!), even though it just renders a simple button 😲:

icyerasor:~$ ng build
icyerasor:~$ ls *.{js,html} -alh
total 33M
-rw-r--r-- 1 icyerasor 1049089 9,4M Aug  6 17:20 awesome-element.js
-rw-r--r-- 1 icyerasor 1049089  581 Aug  6 17:20 index.html
-rw-r--r-- 1 icyerasor 1049089 294K Aug  6 17:20 main.js
-rw-r--r-- 1 icyerasor 1049089 358K Aug  6 17:20 polyfills.js
-rw-r--r-- 1 icyerasor 1049089 664K Aug  6 17:20 polyfills-es5.js
-rw-r--r-- 1 icyerasor 1049089 6,2K Aug  6 17:20 runtime.js
-rw-r--r-- 1 icyerasor 1049089 715K Aug  6 17:20 styles.js
-rw-r--r-- 1 icyerasor 1049089 8,1M Aug  6 17:20 vendor.js

Ivy, tree-shake 🍁 to the rescue?

Okay, you remember that was just a dev-build. So you add --prod and end up with maybe 1.5 MB (still way too much 😒) but kind of doable (for an internal PoC / first draft) working solution:

icyerasor:~$ ng build --prod
icyerasor:~$ ls *.{js,html} -alh
total 3,4M
-rw-r--r-- 1 icyerasor 1049089 1,5M Aug  6 17:25 awesome-element.js
-rw-r--r-- 1 icyerasor 1049089  544 Aug  6 17:25 index.html
-rw-r--r-- 1 icyerasor 1049089 1,4M Aug  6 17:25 main.js
-rw-r--r-- 1 icyerasor 1049089 102K Aug  6 17:25 polyfills.js
-rw-r--r-- 1 icyerasor 1049089 160K Aug  6 17:25 polyfills-es5.js
-rw-r--r-- 1 icyerasor 1049089 1,5K Aug  6 17:25 runtime.js

At some point you need to really make that custom element production ready however.

A small side-note here: If you're not yet using Angular 9/10/+ and thus probably not the Ivy compile / rendering pipeline - I highly recommend you to upgrade to it, as it greatly reduces build sizes by tree-shaking.

Why u no use them custom element everywhere 🤷?

So you wonder how you could only put the really necessary parts of your application into that custom element. The solution I came up with for our project at iteratec is to move the parts of the custom element into an angular module. It works for me, but your mileage may vary.

Another solution is obviously to define the awesome-element in a separate angular project and simply use it as a custom element (through the html tag) in your main project (too). I didn't want to do this if possible, to reduce the number of projects and artifacts we have to deal with. Furthermore for my current project a custom element always has a primary application to which it belongs natively, while other applications use it sparely. Extracting the custom element into its own project and re-including it, probably also means you have the overhead of shared common libs not only in the secondary/consuming projects, but also in the primary project. There are solutions to tackle this overhead by putting shared libs into the global scope, but this is an optimization I won't go into any further here.

Modules 📦 to the rescue?

Without changing anything, your awesome-element.js contains not only the parts needed for your custom element, but everything your application does.
So my goal was to compile only the absolutely needed parts into an includable awesome-element.js for the custom element.

The part you're here for 😌

Steps to build the awesome-element.js that contains only your custom element "module":

  1. You obviously need multiple modules. So not only app.module.ts but another one that contains similar model configuration stuff, i.e. called app-elements.module.ts. It should only include the dependencies needed for your custom element to do its work. It might look something like this:
import {BrowserModule} from '@angular/platform-browser';
import {CUSTOM_ELEMENTS_SCHEMA, Injector, NgModule} from '@angular/core';
import {createCustomElement} from "@angular/elements";
import {AwesomeIconComponent} from './awesome-icon/awesome-icon.component';


@NgModule({
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  declarations: [
    AwesomeIconComponent
  ],
  imports: [
    BrowserModule,
  ],
  providers: [],
  exports: [AwesomeIconComponent]
})

export class AppElementsModule {
  constructor(private injector: Injector) {
    const awesomeIconComponent = createCustomElement(AwesomeIconComponent, {injector});
    customElements.define('awesome-icon', awesomeIconComponent);
  }

  ngDoBootstrap() {
  }
}
  1. Accordingly you'll need another main.ts file, referencing the module. I.e. called main-elements.ts. You import the specific module there and bootstrap it.
import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';

import { AppElementsModule } from './app/app-elements.module';
import { environment } from './environments/environment';

if (environment.production) {
  enableProdMode();
}

platformBrowserDynamic()
    .bootstrapModule(AppElementsModule)
    .then(success => console.log(`Bootstrap success`))
    .catch(err => console.error(err));
  1. Same for tsconfig.app.json -> tsconfig.app-elements.json. There you'll reference your main-elements.ts file instead of the default main.ts.
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./out-tsc/app",
    "types": []
  },
  "files": [
    "src/main-elements.ts",
    "src/polyfills.ts"
  ],
  "include": [
    "src/**/*.d.ts"
  ]
}
  1. Next, in your angular.json file, you can extend the project -> architect -> build section by adding something like this:
"configurations": {
   "extension": {
     "main": "src/main-elements.ts",
     "tsConfig": "tsconfig.app-elements.json",
     "outputPath": "../resources/public/elements"
   }
   ...
}
  1. finally within your package.json you add a new script
"build:elements": "ng build && ng build --output-hashing none --configuration elements && node concatenate.js"

Calling npm run build:elements-prod will build the regular app (incudling your custom element), and then build the app again, but this time use the elements configuration, that outputs its results to a different location. By using the elements configuration the build references the following: tsconfig.app-elements.json -> main-elements.ts -> app-elements.module.ts and thus only your desired module. Finally the files are joined into a single one through the concatenate call.

The concatenate.js being something along the lines of..:

const fs = require('fs-extra');
const concat = require('concat');
(async function build() {

  var files = [
    '../resources/public/elements/vendor.js',
    '../resources/public/elements/polyfills-es5.js',
    '../resources/public/elements/polyfills.js',
    '../resources/public/elements/runtime.js',
    '../resources/public/elements/main.js',
  ];

  // vendor.js is not present in --prod builds
  files = files.filter(function (value, index, arr) {
    return fs.pathExistsSync(value);
  });

  await fs.ensureDir('../resources/public/elements');
  await concat(files, '../resources/public/elements/awesome-element.js');

})();

The results for me with this approach are:

icyerasor:~$ npm run build:elements-prod
icyerasor:~$ ls *.{js,html} -alh
total 985K
-rw-r--r-- 1 icyerasor 1049089 395K Aug  7 09:02 awesome-element.js
-rw-r--r-- 1 icyerasor 1049089  544 Aug  7 09:02 index.html
-rw-r--r-- 1 icyerasor 1049089 132K Aug  7 09:02 main.js
-rw-r--r-- 1 icyerasor 1049089 102K Aug  7 09:02 polyfills.js
-rw-r--r-- 1 icyerasor 1049089 160K Aug  7 09:02 polyfills-es5.js
-rw-r--r-- 1 icyerasor 1049089 1,5K Aug  7 09:02 runtime.js

395kB might be still a lot for a single custom element that basically does nothing, but is a great improvement down from 1.5MB.

Improvements 🔭

Haven't looked into those in detail yet, but probably will in the next weeks:

  • Put the custom element in an angular library?
  • Use ngx-build-plus which greatly simplyfies configuring Angular elements (polyfills, single-bundle concatenation).
  • as noted above: putting shared libs into the global scope would greatly improve the awesome-element.js size

Top comments (1)

Collapse
 
patelvimal profile image
vimal patel

In Angular 10 this works perfectly, however in Angular 11 these custom elements are giving runtime error "Cannot read property 'get' of undefined" I am using aot compilation.