Last year I was tasked to incorporate several Angular components into an existing ASP.Net application. There was already a messy solution implemented (a gargantuan script which loaded a classic Angular app with all the components, regardless of their relevance to the current page), which, obviously, was a ‘little’ not efficient in terms of speed. And, of course, this approach was also a hell in terms of software development process, which required me to run a Gulp script, rebuild the ASP.Net app and hard reload the page every time I changed a tiny bit of CSS. The team was not happy with neither the app performance, nor the development process, so my objective was to

  1. Create a new solution, which will allow to incorporate a single Angular component inside a page, rather than the whole application.
  2. Would work faster and not load huge, unnecessary scripts.
  3. Will allow for faster dev build times with no need of constant script rerun.

So, naturally, I turned to the Web Components.

But what are Web Components?#

Web Components are the concept of components from frameworks like React or Angular, but built for the web overall, framework-agnostic. What does that mean?

We are used to our simple HTML tags in our UI. For example, we know tags like div, span, and others. They have predefined behavior, and may be used in different places of our view.

Web Components essentially allow us to create new HTML tags/elements using JavaScript. Let’s see a small example of how this is done, purely with JavaScript.

class SpanWithText extends HTMLSpanElement {
    constructor() {
        super();
        this.innerText = `Hello, I am a Web Component!`;
    }
}
customElements.define('span-with-text', SpanWithText);

Here we create a class (just like we write a class for the components in Angular), which extends from HTMLElement (HTMLSpanElement in our case, to be more precise), and whenever this element is created, it will have a innerText with value Hello, I am a Web Component. So, whenever we use our element in the DOM, it will already have the populated text inside itself.

<span-with-text></span-with-text>

Cool, can I use it with Angular?#

Of course, it would be great to be able to turn our Angular Components into Web Components and use them wherever we want, no matter the environment. And turns out, with the use of @angular/elements, we can do precisely that.

How this works:#

First things first, we are going to start a new Angular app. We are going to create it with a special flag, so that it only creates config files and no boilerplate code (AppModule/AppComponent and etc…).

ng new web-components --createApplication=false

We will wind up with an empty Angular app. Now, let’s create our first Web Component; inside our new project directory:

ng generate application FirstWebComponent  --skipInstall=true

Add this flag to skip reinstalling any dependencies.

So, this command creates a projects folder inside our root directory, and in it there will be a folder named FirstWebComponent. If we look inside it, we will see a typical Angular app: a main.ts file, an app.module, an app.component and other stuff. Now, we need to get the @angular/elements library, so we run

ng add @angular/elements

This will bring the library inside our node_modules folder, so we can use it to turn our components into Web Components.

The important thing to understand about Angular Components turned into Web Components is that they are simple Angular components — nothing about them has to be different to use as Web Components. You can do anything inside them which you could do with a usual Angular component, and it will work correctly. Any configuration steps you have to do in order to get your components to work are done on the module level; nothing changes in the components themselves. This means you can turn just about any existing Angular component into a Web Component without much hassle.

In the next step, lets create an Angular component which will become our Web Component; inside our newly made project:

ng generate component UIButton

Now what we have to do is make Angular understand that we don’t want it to treat this component as a common Angular component, but rather as something different. This is done in the module bootstrapping level — what we have to do is implement the ngDoBootstrap method on our AppModule and tell our module to define a custom element. For that we are going to use the createCustomElement function from the@angular/elements package.

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

@NgModule({
  declarations: [
    UIButtonComponent,
  ],
  imports: [
    BrowserModule,
  ],
  entryComponents: [UIButtonComponent],
})
export class AppModule implements DoBootstrap {

  constructor(private injector: Injector) {
    const webComponent = createCustomElement(UIButtonComponent, {injector});
    customElements.define('ui-button', webComponent);
  }

  ngDoBootstrap() {}
 }

There are several important things to notice:

  1. We have to pass the injector to our Web Component manually. This is to ensure that dependency injection works at runtime.
  2. We have to put our component in the entryComponents array. This is required for the Web Component to be bootstrapped.
  3. createCustomElement is a functions that, in fact, turns our own component into the Web Component — we pass the result of that function to customElements.define, rather than our Angular component.
  4. The selector of our Angular component is irrelevant. How it will be called from some other HTML template is determined by the string that we pass to the customElements.define function. In this case, it will be called as <ui-button></ui-button>.
  5. The selector we pass to the customElements.define function has to consist of two or more words separated by dashes. This is rather a demand from the Custom Elements API (so it can differentiate custom elements from native HTML tags), than a quirk of Angular itself.

