What are Web Components?
Sat Jul 15 2017
Web Components is the umbrella term for a collection of independent but closely related features being added by the W3C to the HTML and DOM specifications to facilitate developing first class web components using standard browser features. Those features are:
- HTML Templates
- HTML Imports
- Custom Elements
- Shadow DOM
All these features have been around for sometime, have been though several design iterations, and have differing browser support. This post outlines each in turn with brief examples and links to further documentation to help you understand the fundamentals of Web Components. We'll build an example as we go.
HTML Templates
HTML Templates enable you to create DOM elements that are not part of the main document. They exist as fully formed DOM elements but exist in isolation, and no resources referenced (images, scripts) will be downloaded or run until you want to insert the template into the document. Once inserted they become part of the document and behave in the same way as any other DOM element.
This is preferable to either creating the elements directly using JavaScript and assigning properties, attributes, events and children individually (which can be cumbersome), or setting the innerHTML of an element directly (which is both messy and inefficient). They are used extensively when writing Custom Elements, but can also be used independently.
HTML Templates are the most mature of the Web Components features and are supported in all the evergreen browsers. See MDN for more information.
<div id="parent">
<h1>Parent</h1>
</div>
<template id="my-template">
<h2>My First Template</h2>
<script>
console.log('template script output');
</script>
</template>
<script>
/*
get the current document (needed as we'll be importing this
file later), you could just use document if not importing
*/
const currentDocument = document.currentScript.ownerDocument;
const parent = currentDocument.getElementById('parent');
const template = currentDocument.getElementById('my-template');
/*
stamping out an instance of our template
*/
const instance = template.content.cloneNode(true);
console.log('template instance created');
/*
the script inside our template won't run until out template
instance is inserted into the DOM
*/
parent.appendChild(instance);
</script>
HTML Imports
HTML Imports are a way of importing HTML into another HTML page. When you think about it, it seems crazy that there hasn't ever been a standard way to do this. There are ways to do it without HTML Imports, but none of them are particularly elegant or easy to use. With HTML Imports you reference an HTML file in much the same way as you would a CSS file but using ref="import"
rather than ref="stylesheet"
. The HTML Import can include any valid elements a regular HTML page body would have, including scripts, and are cached by the browser in the same way as any other resource.
But HTML Imports are not as basic as they might seem at first. Simply importing an HTML file doesn't add the content to the main document directly, instead a new document is created containing the imported DOM which you can manipulate and query in the standard way, as well as reference the DOM of the document it was inserted into. You can then use JavaScript within the main document to insert the imported document (or part of it) wherever you choose.
At first glance this functionality seems similar to HTML Templates, just with the HTML in a separate file. The key difference is the handling of scripts. As mentioned before, scripts referenced from an HTML Template are not downloaded or run until the DOM is inserted into the main document, whereas scripts in an HTML Import are downloaded and run as they are encountered. The benefit of this is that you can import a HTML document that initialises itself so you don't need to know about its inner workings to start using it straight away. It's entire lifecycle can be managed independently from the document it is imported into. Of course you can have the best of both worlds - you can use HTML Templates within HTML Imports depending on your needs. Furthermore the content of imports, including scripts, will only ever run once no matter how many times the import is referenced, so you don't need to worry about including the same import in multiple places.
Unfortunately HTML Imports are unlikely to be supported in all browsers. The feeling among some browser vendors is that the use of JavaScript ES6 modules in the browser (another feature that is currently under development) will change how we think about importing resources. It's not clear how exactly, since the primary type of resource in Web Components is HTML, while ES6 modules handle only JavaScript. Personally I think HTML Imports work very well for the use case they were designed for, and it would be a shame for them to be dropped in favour of shoe-horning similar behaviour into a mechanism designed to solve a different problem - I would like to see both features supported. Thankfully in the mean time HTML Imports can be polyfilled in all browsers.
Head over to HTML5 Rocks and Web Components for good introductions.
<script>
const importLoaded = (event) => {
const importedDoc = event.target.import;
const importedDocContent = importedDoc.body;
const content = document.querySelector('#content');
content.appendChild(importedDocContent);
};
</script>
<!--
import our previous example - we need to use onload, rather
than addEventListener('load', ...) as the import will usually
have loaded by the time the listener is attached.
-->
<link
rel="import"
href="https://output.jsbin.com/tolita"
onload="importLoaded(event)"
/>
<div id="content"></div>
Custom Elements
Custom Elements are often taken as the key feature of Web Components, and the two terms are often used interchangeably. Custom Elements are a way of extending the DOM to include new first class elements. Consider the basic building blocks of HTML - native elements such as divs, headings, forms, inputs, buttons and so on - they have a clearly defined meaning and a common standard API.
Before Custom Elements there was no way to create a new type of element to which you could natively assign new behaviours, properties or events to. Instead you'd need to take an existing tag (most commonly the humble div
) and create your own interface in JavaScript on top of the standard DOM APIs to create something that behaved like a native element.
The main disadvantages to that approach are that the new Frankenstein element has no clear semantic meaning, and you are introducing a performance cost in order to manage the lifecycle, behaviours and events of the element in a non standard way. You are also tying yourself into a particular implementation that will likely not be compatible with other implementations. Custom Elements on the other hand have built in browser support for all those things so they can leverage the performance advantages that go with it, as well as not being dependent on any particular 3rd party implementation.
Custom Elements allow you to create new DOM elements which have their own tag names and can make direct use of the same DOM APIs as native elements do. They can be imported into any document and used wherever native elements can be, including any current (or future) libraries and frameworks. Best of all they are composable - you can use custom elements within custom elements within custom elements to build anything from simple presentation elements to whole applications. Custom Elements have both a declarative interface (using attributes) and an imperative one (using properties).
Custom Elements have wide browser support but be sure to look for guides and documentation to v1, not the previous v0 incarnation as they differ considerably. The Web Fundamentals site has a great v1 guide on Custom Elements.
<!-- our custom element tag, with an initial attribute value -->
<!-- custom elements must begin with a lower-case letter and contain a dash -->
<my-element nickname="Ada"></my-element>
<!-- a button to show a message from our element -->
<p><button id="show-message">Show message</button></p>
<p>
<!-- some forms to change our element's attribute or property -->
</p>
<form id="change-attr">
<input name="newname" type="text" placeholder="New name" />
<button>Change by attribute</button>
</form>
<form id="change-prop">
<input name="newname" type="text" placeholder="New name" />
<button>Change by property</button>
</form>
<!-- the template for our custom element -->
<template id="my-template">
<h1>My First Custom Element</h1>
<h2 id="nickname"></h2>
</template>
<script>
class MyElement extends HTMLElement {
/*
The setter and getter methods for our element's properties
*/
set nickname(val) {
/*
when our property is changed, reflect the change to an
attribute with the same name.
*/
this.setAttribute('nickname', val);
}
get nickname() {
/*
since we are reflecting this property to an attribute with
the same name, that's where we'll retrieve the value from
*/
return this.getAttribute('nickname');
}
/*
Another element property, this time a function
*/
showMessage() {
alert('Hi ' + this.nickname);
}
/*
define which attributes will be watched for changes
*/
static get observedAttributes() {
return ['nickname'];
}
constructor() {
super();
/*
stamp out our template as before and add it to the element's DOM
*/
const template = document.getElementById('my-template');
const instance = template.content.cloneNode(true);
this.appendChild(instance);
}
attributeChangedCallback(name, oldValue, newValue) {
/*
when our attrribute changes, let's update our content too
*/
this.querySelector('#nickname').textContent = newValue;
}
}
/*
officially announce are new element to thr browser
*/
customElements.define('my-element', MyElement);
const myelement = document.querySelector('my-element');
const messageButton = document.querySelector('#show-message');
messageButton.addEventListener('click', () => {
myelement.showMessage();
});
const formAttr = document.querySelector('#change-attr');
formAttr.addEventListener('submit', (event) => {
event.preventDefault();
myelement.setAttribute('nickname', event.target.newname.value);
});
const formProp = document.querySelector('#change-prop');
formProp.addEventListener('submit', (event) => {
event.preventDefault();
myelement.nickname = event.target.newname.value;
});
</script>
Shadow DOM
Custom Elements sound great, but without the Shadow DOM they aren't truly self contained elements - the HTML within a Custom Element will be accessible from outside the element so you could never be sure that the IDs and class names you give to the content wouldn't clash with those given to outer elements and vice-versa. CSS and JavaScript selectors could leak both ways through the boundary of the element meaning there'd be no guarantee as to the look and feel or behaviour of your element.
The Shadow DOM solves this by isolating your Custom Element's DOM from the rest of the document. You can use any classes or IDs you like without worrying about using unique values from the rest of the document. The CSS inside the Shadow DOM is also scoped to the element so it will only act on the content of the element itself, and conversely styles outside of the element will not be applied to the element contents. Finally the Shadow DOM allows you to specify which styles can be overriden from outside the element (using CSS variables and mixins) so you can create customiseable elements if necessary. You can also optionally include and style elements that are within your Custom Element's opening and closing tags (referred to as the light DOM) using slot
s.
Shadow DOM has wide browser support but be sure to look for guides and documentation to v1, not the previous v0 incarnation as they differ considerably. Again, the Web Fundamentals site has a great v1 guide on Shadow DOM.
<!-- the same example as previously, this time with Shadow DOM -->
<my-element nickname="Ada">
<div>Lovelace</div>
<!-- unless you define a slot in the Shadow DOM, this content will not be displayed -->
</my-element>
<p><button id="show-message">Show message</button></p>
<p></p>
<form id="change-attr">
<input name="newname" type="text" placeholder="New name" />
<button>Change by attribute</button>
</form>
<form id="change-prop">
<input name="newname" type="text" placeholder="New name" />
<button>Change by property</button>
</form>
<!-- some global styling for our element -->
<style>
my-element {
/*
a CSS variable (defined in the custom element) being overridden
*/
--name-border-style: brown;
}
</style>
<template id="my-template">
<!-- Shadow DOM styles are included in the template -->
<style>
/*
:host refers to our custom element itself
these styles can always be overridden using styles
in the main document
*/
:host {
display: block;
border: 1px solid red;
padding: 5px;
}
h2 {
/*
here we define a CSS variable, along with a default value.
the border will take this default colour unless overriden (as it is above)
*/
border: 1px solid var(--name-border-style, blue);
padding: 5px;
}
h3 {
/*
no CSS variables here, so this cannot be styled from
outside the Shadow DOM
*/
border: 1px solid green;
padding: 5px;
}
/*
the ::slotted selector allows you to style the contents of you custom
element tag (the light DOM) - but any rules here will be overriden by global rules
*/
::slotted(div) {
border: 1px solid orange;
padding: 5px;
}
</style>
<h1>My First Custom Element</h1>
<h2 id="nickname"></h2>
<!--
any content inside the my-element tag (known as the light DOM) will be shown in place
of <slot>
-->
<h3><slot></slot></h3>
</template>
<script>
class MyElement extends HTMLElement {
set nickname(val) {
this.setAttribute('nickname', val);
}
get nickname() {
return this.getAttribute('nickname');
}
showMessage() {
alert('Hi ' + this.nickname);
}
static get observedAttributes() {
return ['nickname'];
}
constructor() {
super();
const template = document.getElementById('my-template');
const instance = template.content.cloneNode(true);
/*
create a Shadow DOM and attach it to this element
*/
this.attachShadow({ mode: 'open' });
/*
append our template instance into the Shadow DOM
*/
this.shadowRoot.appendChild(instance);
}
attributeChangedCallback(name, oldValue, newValue) {
/*
now we query the Shadow DOM, rather than the element directly
*/
this.shadowRoot.querySelector('#nickname').textContent = newValue;
}
}
customElements.define('my-element', MyElement);
const myelement = document.querySelector('my-element');
const messageButton = document.querySelector('#show-message');
messageButton.addEventListener('click', () => {
myelement.showMessage();
});
const formAttr = document.querySelector('#change-attr');
formAttr.addEventListener('submit', (event) => {
event.preventDefault();
myelement.setAttribute('nickname', event.target.newname.value);
});
const formProp = document.querySelector('#change-prop');
formProp.addEventListener('submit', (event) => {
event.preventDefault();
myelement.nickname = event.target.newname.value;
});
</script>
Bringing it all together
In our final example we'll just rearrange the code from the previous example into a main document and an HTML Import, and use the polyfill to make it work in the latest version of every browser.
<!-- custom element import -->
<template id="my-template">
<style>
:host {
display: block;
border: 1px solid red;
padding: 5px;
}
h2 {
border: 1px solid var(--name-border-style, blue);
padding: 5px;
}
h3 {
border: 1px solid green;
padding: 5px;
}
/* the polyfill requires a selector to the left of :slotted */
h3 ::slotted(div) {
border: 1px solid orange;
padding: 5px;
}
</style>
<h1>My First Custom Element</h1>
<h2 id="nickname"></h2>
<h3><slot></slot></h3>
</template>
<script>
const currentDocument = document.currentScript.ownerDocument;
class MyElement extends HTMLElement {
set nickname(val) {
this.setAttribute('nickname', val);
}
get nickname() {
return this.getAttribute('nickname');
}
showMessage() {
alert('Hi ' + this.nickname);
}
static get observedAttributes() {
return ['nickname'];
}
constructor() {
super();
const template = currentDocument.getElementById('my-template');
/*
ShadyCSS is the polyfill for Shadow DOM that we need
to activate for each template in browsers that need it
*/
if (!ShadyCSS.nativeShadow) {
ShadyCSS.prepareTemplate(template, 'my-element');
ShadyCSS.styleElement(this);
}
const instance = template.content.cloneNode(true);
this.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(instance);
}
attributeChangedCallback(name, oldValue, newValue) {
this.shadowRoot.querySelector('#nickname').textContent = newValue;
}
}
customElements.define('my-element', MyElement);
</script>
<!-- our main document -->
<!DOCTYPE html>
<html>
<head>
<!-- the polyfill -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/webcomponentsjs/1.0.1/webcomponents-lite.js"></script>
<!-- our custom element import -->
<link rel="import" href="https://output.jsbin.com/sodazew" />
<style>
my-element {
--name-border-style: brown;
}
</style>
</head>
<body>
<my-element nickname="Ada">
<div>Lovelace</div>
</my-element>
<p><button id="show-message">Show message</button></p>
<p></p>
<form id="change-attr">
<input name="newname" type="text" placeholder="New name" />
<button>Change by attribute</button>
</form>
<form id="change-prop">
<input name="newname" type="text" placeholder="New name" />
<button>Change by property</button>
</form>
<script>
const myelement = document.querySelector('my-element');
const messageButton = document.querySelector('#show-message');
messageButton.addEventListener('click', () => {
myelement.showMessage();
});
const formAttr = document.querySelector('#change-attr');
formAttr.addEventListener('submit', (event) => {
event.preventDefault();
myelement.setAttribute('nickname', event.target.newname.value);
});
const formProp = document.querySelector('#change-prop');
formProp.addEventListener('submit', (event) => {
event.preventDefault();
myelement.nickname = event.target.newname.value;
});
</script>
</body>
</html>
And that's it! A whistle-stop tour of Web Components. They are a fantastic addition to the web developer's toolbox that let you create truly reusable components with no libraries or 3rd party code.
If you have any feedback or questions I'd love to hear them @lamplightdev.