Angular Elements solves the problem of code reuse across multiple frameworks and provides a way to use Angular components in non-Angular environments. Learn how you can start using them in your apps today.
Angular is awesome. It's a full-featured, robust framework with a lot of power under the hood. Wouldn't it be nice, though, to be able to use Angular in different contexts without all of the overhead? Maybe you need to share a custom Angular datepicker with other teams using other frameworks. Maybe you need to embed a tiny Angular application into a CMS. Or, maybe you'd like to use Angular components inside of your legacy AngularJS application as an alternative to the ngUpgrade library.
These are the problems that the Angular Elements library solves. Angular Elements is a project under Angular Labs, the Angular team's umbrella for new, experimental ideas. Angular Labs allows the team to break new ground without breaking your application. The first experimental release of Elements became part of Angular 6. It's a minimum viable product that's not yet ready for full-fledged production, but we should see a more refined version of Elements in Angular 7.
So, what exactly is the Angular Elements project and how do we start using it?
Before we dig into Angular Elements, we need to learn a little bit about web components.
Angular Elements lets you package your Angular components as custom web elements, which are part of the web components set of web platform APIs. Web components are technologies to help create reusable, encapsulated elements. Right now, that includes the shadow DOM, HTML templates, HTML imports, and custom elements. The custom elements technology powers Angular Elements.
Custom elements are reusable, encapsulated HTML tags to use in web pages and web apps. They can be used in any framework or library that uses HTML. In short, you're able to create custom DOM elements that have their own functionality that the rest of the page doesn't need to know anything about. (You can also modify existing DOM elements, though we won't be doing that with Angular Elements.)
To create a custom element, you simply need to create an ES6 class that extends from an HTML element and register that class with the browser through an API called the CustomElementRegistry
. Custom elements have lifecycle hooks, such as the connectedCallback
and the disconnectedCallback
. They also allow you to register interest in attributes, which can also have callbacks and properties associated with them. And finally, like other DOM elements, custom elements can have events associated with them using addEventListener
.
The Angular Elements library is essentially a bridge between Angular components and the custom web elements API. With Angular Elements, custom elements act as hosts for Angular components. Angular inputs map to properties, host bindings map to attributes, outputs map to events, and lifecycle hooks map to the custom element lifecycle hooks. As you can see, Angular was purposefully designed to interface easily with the DOM API, and custom elements are no exception.
Angular Elements also let us take advantage of Angular's dependency injection. When we create a custom element using Angular Elements, we pass in a reference to the current module's injector. This injector lets us share context across multiple elements or use shared services.
We also get content projection (transclusion) with Angular Elements, with a couple of caveats. Content project works correctly when the page first renders, but not with dynamic content projection yet. As of now, we also don't yet have support for ContentChild
or ContentChildren
queries. Content projection should get more robust over time, though, and we'll also have the ability to use slots and the shadow DOM as browser support increases.
If you used AngularJS (1.x) a lot, you may have appreciated how flexible it was. You could use AngularJS in lots of places: inside of a CMS, inside of a Microsoft MVC application, or as a full-fledged SPA (single-page application). Angular (2+) is a much more robust framework and was designed as a tool to build complete, full-featured applications. Angular Elements will restore flexibility to Angular, but with many more features than AngularJS could provide.
So, where would we use Angular Elements? Rob Wormald covered three main areas with examples in his ng-conf 2018 talk on Angular Elements:
Essentially, anywhere you think you may need Angular, you'll be able to use it with Angular Elements. The current release of Elements has been optimized for apps and containers, but there's still work to be done for reusable widgets. That brings us to our topic: the challenges facing Angular Elements today.
While Angular Elements is looking extremely promising as of Angular 6, there are still a few challenges that must be overcome as the team gets it ready for production use:
Now that we know all about Angular Elements, let's create our own reusable custom element! We're going to make a simplified clone of the Momentum dashboard Chrome extension as a reusable component. It's straightforward, but we'll use an @Input
, the *ngIf
directive, and a template variable just to see Angular magic used out of context.
Just a reminder, Angular Elements is still in early days, so new updates might break things, and your development environment may affect some of these steps.
To get going, we're going to create a new Angular CLI project and add Elements to it.
First, make sure you have the Angular CLI installed globally (and be certain it is the latest version, at least 6.0.8 as of the time of this writing):
npm install -g @angular/cli
Let's create our application with the CLI. Once the install is done, navigate into the folder with cd
and open it with your favorite editor (I'm using Visual Studio Code).
ng new momentum-element
cd momentum-element
We can add Angular Elements to our project using the ng add
command and pass in the name of our project.
ng add @angular/elements --project=momentum-element
Not only does this command add Angular Elements, it also adds a polyfill called document-register-element and adds it to the scripts section of our Angular CLI config file (angular.json). This polyfill adds support for the custom elements API.
Now that we're set up, let's make our component. We'll just reuse our AppComponent
that's generated by the CLI (it's inside /src/app). Replace the code in app.component.ts with the following:
import { Component, ViewEncapsulation, Input } from '@angular/core';
@Component({
selector: 'app-momentum-element',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
encapsulation: ViewEncapsulation.Native
})
export class AppComponent {
@Input() name = 'friend';
focus: string;
focusSet = false;
setFocus(value) {
this.focus = value;
this.focusSet = true;
}
}
A couple of notes on this. First, the selector won't actually matter here, because we'll be registering this component as a custom element with its own tag. Second, notice that we're using ViewEncapulation.Native
. Specifying an encapsulation strategy affects how our styles are applied to components. The default is called Emulated
, which simulates the shadow DOM in order to have scoped styling, even in browsers that don't support the shadow DOM. We're flipping on the real shadow DOM here by using the Native
strategy.
Other than that, we're just doing a couple simple things here. We're creating an @Input
for the name
attribute, which we'll default to "friend". We're also making a focus
string property and a focusSet
boolean property, as well as a function that sets that property and toggles the boolean to show and hide the input box.
Let's do the template next. Replace the contents of app.component.html with this:
<div class="widget-container">
<div class="content">
<h1>Hello, {{name}}.</h1>
<input *ngIf="!focusSet" type="text" #userFocus (keydown.enter)="setFocus(userFocus.value)" placeholder="What's your focus today?" />
<div *ngIf="focusSet" class="focus">
<p>Your main focus today is:</p>
<p>{{focus}}</p>
</div>
</div>
</div>
We've got a simple input with a template variable here so the user can type a focus for the day, hit enter, and it will display. Nothing too crazy, but we're taking advantage of Angular to make this easy.
Finally, let's add some style. Replace the contents of app.component.css with this:
.widget-container {
color: white;
font-family: arial;
width: 400px;
height: 300px;
position: relative;
background-image: url('https://source.unsplash.com/400x300?mountains,snow,high%20contrast');
}
.content {
text-align: center;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
input {
font-size: 24px;
border: 2px black solid;
}
.focus {
width: 300px;
font-size: 20px;
}
That's all we need to do to get our component up and running.
So far, we haven't done anything related to Angular Elements here. In fact, you could update index.html to use app-momentum-element
instead of app-root
, run ng serve
, and see the working component in the browser, just like normal.
To use our component as a reusable widget, we just need to modify the way our AppModule
bootstraps. We only need to do two things to do this. First, instead of having the AppComponent
in a bootstrap
array, rename that array to entryComponents
to prevent the component from bootstrapping with the module:
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule],
providers: [],
entryComponents: [AppComponent]
})
Next, we need to create the custom element. This can be done in a few different places, but we'll do it in a constructor function. You'll need to add Injector
to the list of @angular/core
imports, and you'll need to import createCustomElement
from @angular/elements
. Once that's done, add the constructor like this:
constructor(private injector: Injector) {
const el = createCustomElement(AppComponent, { injector });
customElements.define('momentum-element', el);
}
Here, we're calling the createCustomElement
function, passing in our component and an instance of our module's injector (we're using ES6 destructuring here, but it's equivalent to {injector: injector}
). This function returns a special class that we can use with the Custom Elements API, where we define our element by passing in a selector and the class.
Finally, we need to add ngDoBootstrap
to override the bootstrap function. Our completed AppModule
will look like this:
import { BrowserModule } from '@angular/platform-browser';
import { NgModule, Injector } from '@angular/core';
import { createCustomElement } from '@angular/elements';
import { AppComponent } from './app.component';
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule],
providers: [],
entryComponents: [AppComponent]
})
export class AppModule {
constructor(private injector: Injector) {
const el = createCustomElement(AppComponent, { injector });
customElements.define('momentum-element', el);
}
ngDoBootstrap() {}
}
That's it!
So far, we've created a component and turned it into a custom element. Now we need to package it up so that it can be used elsewhere with a script tag. This means we need to not only build the application as we normally would, but concatenate all of the script files that the CLI produces into a single file.
I'll go ahead and let you know, this is the roughest part of this process right now. There are several different ways you can accomplish this:
I'm going to show you the Node approach today, because it seems to work on multiple platforms without any issues. In the future, though, I'm sure there will be a CLI schematic for Angular Elements that generates a flat file structure and bundles into one file. Don't forget, we're only getting started with the potential for Elements!
To create a Node build script, you'll need to install two more dependencies:
npm install --save-dev concat fs-extra
Next, at the root of our project, create a file called elements-build.js and paste this in:
const fs = require('fs-extra');
const concat = require('concat');
(async function build() {
const files = [
'./dist/momentum-element/runtime.js',
'./dist/momentum-element/polyfills.js',
'./dist/momentum-element/scripts.js',
'./dist/momentum-element/main.js'
];
await fs.ensureDir('elements');
await concat(files, 'elements/momentum-element.js');
await fs.copyFile(
'./dist/momentum-element/styles.css',
'elements/styles.css'
);
})();
This script will take all of the scripts that the CLI generates and combine them into a single file. It will also move the CSS file over, though since we're using native encapsulation, this file will be empty.
Next, create a folder at the root of the project called elements
. This is where we'll keep the concatenated files, as well as the HTML file we'll use to test our custom element.
Now, open up package.json and add a new script:
"build:elements": "ng build --prod --output-hashing none && node elements-build.js"
We're running the CLI build command with the prod flag and then running our build script with node.
Finally, in that new elements
folder, create a file called index.html and paste this in:
<!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">
</head>
<body>
<momentum-element name="Sam"></momentum-element>
<script type="text/javascript" src="momentum-element.js"></script>
</body>
</html>
Feel free to replace my name with yours in the name
attribute!
That's all we need to do to get a working build of a custom element.
Let's see if all this actually works. First, run our new build script:
npm run build:elements
You should see momentum-element.js and momentum-element.css in the elements
folder alongside our HTML file. You'll see that the script file is about 200 kb right now, which is a bit large for something so simple. Don't worry, though - Ivy will help cut that down by quite a bit in the future.
Now let's serve up that folder. You'll want to install a simple HTTP server. I like to use static-server
:
npm install -g static-server
You can then cd
into that folder and start up the server:
cd elements
static-server
When you navigate to localhost:9080 (in a browser that supports custom elements, like Chrome), you should see your custom element!
We've got ourselves a fully functional custom element! Feel free to experiment and build on this example. You can add multiple instances of this element to do the DOM with different name inputs. You could also add custom event listeners to interact with other elements. Try dropping this custom element into a React or Vue app! You can check out my finished code at this GitHub repo.
Note: if you're getting an error like Failed to construct 'HTMLElement'
, you may have a package version problem. I was getting this error when I created a project with an older version of the CLI, even when I tried to manually update. Once I updated my global version of the CLI and generated the project, it worked. Of course, you'll also be unable to run this custom element in IE or Edge.
Hopefully you've begun to understand some of the power of Angular Elements. The flexibility of custom elements means there is a dizzying array of possibilities to use your Angular code across teams, frameworks, and even technologies. There are still some things to hammer out with the usage and tooling around Angular Elements, but this first release looks like a solid foundation for the next release!
To dig deeper into Angular Elements and custom elements in general, check out these resources:
Sam Julien is an Angular teacher and developer and the founder of the comprehensive video course UpgradingAngularJS.com. He's also the co-organizer of Angular Portland. When he's not coding, you'll find Sam outside hiking and camping like a good Oregonian.