webcomponents
A quick overview of how webcomponents work. Web components are the umbrella
term for a bunch of technologies, but based on conversations with people who've
used them extensively (>1 year
) the only interesting parts are custom
elements
and shadow dom
.
Status
[ 03/09/15 ]
I first wrote this section as I was searching for reusability patterns. custom elements seemed like a good solution to reusability. After all: smart people were using it and it was aimed at modularity.
After having tried and used custom elements, I'm now against the usage of web components within applications. Custom elements should only be used to implement elements that could have been browser built-ins. For every other use case the OO approach doesn't work well.
Create
Custom elements are registered with a name as the first argument, and a set of options as the second:
const CustomButton = document.registerElement('custom-button', {
prototype: Object.create(window.HTMLButtonElement.prototype),
extends: 'button'
})
The custom-button
can now be called in one of two ways:
// a function was returned
const customButton = new CustomButton()
// an existing element was extended
document.createElement('custom-button')
document.createElement('button', 'custom-button')
and because we're extending we can do in html:
<button is="custom-button">foobar</button>
Manage
Custom elements fire lifecycle events:
.createdCallback() // after element was created
.attachedCallback() // after element was attached to DOM
.detachedCallback() // after element was detached from dom
.attributeChangedCallback(attr, old, nw) // on element attribute change
An example of a reusable self contained updating time element:
let CustomTimeProto = Object.create(window.HTMLTimeElement.prototype)
let timeEls = []
let raf = null
// register element
window.customTimeElement = document.registerElement('custom-time', {
prototype: CustomTimeProto,
extends: 'time'
})
// register the element, so multiple instances use the
// same update (poor man's RAF) loop
CustomTimeProto.createdCallback = function () {
timeEls.push(this)
}
// initialize the update loop
CustomTimeProto.attachedCallback = function () {
if (!raf) raf = setInterval(updateTimeEls, 60 * 1000)
}
// unregister the element, and destroy the update
// loop if it's void
CustomTimeProto.detachedCallback = function () {
const ix = timeEls.indexOf(this)
if (ix !== -1) timeEls.splice(ix, 1)
if (!timeEls.length) raf = null
}
// change the text in the element to show
// how much it diffs compared to the
// `datetime` property on the element
CustomTimeProto.recalc = function () {
const start = this.getAttribute('datetime')
return new Date() - start
}
// iterate over all registered els
// and update the `textContent`
function updateTimeEls () {
timeEls.forEach((time) => {
time.textContent = time.recalc()
})
}
Data sharing in an application
In a real application there's a hierarchy in the way your application is
structured. Components are consumed by views, and views are directed by a
router. Also: data must be retrieved from sources, and to prevent duplicate
calls from happening it's preferable that the data is shared. The
custom-element
module enables attaching multiple listeners to a single event,
which allows a separation between module-level listeners and application-level
listeners.
An important note is that with webcomponents all injected data should be shared
through the attributes, analogous to React's props
.
An example of a flux
application component (not on the module level):
const customElement = require('custom-element')
const emitter = require('@ns/my-event-emitter')
// we're extending the custom element that we defined in the previous
// example to listen to changes in our event emitter. Whenever the
// timezone changes we update the `datetime` attribute with the
// corresponding offset.
var TimezoneTimeElement = customElement(window.customTimeElement)
TimezoneTimeElement.on('attached', function () {
emitter.on('timezone:change', (timezoneOffset) => {
const datetime = this.getAttribute('datetime')
this.setAttribute('datetime', datetime + timezoneOffset)
})
})
document.registerElement('timezone-time-element', TimezoneTimeElement)
High performance components
Sometimes regular dom updates are too slow, and you need a more performant way
of rendering elements. There are two choices for this: virtual-dom
and
webGL
. WebGL is it's own programming language with various nooks and crannies
(you render pixels, not elements) so usually virtual-dom
will be your go-to
tool in these kinds of situations. By using virtual-dom
within web
components you combine high performance data rendering with self-encapsulation.
unresolved questions
- how do you pass data into a complex webcomponent? (don't want enormous lines of json injected into an html property).
- is it possible to expose both a
js
+html
api forvirtual-dom
nodes? That way the amount of render loops can be minimized.
[examples tbd]
Testing
In order to guarantee correctness of self-contained elements, they must be
tested. Examples below are extracted from
github/time-element
:
Test element creation
Create the element and test its attributes
// from document.createElement()
const time = document.createElement('time', 'local-time')
// from constructor
const time = new window.LocalTimeElement()
Test attributes on creation
const time = new window.LocalTimeElement()
assert.equal(time.nodeName, 'TIME')
assert.equal(time.getAttribute('is'), 'local-time')
// also test for blank states, e.g. the behavior if an attribute is not set
Test results based on attributes
After having asserted that the element is indeed created as the right type, you can proceed to check if the content of the element is what was expected based on the input values. Module level components should be self-contained, which means there should be no unwanted side effects (like triggering a router). Examples:
simple
const time = document.createElement('time', 'relative-time')
time.textContent = 'Jun 30'
time.setAttribute('datetime', 'bogus')
assert.equal(time.textContent, 'Jun 30')
complicated
const now = new Date().toISOString();
const root = document.createElement('div')
root.innerHTML = '<time is="relative-time" datetime="' + now + '"></time>'
if ('CustomElements' in window) {
window.CustomElements.upgradeSubtree(root)
}
assert.equal(root.children[0].textContent, 'just now')
Reusable webcomponents
Probably the hardest part of building webcomponents is finding the right abstractions for reusability. Since we're mostly extending prototypes it can be hard to find the correct abstractions for good base classes. In userland this is about the only interface you should need to expose:
document.registerElement('search-sidebar', require('@ns/search-sidebar'))
const sidebar = document.createElement('aside', 'search-sidebar')
sidebar.on('query', () => /*do something*/)