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.

Custom Elements npm dependencies

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:

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:

// 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

Mobile

Enjoy Custom Elements and their power!

Andrea Giammarchi

Fullstack Web Developer, Senior Software Engineer, Team Leader, Architect, Node.js, JavaScript, HTML5, IoT, Trainer, Publisher, Technical Editor