loading...
This Dot

Angular Elements: Why?

flakolefluk profile image Ignacio Le Fluk ・8 min read

In this post, I'll answer the same questions that I had when I started using Angular Elements.

First of all, what are Angular Elements?

Angular Elements are regular Angular Components that have been packaged as Custom Elements. Custom Elements are part of Web Components and allow us to create new HTML elements, that can be used directly on our page, or by using a polyfill if they are not supported by the browser.

There are two types of custom elements:

  • Customized built-in elements. They inherit from other basic HTML elements (<p>, <span>, etc)
  • Autonomous custom elements, which are standalone and don't inherit from other HTML elements.

Support for these types of elements vary from browser to browser, but autonomous custom elements have broader support.

Browsers support for custom elements

How do custom elements work?

To register a custom element on the page, we must use the CustomElementRegistry.define() method.

customElements.define('my-element', MyElement);

The first parameter is a string that defines the tag that will be used for the element. The custom element's specifications require that the element name contain a dash. The second parameter is a class that contains the behavior of the new element. There's an optional third parameter, required when creating a customized built-in element, that declares which element we are extending.

Now we should be able to use the tag <my-element></my-element>.

Angular elements will take our Angular Component, and package it in a class that can be used by the define() method. That's it!

...

Not really.

Let's take a deeper look at how to generate and use these custom elements in different contexts.

Angular elements in an angular application.

We'll create a new project.

ng new my-app
cd my-app

In order to use Angular Elements, we need to add the @angular/elements library.

ng add @angular/elements

Now let's generate a component. (The source component for the custom element)

ng g c awesome-element

To generate and use our custom element in our application, we need to do a few steps:

  1. Add our component to the entryComponents list of our module
  2. Use the createAngularElement method of @angular/elements to create a custom element class based on the angular component
  3. Register the custom element with the browser
  4. Add the CUSTOM_ELEMENTS_SCHEMA to tell Angular that we will be using tags that it will not recognize because they are custom elements.

Our AppModule will look like this.

// ...
import { BrowserModule } from '@angular/platform-browser';
import { NgModule, CUSTOM_ELEMENTS_SCHEMA, Injector } from '@angular/core';
import { createCustomElement } from '@angular/elements';
// ...

@NgModule({
  declarations: [AppComponent, AwesomeElementComponent],
  imports: [BrowserModule],
  entryComponents: [AwesomeElementComponent], // (1)
  bootstrap: [AppComponent],
  schemas: [CUSTOM_ELEMENTS_SCHEMA], // (4)
})
export class AppModule {
  constructor(public injector: Injector) { 
    const el = createCustomElement(AwesomeElementComponent, { injector }); //(2)
    customElements.define('my-component', el); //(3)
  }
}

Now, we can use the element using the selector of the define method (not the one in the component), just like any other HTML tag.

<my-component></my-component>

One important thing to be aware of is that custom elements require that you use es6 classes. If you are targeting es5, you will need to use polyfills.

There are useful polyfills that can help you if you are targeting older browsers or es5.

Browser support with polyfills

These polyfills can be added to the polyfills.ts file of your project or loaded from a CDN.

Mapping inputs and outputs

We created a simple component that displays a simple message, but we might need to add inputs and outputs. We don't need to do anything different for our custom element that we would normally do with an Angular Component. Angular elements will be in charge of creating a bridge between component inputs and custom element attributes. Because attributes won't differentiate between upper and lowercase, the input name will be converted to a dash-separated lowercase name. Outputs will become HTML custom events, preserving its name, unless an alias is set, which will be used instead.

awesome-element.component.ts

import { Component, OnInit, EventEmitter, Input, Output } from '@angular/core';

@Component({
  selector: 'app-awesome-element',
  templateUrl: './awesome-element.component.html',
  styleUrls: ['./awesome-element.component.css'],
})
export class AwesomeElementComponent implements OnInit {
  @Input() myInput = ''; //mapped to my-input attribute
  @Output() myOutput = new EventEmitter<string>();  //mapped to myOutput native event
  constructor() {}

  ngOnInit() {}
}

awesome-element.component.html

<p>{{myInput}}</p>
<button (click)="myOutput.emit('hello event!')">CLICK</button>

app.component.ts

import { Component, ViewChild, AfterViewInit } from '@angular/core';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
})
export class AppComponent implements AfterViewInit {
  @ViewChild('comp', { static: true }) comp;

  ngAfterViewInit() {
    this.comp.nativeElement.addEventListener('myOutput', (e) => {
      console.log(e.detail ); // hello event!
    });
  }
}

app.component.html

<my-component my-input="hello input!" #comp></my-component>

Web component in Angular application

Building for external application

If you plan to build your web component, you will have to make some modifications when bootstrapping your app. Because I want to keep both examples together (using the component in an angular app, and building for external use), I will create a new folder named elements, containing a modified version of some files.

src/elements/main.ts

import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';

import { environment } from '../environments/environment';
import { ElementsModule } from './elements.module';


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

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

src/elements/elements.module.ts

import { BrowserModule } from '@angular/platform-browser';
import { NgModule, Injector, DoBootstrap } from '@angular/core';
import { AwesomeElementComponent } from '../app/awesome-element/awesome-element.component';
import { createCustomElement } from '@angular/elements';

@NgModule({
  declarations: [AwesomeElementComponent],
  imports: [BrowserModule],
  entryComponents: [AwesomeElementComponent],
  exports: [AwesomeElementComponent]
})
export class ElementsModule implements DoBootstrap {
  constructor(private injector: Injector) {
    const element = createCustomElement(AwesomeElementComponent, { injector });
    customElements.define('my-component', element);
  }

