This is the sixth chapter of the Writing a JavaScript framework series. In this chapter, I am going to discuss the usefulness of Custom Elements and their possible role in a modern front-end framework’s core.
The series is about an open-source client-side framework, called NX. During the series, I explain the main difficulties I had to overcome while writing the framework. If you are interested in NX please visit the home page: https://nx-framework.com
.
The series includes the following chapters:
- Project structuring
- Execution timing
- Sandboxed code evaluation
- Data binding introduction
- Data Binding with ES6 Proxies
- Custom elements (current chapter)
- Client-side routing
The era of components
Components took over the web in the recent years. All of the modern front-end frameworks – like React, Vue or Polymer – utilize component based modularization. They provide distinct APIs and work differently under the hood, but they all share the following features with many of the other recent frameworks.
- They have an API for defining components and registering them by name or with a selector.
- They provide lifecycle hooks, which can be used to set up the component’s logic and to synchronize the view with the state.
These features were missing a simple native API until recently, but this changed with the finalization of the Custom Elements spec. Custom Elements can cover the above features, but they are not always a perfect fit. Let’s see why!
Custom Elements
Custom Elements are part of the Web Components standard, which started as an idea in 2011 and resulted in two different specs before stabilizing recently. The final version feels like a simple native alternative to component based frameworks instead of a tool for framework authors. It provides a nice high-level API for defining components, but it lacks new non polyfillable features.
If you are not yet familiar with Custom Elements please take a look at this article before going on.
The Custom Elements API
The Custom Elements API is based on ES6 classes. Elements can inherit from native HTML elements or other Custom Elements, and they can be extended with new properties and methods. They can also overwrite a set of methods – defined in the spec – which hook into their lifecycle.
class MyElement extends HTMLElement {
// these are standard hooks, called on certain events
constructor() { ... }
connectedCallback () { ... }
disconnectedCallback () { ... }
adoptedCallback () { ... }
attributeChangedCallback (attrName, oldVal, newVal) { ... }
// these are custom methods and properties
get myProp () { ... }
set myProp () { ... }
myMethod () { ... }
}
// this registers the Custom Element
customElements.define('my-element', MyElement)
After being defined, the elements can be instantiated by name in the HTML or JavaScript code.
<my-element></my-element>
The class-based API is very clean, but in my opinion, it lacks flexibility. As a framework author, I preferred the deprecated v0 API – which was based on old school prototypes.
const MyElementProto = Object.create(HTMLElement.prototype)
// native hooks
MyElementProto.attachedCallback = ...
MyElementProto.detachedCallback = ...
// custom properties and methods
MyElementProto.myMethod = ...
document.registerElement('my-element', { prototype: MyElementProto })
It is arguably less elegant, but it can integrate nicely with both ES6 and pre ES6 code. On the other hand, using some pre ES6 features together with classes can get pretty complex.
As an example, I need the ability to control which HTML interface the component inherits from. ES6 classes use the static extends
keyword for inheritance, and they require the developer to type in MyClass extends ChosenHTMLInterface
.
It is far from ideal for my use case since NX is based on middleware functions rather than classes. In NX, the interface can be set with the element
config property, which accepts a valid HTML element’s name – like button
.
nx.component({ element: 'button' })
.register('my-button')
To achieve this, I had to imitate ES6 classes with the prototype based system. Long story short, it is more painful than one might think and it requires the non polyfillable ES6 Reflect.construct
and the performance killer Object.setPrototypeOf
functions.
function MyElement () {
return Reflect.construct(HTMLElement, [], MyElement)
}
const myProto = MyElement.prototype
Object.setPrototypeOf(myProto, HTMLElement.prototype)
Object.setPrototypeOf(MyElement, HTMLElement)
myProto.connectedCallback = ...
myProto.disconnectedCallback = ...
customElements.define('my-element', MyElement)
This is just one of the occasions when I found working with ES6 classes clumsy. I think they are nice for everyday usage, but when I need the full power of the language, I prefer to use prototypal inheritance.
Lifecycle hooks
Custom Elements have five lifecycle hooks that are invoked synchronously on certain events.
constructor
is called on the element’s instantiation.connectedCallback
is called when
the element is attached to the DOM.disconnectedCallback
is called when the element is detached from the DOM.adoptedCallback
is called when the element is adopted to a new document withimportNode
orcloneNode
.attributeChangedCallback
is called when a watched attribute of the element changes.
constructor
and connectedCallback
are ideal for setting up the component’s state and logic, while attributeChangedCallback
can be used to reflect the component’s properties with HTML attributes and vice versa. disconnectedCallback
is useful for cleaning up after the component instance.
When combined, these can cover a nice set of functionalities, but I still miss a beforeDisconnected
and childrenChanged
callback. A beforeDisconnected
hook would be useful for non-hackish leave animations, but there is no way to implement it without wrapping or heavily patching the DOM.
The childrenChanged
hook is essential for creating a bridge between the state and the view. Take a look at the following example.
nx.component()
.use((elem, state) => state.name = 'World')
.register('my-element')
<my-component>
<p>Hello: ${name}!</p>
</my-component>
It is a simple templating snippet, which interpolates the name
property from the state into the view. In case the user decides to replace the p
element with something else, the framework has to be notified about the change. It has to clean up after the old p
element and apply the interpolation to the new content. childrenChanged
might not be exposed as a developer hook, but knowing when a component’s content mutates is a must for frameworks.
As I mentioned, Custom Elements lacks a childrenChanged
callback, but it can be implemented with the older MutationObserver API. MutationObservers also provide alternatives for the connectedCallback
, disconnectedCallback
and attributeChangedCallback
hooks for older browsers.
// create an observer instance
const observer = new MutationObserver(onMutations)
function onMutations (mutations) {
for (let mutation of mutations) {
// handle mutation.addedNodes, mutation.removedNodes, mutation.attributeName and mutation.oldValue here
}
}
// listen for attribute and child mutations on `MyComponentInstance` and all of its ancestors
observer.observe(MyComponentInstance, {
attributes: true,
childList: true,
subtree: true
})
This might raise some questions about the necessity of Custom Elements, apart from their simple API.
In the next sections, I will cover some key differences between MutationObservers and Custom Elements and explain when to use which.
Custom Elements vs MutationObservers
Custom Element callbacks are invoked synchronously on DOM mutations, while MutationObservers gather mutations and invoke the callbacks asynchronously for a batch of them. This is not a big issue for setup logic, but it can cause some unexpected bugs during cleaning up. Having a small interval when the disposed data is still hanging around is dangerous.
Another important difference is that MutationObservers do not pierce the shadow DOM boundary. Listening for mutations inside a shadow DOM require Custom Elements or manually adding a MutationObserver to the shadow root. If you never heard about the shadow DOM, you can learn more about it here.
Finally, they offer a slightly different set of hooks. Custom Elements have the adoptedCallback
hook, while MutationObservers can listen on text change and child mutations in any depth.
Considering all of these, combining the two to get the best of both worlds is a good idea.
Combining Custom Elements with MutationObservers
Since Custom Elements are not yet widely supported, MutationObservers must be used for detecting DOM mutations. There are two options for using them.
- Building an API on top of Custom Elements and using MutationObservers for polyfilling them.
- Building an API with MutationObservers and using Custom Elements to add some improvements when they are available.
I chose the latter option, as MutationObservers are required to detect child mutations even in browsers with full Custom Elements support.
The system that I will use for the next version of NX simply adds a MutationObserver to the document in older browsers. However, in modern browsers, it uses Custom Elements to set up hooks for the topmost components and adds a MutationObserver to them inside the connectedCallback
hook. This MutationObserver than takes the role of detecting further mutations inside the component.
It looks for changes only inside the part of the document which is controlled by the framework. The responsible code looks roughly like this.
function registerRoot (name) {
if ('customElements' in window) {
registerRootV1(name)
} else if ('registerElement' in document) {
registerRootV0(name)
} else {
// add a MutationObserver to the document
}
}
function registerRootV1 (name) {
function RootElement () {
return Reflect.construct(HTMLElement, [], RootElement)
}
const proto = RootElement.prototype
Object.setPrototypeOf(proto, HTMLElement.prototype)
Object.setPrototypeOf(RootElement, HTMLElement)
proto.connectedCallback = connectedCallback
proto.disconnectedCallback = disconnectedCallback
customElements.define(name, RootElement)
}
function registerRootV0 (name) {
const proto = Object.create(HTMLElement)
proto.attachedCallback = connectedCallback
proto.detachedCallback = disconnectedCallback
document.registerElement(name, { prototype: proto })
}
function connectedCallback (elem) {
// add a MutationObserver to the root element
}
function disconnectedCallback (elem) {
// remove the MutationObserver from the root element
}
This provides a performance benefit for modern browsers, as they only have to deal with a minimal set of DOM mutations.
Conclusion
All-in-all it would be easy to refactor NX to use no Custom Elements without a big performance impact, but they still add a nice boost for certain use cases. What I would need from them to be really useful though is a flexible low-level API and a greater variety of synchronous lifecycle hooks.
If you are interested in the NX framework, please visit the home page. Adventurous readers can find the NX core’s source code in this Github repo.
I hope you found this a good read, see you next time when I’ll discuss client-side routing!
If you have any thoughts on the topic, please share them in the comments.