Reintroducing Custom Elements V1
I’ve kept the old polyfill name but bumped its version to V1, and I am talking about the old document.registerElement(…) API which has been superseded by current V1 specifications, based on a global customElements
instance which implements the CustomElementsRegistry interface.
Previously, on our screens …
The now called V0 version of the API has been natively implemented by Chrome only but successfully polyfilled for every other mobile and desktop browser.
TIL: not only @AMPhtml, the @seattletimes and the @nytimes use my Custom Elements polyfill in production! https://t.co/3JCtUT221B #honored
— Andrea Giammarchi (@WebReflection) August 4, 2016
Even if advocated as ready for production, even if custom elements have been used indeed in production by big players, even if various frameworks came out based on such API, and even with frameworks from Mozilla itself, browsers vendors never quite agreed on the V0 API and for various reasons, one of those being that it wasn’t playing so well with modern ECMAScript classes based syntax.
This part of the story is the one that hits the Web the most, because developers feel like they shouldn’t bet on early adoption of living standards, basically going against the whole purpose of having living standards.
However, we can consider this part of the story something to just keep in mind for the future, and move on with version V1 of Custom Elements which has been agreed by all major browsers vendors so that it is, finally today, a safe bet.
A graceful migration
In order to simplify as much as possible the migration between V0
and V1
, my document-register-element polyfill implements V1
on top of the production ready, and battle tested, version 0
. In few words, code based on V0
will work on code based on V1
and vice-versa, giving you the freedom to switch to V1
whenever it’s convenient. Please note that once every browser will implement V1
on stable channels, my poly won’t be on your way but it will keep polyfilling older browsers and features that some vendor might have not shipped yet.
What’s new in Custom Elements V1
The first big change is that document
is not anymore the definition entry point, there is a new global customElements
object with 3 methods:
customElements.define(name, Class[, options])
to define once a custom element providing its name, itsClass
, and optionally an object with anextends
keyword describing its native behaviorcustomElements.whenDefined(name)
which returns aPromise
that will be invoked once the element has been defined, very useful way to initialize on demand instead of synchronously, components dependenciescustomElements.get(name)
which returns the definedClass
constructor for the specified name
The second change is that components are defined by classes, skipping completely the previously proposed, verbose, and convoluted, ES5 style prototype definition.
// all you need to define
// a very basic custom element
customElements.define(
'my-element',
class extends HTMLElement {
constructor() {
super();
console.log(`it's a me!`);
}
}
);
// test it like ...
document.body.appendChild(
document.createElement('my-element')
);
The third most relevant change is that methods are named differently so that attachedCallback
is now called connectedCallback
, detachedCallback
is now disconnectedCallback
, the createdCallback
is now the constructor
, and finally the attributeChangedCallback
triggers only if an attribute has been defined through a public static observedAttributes
array of attributes to watch, as opposite of any attribute, like it was previously for v0
.
Two ways to create a custom element
Similar to v0
, there are two ways to create a new element in v1
as well:
- through new Constructor() JS based user-land invocation
- through document.createElement(…) native method call, which also happens implicitly for already available nodes in the current document
// the class
class MyEl extends HTMLElement {
constructor() {
// mandatory super call to upgrade the current context
super();
// any other Class/DOM related procedure
this.setAttribute('easy', 'peasy');
}
}
// its mandatory definition
customElements.define('my-el', MyEl);
// the JS way to create a custom element
const newMe = new MyEl();
// the DOM way to create a custom element
const createMe = document.createElement('my-el');
If there was already an element such <my-el></my-el>
in the document, the constructor
would’ve been invoked once, upgrading such element within the super()
call and setting the attribute after that.
Caveat: an unpolyfillable upgrade
There are things that current V1
makes impossible to reproduce via plain JS and the most obvious is the JS instance upgrade. In older engines, extending any DOM native class is not enough to have a proper DOM instance.
// the generic ES5 class definition
function MyEl() {
// as replacement for super()
HTMLElement.call(this);
// Throws: this DOM object constructor
// cannot be called as a function
}
MyEl.prototype = Object.create(
HTMLElement.prototype,
{constructor: {value: MyEl}}
);
// this is going to fail already
new MyEl();
// even dropping the super call
document.body.appendChild(new MyEl);
// Throws: Failed to execute 'appendChild' on 'Node':
// parameter 1 is not of type 'Node'
This is not a problem if elements are created through the DOM, where the constructor instance would be already one from native DOM-land.
However, it’s a huge deal if custom elements are created via JS.
A simple workaround, that in all honest doesn’t look so great but it does the job, is the constructor
context upgrade through the super()
call.
// ES6/2015
class MyEl extends HTMLElement {
constructor() {
// address the upgraded instance and use it
const self = super();
// perform some operation
self.setAttribute('easy', 'peasy');
// return the upgraded instance
// to overrid the originally created one
return self;
}
}
// ES5 equivalent
function MyEl() {
var self = HTMLElement.call(this);
self.setAttribute('easy', 'peasy');
return self;
}
MyEl.prototype = Object.create(
HTMLElement.prototype,
{constructor: {value: MyEl}}
);
Now you have two options: remember to re-assign the result of the super
call and return such result whenever you need a constructor … or use inheritance to simplify your life in a both forward and backward compatible way:
// base class to extend, same trick as before
class HTMLCustomElement extends HTMLElement {
constructor(_) { return (_ = super(_)).init(), _; }
init() { /* override as you like */ }
}
// create any other class inheriting HTMLCustomElement
class MyElement extends HTMLCustomElement {
init() {
// just use `this` as regular
this.addEventListener('click', console.log);
// no need to return it
}
}
// ES5 equivalent
function MyElement(_) {
_ = HTMLCustomElement.call(_ || this);
_.init();
return _;
}
MyElement.prototype = Object.create(
HTMLCustomElement.prototype,
{constructor: {value: MyElement}}
);
Remember, by specifications super()
alredy returns the right instance and upgrade the current constructor scope with a this
context, so this solution is actually pretty standard compliant.
The only non fully standard thing is the optional argument used only when needed through the polyfill, an argument that won’t affect anyhow the usage of the native custom element ability.
Reacting to attributes changes
V0
was probably too greedy so that V1
specified a public static property that should reference a list of properties to watch.
class MyDom extends HTMLElement {
// returned as read/only array of properties to watch
static get observedAttributes() {
return ['country'];
}
// invoked only when a watched property changes
attributeChangedCallback(name, oldValue, newValue) {
// react to changes for name
alert(name + ':' + newValue);
}
}
var md = new MyDom();
// nothing hapoppens
md.setAttribute('test', 'nope');
// alerted: country: UK
md.setAttribute('country', 'UK');
Connect and disconnect elements
These two callback are great to setup or teardown listeners, classes, anything related to the surrounding DOM.
class WordsCounter extends HTMLElement {
// executed once when live on DOM
connectedCallback() {
this.textCounter =
this.parentNode.split(/\s+/).length;
this.addEventListener('mouseover', this.highlight);
}
// executed when erased, disconnected, destroyed
disconnectedCallback() {
this.removeEventListener('mouseover', this.highlight);
}
// generic method used as handler
highlight(e) {
e.currentTarget.classList.add('highlight');
}
}
Extending native elements
One of the most powerful and progressive enhancement oriented techniques described by specs, is the ability to create custom elements that act like native ones.
All its needed, is to use the third argument during definition, and a second one during elements creation.
// native class extend
class ButtonCounter extends HTMLButtonElement {
connectedCallback() {
this.textContent = 0;
this.addEventListener('click', this.increment);
}
increment(e) {
// remember, since about ever
// e.currentTarget is the element you added
// the listener to, in this case the instance itself
e.currentTarget.textContent++;
}
}
// define passing the third options parameter
customElements.define(
'button-counter',
ButtonCounter,
{extends: 'button'}
);
// create a new counter button
document.body.appendChild(
new ButtonCounter()
);
Compatibility
As mentioned at the beginning, the polyfill is based on the older, battle tested and production used V0
API. Since the V1
has been approved, soon every browser will switch native, instead of polyfill, with a possible exception for WebKit based browsers that will go “hybrid mode“, meaning non native extends will pass through the poly, while others will pass through the native customElements
.
WebKit is indeed the only vendor that decided to not listen to developers avoid extending native built-ins … but specs are specs, and my polyfill job is to respect them.
In few words, every V0
compatible browser has been also tested against V1
updates, including:
Desktop
- Chrome
- Firefox
- IE 8 or greater (please read about IE8 caveats)
- Safari
- Opera
Mobile
- iOS 5.1 or greater
- Android 2.2 or greater
- FirefoxOS 1.1 or greater
- KindleFire 3 or greater
- Windows Phone 7 or greater
- Opera Mobile 12 or greater
- Blackberry OS 7* and OS 10
- webOS 2 or LG TV
- Samsung Bada OS 2 or greater
- NOKIA Asha with Express Browser
Enjoy Custom Elements and their power!