  ngDoBootstrap() {}
}

}

src/elements/index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Angular Elements</title>
    <base href="/" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link rel="icon" type="image/x-icon" href="favicon.ico" />
  </head>
  <body>
    <h1>Angular elements + static site</h1>
    <my-component my-input="hello-static"></my-component>
    <script>
      document.querySelector('my-component').addEventListener('myOutput', e => {
        console.log(e.detail);
      });
    </script>
  </body>
</html>

One of the main differences is in the elements.module.ts file. We've removed all the references to the AppComponent. Also, we removed the bootstrap property of the NgModule decorator. Instead, we add the ngDoBootstrap method, used for manual bootstrapping, and do nothing in it.

index.html does not make any reference to the app. Instead, it will use the custom element directly.

We need to build the component using the same command we used before, but passing the proper parameters.

ng build --main=src/elements/main.ts --index=src/elements/index.html --outputPath=dist/elements

build output

You can add the whole command to the scripts section of package.json

We can now serve the static generated site. I'm using http-server for its simplicity.

http-server dist/elements/

The static site will now be served on port 8080.

Static site with custom element

We can see, from the build output, that lots of files were generated. If we look at the output index.html file, we will notice that all of them have been added at the end of the body of the page.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Angular Elements</title>
    <base href="/" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link rel="icon" type="image/x-icon" href="favicon.ico" />
  </head>
  <body>
    <h1>Angular elements + static site</h1>
    <my-component my-input="hello-static"></my-component>
    <script>
      document.querySelector('my-component').addEventListener('myOutput', e => {
        console.log(e.detail);
      });
    </script>
  <script src="runtime-es2015.js" type="module"></script><script src="runtime-es5.js" nomodule defer></script><script src="polyfills-es5.js" nomodule defer></script><script src="polyfills-es2015.js" type="module"></script><script src="styles-es2015.js" type="module"></script><script src="styles-es5.js" nomodule defer></script><script src="scripts.js" defer></script><script src="vendor-es2015.js" type="module"></script><script src="vendor-es5.js" nomodule defer></script><script src="main-es2015.js" type="module"></script><script src="main-es5.js" nomodule defer></script></body>
</html>

Depending on the browser, es5 or es2015 files will be loaded (Differential loading).

This worked fine because the CLI is taking care of adding the proper files to the page, but in terms of portability, this may not be the best option. To make your life easier, you can concatenate these files in two bundles (es5 and es2015). These bundles must be added to your site using the same differential loading techniques used before.

When building for production, disable the hashes in the filenames. To make the bundling easier.

I use a script for this purpose.

const path = require('path');
const fs = require('fs');
const distFolder = path.join(__dirname, 'dist', 'elements');
const bundlesFolder = path.join(__dirname, 'dist', 'bundles');

async function bundle(format) {
  const files = await Promise.all([
    readFile(path.join(distFolder, `runtime-${format}.js`)),
    readFile(path.join(distFolder, `polyfills-${format}.js`)),
    readFile(path.join(distFolder, `scripts.js`)),
    readFile(path.join(distFolder, `main-${format}.js`)),
  ]);

  try {
    await writeFile(
      path.join(bundlesFolder, `bundle-${format}.js`),
      files.join('\n'),
    );
  } catch (e) {
    console.error(e);
  }
}

async function bundleAll() {
  if (!fs.existsSync(bundlesFolder)) {
    fs.mkdirSync(bundlesFolder);
  }
  await Promise.all([bundle('es5'), bundle('es2015')]);
}

async function readFile(filePath) {
  return await new Promise((resolve, reject) => {
    fs.readFile(filePath, { encoding: 'utf-8' }, (err, data) => {
      if (err) {
        reject(`Error reading ${filePath}`);
      }
      resolve(data);
    });
  });
}

async function writeFile(filePath, data) {
  await new Promise((resolve, reject) => {
    fs.writeFile(filePath, data, (err, data) => {
      if (err) {
        reject(`Error writing ${filePath}`);
      }
      resolve(data);
    });
  });
}

bundleAll();

For building and bundling for production, we can run the command:

ng build --main=src/elements/main.ts --index=src/elements/index.html --outputPath=dist/elements --outputHashing=none --prod && node bundle.js

comment AwesomeElementComponent usages in the AppModule if you see an error after executing the command above

Now, we can use the bundles on any page.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Angular Elements</title>
    <base href="/" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link rel="icon" type="image/x-icon" href="favicon.ico" />
    <link rel="stylesheet" href="styles.css" />
  </head>

  <body>
    <h1>Angular elements + Bundles</h1>
    <my-component my-input="hello-bundle"></my-component>
    <script>
      document.querySelector('my-component').addEventListener('myOutput', e => {
        console.log(e.detail);
      });
    </script>
    <script src="bundle-es2015.js" type="module"></script>
    <script src="bundle-es5.js" nomodule defer></script>
  </body>
</html>

Why should I use Angular Elements?

  • You are migrating from AngularJS to Angular (allows a gradual migration).
  • You have a static site, and you just want to embed a complex component to it.
  • You have a large team working with different technologies, and you want to share some parts of your app.

Final words

  • I hope you found this article helpful. There's still room for Angular Elements to improve bundling size and tooling. Hopefully, Ivy will also add some improvements.
  • You can find the final code in here

This Dot Inc. is a consulting company which contains two branches : the media stream and labs stream. This Dot Media is the portion responsible for keeping developers up to date with advancements in the web platform. In order to inform authors of new releases or changes made to frameworks/libraries, events are hosted, and videos, articles, & podcasts are published. Meanwhile, This Dot Labs provides teams with web platform expertise using methods such as mentoring and training.

Discussion

pic
Editor guide