Angular Elements - Modularized
Published: 2020-08-06 15:32:41

### Angular-webcomp-elements?
So you want to start using [web components](https://www.webcomponents.org/), mainly [custom elements](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements) with angular (10) == [Angular Elements](https://angular.io/guide/elements). If you don't, you might check out this [video](https://noti.st/naltatis/zQb2m5) and [book](https://livebook.manning.com/book/micro-frontends-in-action/welcome/v-4/0) by [Michael Geers](https://twitter.com/naltatis). There's a [boatload](https://blog.bitsrc.io/using-angular-elements-why-and-how-part-1-35f7fd4f0457) of [tutorials](https://medium.com/@jorgecasar/how-to-use-web-components-with-angular-41412f0bced8) out [there](https://dev.to/prestonjlamb/getting-started-with-angular-elements-3kj3) explaining how to do this, [including](https://www.npmjs.com/package/@webcomponents/custom-elements) 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 <awesome-element>-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 😲:
```console
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:

```console
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](https://angular.io/guide/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](https://twitter.com/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:

```typescript
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() {
  }
}
```

2. 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.
```typescript
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));
```


3. Same for `tsconfig.app.json` -> `tsconfig.app-elements.json`. There you'll reference your `main-elements.ts` file instead of the default `main.ts`.
```typescript
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./out-tsc/app",
    "types": []
  },
  "files": [
    "src/main-elements.ts",
    "src/polyfills.ts"
  ],
  "include": [
    "src/**/*.d.ts"
  ]
}
```


4. Next, in your `angular.json` file, you can extend the project -> architect -> build section by adding something like this:
```javascript
"configurations": {
   "extension": {
     "main": "src/main-elements.ts",
     "tsConfig": "tsconfig.app-elements.json",
     "outputPath": "../resources/public/elements"
   }
   ...
}
```

5. finally within your `package.json` you add a new script
```javascript
"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..:
```javascript
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:

```console
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](https://angular.io/guide/creating-libraries)?
 * Use [ngx-build-plus](https://github.com/manfredsteyer/ngx-build-plus#readme) 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