August 9, 2019

No-polyfill HTML 5 Web Components in Vanilla JS

The increasingly supported Web Components v1 API allows very flexible use of the DOM by declaring custom tags and defining their behavior, among other things. However, its use in production as of 2019 requires polyfill scripts and I’m shy about adding those to my production builds without careful consideration. In this post, I explore the older and simpler way to achieve similar results with backwards and forwards compatible strategies.

Custom HTML Element Attributes

Discussions at WHATWG, WICG and W3C debate custom attribute names. Some favor data-* forever, ignoring that the verbosity pushed some projects like Angular (ng-*) and Vue (v-*) to ignore the standard entirely.

My conclusion from reading those threads is that the least cumbersome way of guaranteeing compatibility with future HTML and XML versions is to prefix otherwise aribtrary names with an underscore (_), which works in all current browsers (down to MSIE 8, Opera 12, etc.)

Unobtrusive JavaScript / Progressive Enhancement

Back in the jQuery glory days, something usable on its own like a form element would be quietly tagged with a special ID or CSS class to mark it for later enhancement by a script. If JavaScript was available and the script happened to run properly on a given browser, the user would get the enhancement.

Rather than polluting the CSS namespace, since we can now select based on any attribute we can mark an element as being the root node of a JavaScript enhancement with a data attribute or say _mycomponent="true". Configuration options could use more custom attributes as well.

Web components could then select those nodes on DOMContentLoaded for initialization purposes. (Or perhaps use a MutationObserver to detect and modify the nodes earlier and also whenever new ones get inserted later.)

Components could also give developers the flexibility of initializing more instances at any time based on a list of DOM nodes. For example:

// Slower than getElementsByClassName() but this is an one-time operation.
Mycomponent.init_nodes(document.querySelectorAll('[_mycomponent="true"]'));

Custom HTML 5 Elements

If a tag or section of the DOM tree is not useful without its accompanying script, it would make more sense to use a custom element with CSS display: none by default, later revealed by the script. Thanks to the HTML5 HTMLUnknownElement which inherits from HTMLElement, unknown tags are perfectly valid and behave almost like <div> or <span>, with a few simple rules:

  • To guarantee compatibility with future HTML versions, the tag name must include at least one hyphen (-), just like full-blown web components.

  • It must be a block and not a self-closing tag, i.e. <my-thing></my-thing> is valid, <my-thing /> is not.

  • Its CSS display property must be specified explicitly.

Thus a valid custom element without any special declaration might look like this:

<date-range _begin="2019-01-01" _end="2019-12-31"></date-range>

For bonus points, that block instead of being hidden, could contain an alternative for browsers without JavaScript or for which the component fails to load. This would be tricky to get right for full browsers though (the alternate content would appear until the component replaces its content).

Form Processing

A lot of my web components are meant to be high level form inputs. While clearly not as standards-compliant as creating full-blown DOM input elements, a web component could at least extend its root DOM element with .name and .value properties to resemble other inputs. The latter would be defined using Object.defineProperty() in order to generate its state’s JSON representation only on demand. (I also throw in .valueobj for the non-serialized data.)

Other scripts looking for form data cannot rely on the form.elements list: some inputs may be part of a component’s private UI and the component itself wouldn’t be in the list. (This is where that shadow DOM would be handy.) To stay efficient, my web components add custom attribute _control to their root nodes and _shadow to any private control children. Then, this one-liner does roughly what form.elements used to while avoiding children of control-type components, pretty efficiently:

// This is a slow selection method, but we're talking about submit event handling, not frequent use.
form.querySelectorAll('[_control], input:not([_shadow]), select:not([_shadow]), textarea:not([_shadow])');

Methods and Callbacks

Component modules could expose any number of methods, taking an instance’s root DOM node as their first arguments. (Extending DOM nodes incurs a cost, so I keep it to a minimum.) For example:

Mycomponent.update(some_node, ["width", 250]);

If a calling script needs to be notified of certain things (most likely state changes), components could offer a callback interface:

Mycomponent.on(some_node, "change", function () {
	// Do something with some_node.value
});

Internal State

Components need to remember their state data, configuration and callbacks. In line with the above, I chose to stash those in a single __data property of its root DOM node, so that the DOM node itself can be used as the unique identifier to an instance of the component.

Conclusion

It is definitely possible to get a lot of the benefits of web components without browser support for the v1 W3C API nor any polyfills. It is less clean for the developer, but not for end users and given that the polyfills take up 109KB minified, there is a significant end-user reward as of 2019 for developers to consider tolerating the above compromises.

© 2008-2023 Stéphane Lavergne — Please ask permission before copying parts of this site.

Powered by Hugo & Kiss.