D&D5e, Web-components, & Performance

tl;dr — web-components (edit: with shadowRoots) are kind of slow.

The graph shows the average of five trials (post warmup) in Chrome on my Macbook Pro (it’s a maxed-out 2015 model). I saw nothing to suggest that a larger number of trials would significantly change the results.

Over the long weekend I was hoping to play some D&D with my kids (it never ended up happening). One of the things I find pretty frustrating with the new (well, 2014) Players Handbook is that the spells are all mixed together in strict alphabetical order. (It’s not like earlier editions were better organized. It’s also not clear how they could really fix this, since any arrangement will either require repeating spell descriptions or some kind of tedium.) So to figure out what spells are available to, say, a first level Druid, and what they do, you need to do a lot of page-flipping and cross-referencing.

As an aside, in the late-lamented DragonQuest (SPI, 1980), magic is divided up into “colleges” with a given character having access to the spells of only one college. Each college, along with its spells, is described in one place, with the only page-flipping being required for a relatively small number of spells that are common between colleges. A typical college has access to 20-30 different spells which are thematically unified (e.g. Adepts of the College of Fire have fire-related spells).

This kind of organization is not possible for D&D because the overlap between classes is so great. Sorcerors essentially get a subset of Wizard spells, as do Bards. And so on.

So it struck me that some kind of easily filterable list of spells which let you decide how you wanted them sorted and what you wanted shown would be handy, and this seemed like a nice test for b8r’s web-component system.

Now, b8r is the third rewrite of a library I originally developed with former colleagues for USPTO. Initially we had a highly convoluted “data-table” (not an original name, of course) that essentially let you point a descriptive data-structure at an array and get a table with all kinds of useful bells and whistles. This quickly became unmanageable, and was particularly horrible when people started using it to create quite simple tables (e.g. essentially short lists of key/value pairs) and then add fancy behaviors (such as drag-reordering) to them.

So, I developed a much simpler binding system (“bind-o-matic”) that allowed you to do fine-grained bindings but didn’t try to do bells and whistles, and it quickly proved itself better even for the fancy tables. b8r‘s data-binding is the direct descendant of bind-o-matic.

Anyway, all-purpose tables are hard. So I looked around for something off-the-shelf that would just work. (I’m trying to purge myself of “not invented here” syndrome, and one of the joys of web-components is that they’re supposed to be completely interoperable, right?) Anyway, the closest thing I could find (and damn if I could find a compelling online demo of any of them) had been abandoned, while the next closest thing was “inspired by” the abandoned thing. Not encouraging.

So I decided to write a minimal conceptual set of custom elements — in essence placeholder replacements for <table> <tr> <td> and <th> — which would have zero functionality (they’d just be divs or spans with no behavior) that I could later add CSS and other functionality to in order to get desired results (e.g. the ability to switch a view from a virtual grid of “cards” to a virtual table with a non-scrolling header and resizable columns — i.e. “the dream” of data-table implementations, right?

Now, I have implemented a function named makeWebComponent that will make you a custom element with one line of code, e.g. makeWebComponent('foo-bar', {}). So I made a bunch of components exactly that way and was horrified to see that my test page, which had been loading in ~1000ms was suddenly taking a lot longer. (And, at the time it was loading b8r from github and loading the spell list from dnd5eapi.co and then making an extra call for each of the 300-odd spells in the list.)

So, I ended up implementing my interactive D&D spell list using old school b8r code and got on with my life.

Hunting down the problem

I’m pretty enthusiastic about web-components, so this performance issue was a bit of a shock. So I went to the bindinator benchmark page (which lets me compare the performance of b8r to special-purpose vanilla javascript code doing the same thing with foreknowledge of the task.

In other words, how fast is b8r compared with javascript hand-coded to do the exact same thing in a completely specific way? (An example of how unfair the comparison can be is swapping two items in a large array and then updating the DOM vs. simply moving the two items.)

So the graph at the top of the page compares creating a table of 10,000 rows with a bunch of cells in each row using vanilla js to b8r, and then the other three columns show b8r with two simple <a> tags replaced with <simple-span>, where <simple-span> is defined using makeWebComponent vs. hand-coded (but still with a style node) vs. hand-coded (with nothing except a slot to contain its children).

class SimpleSpan extends HTMLElement {
  constructor() {
    super();

    // const style = document.createElement('style');
    // style.textContent = ':host { font-style: italic; }';
    const shadow = this.attachShadow({mode: 'open'});
    const slot = document.createElement('slot');
    // shadow.appendChild(style);
    shadow.appendChild(slot);
  }
}
window.customElements.define('simple-span', SimpleSpan);

Above is the hand-coded <simple-span> so you can see exactly what I’m doing and second-guess me. The commented-out lines are the difference between the fourth and fifth columns.

I should add that I tried rewriting the above code to use cloneNode(true) instead of creating the new nodes in the constructor, but performance was no different in this case. If the nodes being created were more complex there would likely be an advantage. (I make extensive use of cloneNode(true) in b8r since past experimentation showed a benefit, and makeWebComponent makes use of it.)

I should add that even the simplest component made with makeWebComponent has a style node because Google’s best practices suggest that all custom elements should support the hidden attribute, and doing this with styles is by far the simplest (and I would hope the most performant) way to do this.

It also occurred to me that having the <style> contain :host { font-style: italic; } might be more expensive that :host([hidden]) { display: none; } but, again, that was a wash. Similarly, it occurred to me that initializing the shadowRoot as {mode: 'closed'} might help. It didn’t.

So, this appears to show that the overhead for just two trivial custom elements in each of 10,000 rows is comparable to the overhead for b8r in its entirety for the entire table row. Now, b8r is creating a given table row by cloning, ad-hoc, the hierarchy it’s using as a template, and then querying that hierarchy to find bound elements and then inserting values as appropriate.

When you consider the kinds of uses to which browsers put custom elements internally (e.g. <video> and <input type=”data”>) these are generally not something you’re going to have 20,000 of in the DOM at once. It’s easy, however, to imagine wanting every item in a pretty huge list to include a custom checkbox or popup menu. It’s worth knowing that even simple custom elements have a non-trivial overhead (and we’re talking 1ms for 20 of them on a fast, high-end laptop).

Addendum

I didn’t realize that you could create a web-component with no shadowRoot. (This isn’t exactly something anyone makes obvious — I’ve found literally zero examples of this in tutorials, etc.)

If you do this, the performance issue mostly goes away.

Now, you do lose all the “benefits” of the shadow DOM but you gain performance and still can attach style rules to custom (unique!) tagNames, which makes managing the resulting CSS easier. This is particularly nice given my desire to replace b8r components with custom elements, since there needn’t be any performance overhead (you can “seal” the styling in a container with just one shadowRoot rather than ten thousand instances).