Our next step is building the app!

ng build FirstWebComponent

When we look inside the dist folder, we will see the following:

Files generated after the build

These are the files we need to include in another app to be able to use our Web Component. In order to achieve this, we can copy this files to another project, and include it in different ways:

  1. If we are to use it inside a React app, we can install the polyfill, and then include our files using simple import statements. You can read more about it in Vaadin’s official blog post.
  2. In order to use it in a plain HTML app, we have to include all our generated files via script tags and then use the component:
<html>
  <head>
    <script src="./built-files/polyfills.js"></script>
    <script src="./built-files/vendor.js"></script>
    <script src="./built-files/runtime.js"></script>
    <script src="./built-files/styles.js"></script>
    <script src="./built-files/scripts.js"></script>
  </head>
  <body>
    <ui-button></ui-button>
  </body>
</html>

3. In order to use it in another Angular app, we don’t really need to build the component; as it is a simple Angular component, we can just import and use it. But if we only have access to compiled files, we can include them with the scripts field in our angular.json file in the target project, and add schemas: [CUSTOM_ELEMENTS_SCHEMA], to our app.module.ts file.

So, this first part was easy. A couple of things to note:

  1. If we have inputs inside our web component, the naming pattern changes when we use it. We use camelCase in our Angular component, but to access that input from another HTML file, we would have to use kebab-case:
@Component({/* metadata */})
export class UIButtonComponent {
  @Input() shouldCountClicks = false;
}
<ui-button should-count-clicks="true"></ui-button>

2. If we have outputs inside our Angular component, we can listen to the emitted synthetic events via addEventListener. There is no other way, including React — the usual on<EventName={callback}> won’t work, as this is a synthetic event.

But what about Browser compatibility?#

So far, so good — we have built our app and successfully tested it in a major modern browser, say Chrome. But what about the bane of all web developers — IE? If we open our app in which we use a Web Component, we will see that our button-with-counter has successfully been rendered. So, we are good? Not quite.

If we had the feature that makes the component count the clicks on the button, then after we click in in Internet Explorer, we will see that the counter is not being updated. To save you some further investigations — the problem is that change detection in Angular Web Components does not work on IE. So, should we forget about Web Components on IE? No, thankfully, there is a simple workaround. We have to either implement a new Change Detection Zone Strategy, or import one. There is a small package with exactly what we want:

npm i elements-zone-strategy

And slightly change our app.module.ts file:

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

@NgModule({
  declarations: [
    UIButtonComponent,
  ],
  imports: [
    BrowserModule,
  ],
  entryComponents: [UIButtonComponent],
})
export class AppModule implements DoBootstrap {

  constructor(private injector: Injector) {
    const strategyFactory = new ElementZoneStrategyFactory(UIButtonComponent, injector);
    const webComponent = createCustomElement(UIButtonComponent, {injector, strategyFactory});
    customElements.define('log-activity', webComponent);
  }

  ngDoBootstrap() {}
 }

Here we just invoked the ElementZoneStrategyFactory constructor, got a new strategyFactory and passed it on to the createCustomElement function along with the injector.

Now you can open the same component in IE and — surprise — it works! Change Detection now works on IE as expected.

Don’t forget about the polyfills#

You will be visiting the polyfills.ts file regularly. My advice is customize is and don’t load all the polyfills at once, only the ones needed.

How do I debug?#

Debugging (and error messages in general) is a bit of a problem in Angular Web Components. You don’t have hot reload, and you run your built components in another environment rather than an Angular app, so you might wonder how you could get those. In fact, you can change the index.html file a bit and put the selector of your Web Component instead of app-root:

Then run your app as usual:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>UIButton</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>
  <ui-button></ui-button>
</body>
</html>
ng serve UIButton

This will spawn an Angular app that only contains your web component. An upside to this is that you will have hot reload (this is a big deal). But there also is a downside: error messages often get chewed up: for example, if you forget to provide one of your services, in a common Angular app you will get an angry StaticInjectorError, detailing which service was not provided. In our case, we will just be presented with a blank screen and no errors — a mystery to investigate and get frustrated about.

Conclusion#

This basic setup gives us everything we need to build and deploy apps that use Angular Components and Web Components. But this setup is far from ideal — we would want to have some scripts to

  1. Automatically generate new Web Components
  2. Scripts to run our build process automatically
  3. Hot reload

In my next article, we will start diving into this scripts to make our development process way more fun.