DEV Community

Cover image for Our Web component development methodology
Bryan Ollendyke
Bryan Ollendyke

Posted on

Our Web component development methodology

Background

The HAXTheWeb team has been developing web components since Polymer version 2 was released. We bought in (by our estimation) about 2 years early (this was 2017) and it was extremely painful at the time first learning front end development, then the specs, then the V0 spec only to have to migrate to V1 spec and LitElement once it became clear that V1 was gaining full adoption.

In that time, we've created over 400 web components, published almost 300 npm packages, 99% of which are web components, launched 4 major applications using web components (ELMS:LN content, management console, Studio, and HAXcms) as well as the crazy ambitious HAX editor.

What started as a march off the deep end and into the darkness has turned into patterns of design and development that allow us to pull off the impossible with a small team and simple process of on-boarding new creators and creations. Here's some of what we've landed on as far as conventions for performance and sanity in the development of our portfolio.

Tooling

We use a tooling we wrote called WCFactory in our main LRNWebComponents monorepo. In fact, WCFactory is a mono-repo / team collaboration space generator. We use wcf factory to make a new "factory" (aka monorepo for elements) and then wcf element to generate a new element via CLI. While we fork from these, the conventions involved remain the same for anyone on the team:

  • git clone the repo
  • yarn install (we use yarn because it can install things flat, useful in the end)
  • cd elements/what-ever and run yarn start will always produce a demo to work with that element

Anyone just getting started with web components, we recommend Open-WC because of documentation, community, and workflow scale. WCfactory is where you'll end up when you make more than 5 elements across 3 or more devs.

How we web component

We lean on HTMLElement (vanilla) and LitElement base classes in our development. Questions for vanilla vs LitElement:

  • Is it heavily attribute / property driven? Will we update values in the DOM frequently? LitElement +1
  • Is it a singleton / managing a specific aspect of the page for other elements? HTMLElement +1
  • Is it a minor design asset which is largely unchanged but we use a lot in the page (like a button)? HTMLElement +1 but also, "Existing element of some kind" +1 (we like to reuse things)

SuperClass

We love using SuperClass to mix functionality into an existing element / base class. I wrote previously how we were able to do this with an IntersectionObserverMixin in order to provide lazy loading actions based on the visibility of the element (see code here). The top of a SuperClass looks like this:

const IntersectionObserverMixin = function(SuperClass) {
  // SuperClass so we can write any web component library / base class
  return class extends SuperClass {
Enter fullscreen mode Exit fullscreen mode

And gets implemented like class YourElement extends SomeMixin(HTMLElement) {}. The awesome thing about this is that you can provide this piece of functionality to LitElement base classes too!

setTimeout

setTimeout is a simple way to cheat on microtask timing. For example, let's say we want to apply event listeners to the window when our element is in the page. Maybe it's a Singleton which ensures that we render tooltips and toast messages.

Example

setTimeout() {
  window.addEventListener("show-tooltip", this.showTooltip.bind(this));
  window.addEventListener("show-toast", this.showToast.bind(this));
},0);
Enter fullscreen mode Exit fullscreen mode

The reason we wrap these in the setTimeout is to delay a timing cycle called a microTask. This means that the present execution tree might be processing 10 classes loading at once. If you apply event listeners in your class, your going to make that timing loop have to process those 2 event listeners.

If you just said "so?" your not alone. Most don't get this granular, however consider that your app doesn't use one web component, it uses hundreds or in our case, an unknown quantity in an unknown number of places (see uwc series of posts).

setTimeout allows Javascript to do the following:

  • Finish it's execution loop processing X number of elements
  • When that task is finished, come back and handle the things in the setTimeout

This ensures that the X number of elements (if they all are doing this..) begin to execute in batches without slowing down to attach event listeners (in this example). We do this constantly for EventListener, IntersectionObserver, MutationObserver, state management operations in MobX and others to ensure maximal performance.

CustomEvent and EventListener

We like to apply EventListeners in our constructor:

constructor() {
  super();
  setTimeout(() => {
    this.addEventListener("whatever-changed", this._whateverChanged.bind(this));
  }, 0);
}
Enter fullscreen mode Exit fullscreen mode

We also often wrap things in the setTimeout mentioned in the last heading AND put them in our constructor. Web component constructor is like your starting point / defaults for the thing. We will often place events here that are related to the self.

Warning: You CAN NOT do a this.shadowRoot.querySelector('#idname').addEventListener... at this point because in LitElement shadowRoot isn't available until the firstUpdated life cycle or connectedCallback in HTMLElement.

Binding with window events

Window events lose context of what bound them. You might not care, like if you wanted a window even that on scroll just displayed the x and y position of the screen, who cares. But most of the time, a singleton or other element that's paying attention an event at the window level will want to execute methods within its own context.

In JavaScript you can see this in the original example with:
window.addEventListener("show-toast", this.showToast.bind(this));. The .bind(this) tells JS that whenever this event listener is processed, handle the context of "this" as if it was the present element setting this listener as opposed to the window itself. This makes it a lot easier to have things lazy register or subscribe to the events of other things.

Naming

We recommend naming events things like whatever-changed for the whatever attribute / property. And that this bubble up data as follows:

this.dispatchEvent(
  new CustomEvent(`${propName}-changed`, {
    detail: {
      value: this[propName],
    }
  })
);
Enter fullscreen mode Exit fullscreen mode

Here, propName is whatever you want it to be but the detail of what we're sending will always be accessible in a listener via e.detail.value. Normalizing in this way helps a lot for developer experience across a team of any size.

Property Change records (LitElement)

This is how we boilerplate our updated life-cycle in LitElement. You can mirror similar functionality in HTMLElement by using observedAttributes and attributeChangedCallback.

updated(changedProperties) {
    changedProperties.forEach((oldValue, propName) => {
      /* notify example
      // notify
      if (propName == 'format') {
        this.dispatchEvent(
          new CustomEvent(`${propName}-changed`, {
            detail: {
              value: this[propName],
            }
          })
        );
      }
      */
      /* observer example
      if (propName == 'activeNode') {
        this._activeNodeChanged(this[propName], oldValue);
      }
      */
      /* computed example
      if (['id', 'selected'].includes(propName)) {
        this.__selectedChanged(this.selected, this.id);
      }
      */
    });
  }
Enter fullscreen mode Exit fullscreen mode

In this, you'll see that we have notify example, as in when we notice a property value change, that we'd send up an event in the naming convention in the previously heading.

The observer example, is noticing something change and then running functions internal to our element. This relationship works great with event listeners placed on child elements, changing properties internal to the element your working on, and then running functions that do the processing. This way someone can always jump in and modify a value to have the functionality process (a good general state management approach).

computed is a way of saying "when any of these values change, reprocess everything". It's a shortcut for having lots of methods to handle each individual property change. This is kinda fun in that it uses a [].includes array processor to lazy process all the possible things that would be making the change. In some examples I don't even pass in values I just have a this.__updateSomeStuff(); which would then handle all the internal values directly from the element itself.

CSS Styles

CSS variables are a great way of getting visual tweaks into elements while allowing usage of Shadow DOM to hide things from global overrides. I'd say 90% of our elements use Shadow DOM while ~10% don't for intentional styling reasons.

We try to name our CSS variables like this:
--ELEMENT-NAME-SEMANTICTHING-attributename. For example, if we have an element called fancy-card which has a region on the card for the title / description, we'd make css variables to target things like this:

.description-area {
  background-color: var(--fancy-card-description-background-color, transparent);
}
#title {
  color: var(--fancy-card-title-color, inherit);
}
Enter fullscreen mode Exit fullscreen mode

We're also starting to leverage the ::part spec but it's too early on in our adoption for me to write in depth about it. This CSS tricks articles covers it well so go there :).

dynamic import() of assets

I wrote about Dynamic Element Hydration and how we can use import() to handle the dynamic lazy loading of any element in the DOM, but we also recommend using import() in other places to improve the execution / timing of your web components.

For example; Let's say you've got the following <cool-button> element which in it's shadowRoot looks like this (render is a LitElement convention):

render() {
  return html`
  <another-button-look>
    <button-click-normalize>
      <slot></slot>
    </button-click-normalize>
  </another-button-look>
  `;
}
Enter fullscreen mode Exit fullscreen mode

I can make the element render more rapidly without aFlash of Unstyled Content (FOUC) by doing the following in the element (again, styles is a LitElement convention but you can use normal css in your HTMLElement output to do this):

static get styles() {
  return [css`
  another-button-look:not(:defined) {
    display: none;
  }
  `];
}

firstUpdated() {
  setTimeout(() => {
    import("location/of/another-button-look.js");
    import("location/of/button-click-normalize.js");
  }, 0);
}
Enter fullscreen mode Exit fullscreen mode

Another way I could do this based on functionality and timing is see how button-click-normalize is most likely a functional piece as opposed to visual? I could load these separately to get a faster paint for the visual piece, but wait for the next microTask to handle the functional piece which someone wouldn't be able to interact with anyway until they mentally process what the page is.

constructor() {
  super();
  import("location/of/another-button-look.js");
}
firstUpdated() {
  setTimeout(() => {
    import("location/of/button-click-normalize.js");
  }, 0);
}
Enter fullscreen mode Exit fullscreen mode

Breaking out why I'd do it this way (from a timing perspective):

  • constructor will load, all the other js modules will run through their execution tree.
  • It'll notice the dynamic import and kick off an additional thread to handle just that import but keep going
  • When the initial tree is resolved, it'll start rendering, even if our another-button-look keeps downloading assets
  • firstUpdated is processed when the shadowRoot of our element is able to be queried / visible. We setTimeout to delay another microTask beyond the visual paint, and that will then kick off another (async) execution thread to go get our button-click-normalize
  • (async) When another-button-look tree finishes, it'll visually render
  • (async, but probably later) the button-click-normalize is ready to go

Again, these small tweaks in timing have huge impacts over the development of ALL elements in your portfolio. Is it overkill thinking this deeply about timing in one element? Maybe, but it still helps in rendering things faster.

Singleton: portions of app state

I've mentioned "Singleton" multiple times but not actually shown how we handle pieces of system state management via Singletons so here's an example with our Modal.

The "Singleton" will invoke something like this:

// register globally so we can make sure there is only one
window.SimpleModal = window.SimpleModal || {};
// request if this exists. This helps invoke the element existing in the dom
// as well as that there is only one of them. That way we can ensure everything
// is rendered through the same modal
window.SimpleModal.requestAvailability = () => {
  if (!window.SimpleModal.instance) {
    window.SimpleModal.instance = document.createElement("simple-modal");
    document.body.appendChild(window.SimpleModal.instance);
  }
  return window.SimpleModal.instance;
};
Enter fullscreen mode Exit fullscreen mode

Then, when something wants to leverage the modal, it can do it this way:

import "@lrnwebcomponents/simple-modal/simple-modal.js";
constructor() {
  super();
  // could just call it directly without capturing return
  const modal = window.SimpleModal.requestAvailability();
}
// example render with the @ convention to listen for click event
render() {
  return html`<button @click="${this.popSomethingUp}">Click to pop up something</button>`;
}
// fire event, bubble it up to the window where Singleton
// will react to it and make a modal show up
popSomethingUp(e) {
  let p = document.createElement("div");
  p.innerHTML = 'inner content of some thing';
  const evt = new CustomEvent("simple-modal-show", {
    bubbles: true,
    composed: true,
    detail: {
      title: 'Some stuff pulled in',
      elements: { content: p },
      invokedBy: document.getElementById('button1'),
    }
  });
  this.shadowRoot.querySelector('button').dispatchEvent(evt);
}
Enter fullscreen mode Exit fullscreen mode

This is a "Singleton" in that there's only one modal / pop up methodology we want to leverage. This is how we can provide a consistent modal / pop-up experience across an unknown series of applications / elements.

It's also done in such a way that you can leverage the modal directly OR leverage any of our elements and everything just works as pieces. In the event of Singleton's we also attach two properties to our CustomEvent to ensure it reaches the window no matter where it is:

{
  bubbles: true,
  composed: true
}
Enter fullscreen mode Exit fullscreen mode

bubbles you probably know, it tells the event to "bubble" up through elements beyond the child => parent relationship. composed is a new one when working with web components though. It says that events should not just bubble up, but bubble across shadowRoot's. This subtle difference is a big deal for the Singleton but you can leverage this capability in the following ways:

  • Your element intentionally only wants the parent element to have this information (simple-fields, our headless form render NEEDS this or your get value flooding / don't know where a value came from)
  • Your element knows it's got a parent in charge of managing state but doesn't know how many parents there are (map-menu for example, can be nested as deeply as the menu calls for and children don't know how deep they are)
  • You can bubble if you know things are going to be in light-dom while restricting the event escaping fully by not including composed to ensure that your parent element's shadowRoot is effectively a boundary.

Property names

We always ensure that we translate properties and attributes like follows - <your-element custom-attribute-name="whatever"> will ensure that this.customAttributeName is the property containing the attribute value.

In LitElement you can ensure this happens by doing the following in your properties getter:

static get properties() {
  return {
    customAttributeName: {
      type: String,
      attribute: "custom-attribute-name"
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

We also only recommend using the reflect:true flag when you actually need to leverage the value in the CSS of your element (or parent elements that will render the element differently based on the presence of the attribute).

Semantics

Always make the HTML contain the semantic rationale of the element. Things that are not for end-user or downstream developer adoption should be named more functional. For example, we use video-player in content (highly semantic as to what that is) but that tag is primarily driven by a11y-media-player which is more for developers / internal usage.

But we also have ~40 legacy PolymerElement based elements we use and ~50 of our own older elements that are deprecated internally built on PolyerElement. We like to re-use things that get the job done and then our integration methodology allows us to progressively collapse away from Polymer, LitElement, etc without disrupting our properties out in production. For perspective, in 2019 we were about 15% LitElement, 5% vanilla, 80% PolymerElement. As of this writing we are more like 80% LitElement, 10% vanilla and 10% PolymerElement.

We no longer create PolymerElement based elements and two apps that leverage most of our PolymerElement based elements are being phased out over the next year to purely LitElement + vanilla ones. The methodology and web platform make this possible because of that alignment on the semantics of how we name and treat our elements like APIs.

Reuse / base class selection

If you leverage someone else's element, always wrap it in a name that's specific to your project usage. For example, simple-modal leverages paper-dialog which is a PolymerLegacy base class (aka older but still works fine). By wrapping it in our own name, at a later point when we remove paper-dialog and replace it with simple-dialog-element (or whatever) then none of our endpoint integrations will change. Doing things in this way will save you time and headaches later on and is of minimal performance penalty in the download of bytes.

If you want to use LitElement without shadowRoot (and the benefits / issues it can cause) then createRenderRoot() { return this; } on your element will ensure that all render content is in light-dom.

We are not LitElement purists but we highly recommend it because:

  • better developer experience than pure vanilla
  • small / high performance
  • aligned heavily with web platform conventions

Progressive enhancement

Leverage fake slot areas for progressive enhancement purposes. For example, our meme-maker tag doesn't actually have a slot but is implemented with a <img src=.... That way as the page is loading, an image will present itself before the meme capability takes over from being loaded. This is a nice progressive enhancement approach.

This can also be achieved by using slots with names that don't exist. For example:

<my-tag>
  <p>Something I want in the tag</p>
  <p slot="notreal">Content I only want SEO to notice or if JS fails</p>
</my-tag>
Enter fullscreen mode Exit fullscreen mode

In this example the notreal slot isn't actually anywhere in the shadowRoot of my-tag but a generic <slot></slot> exists, allowing the 1st paragraph to render correctly in the hydrated element.

State management

There's a lot of articles written about this (and we get into it a lot on HAXcamp uncode). Generally we follow properties / attributes being passed down and custom events sending data up. In instances where we have a larger application we use Mobx. I'll write up how HAXcms state management works with Mobx in the future as it's a whole thing :).

Discussion

  • What am I missing?
  • What do you not agree with / what can we improve?
  • What questions do we still need to answer?

Top comments (2)

Collapse
 
zenwork profile image
Florian Hehlen

It's great that you took the time to share all your hard-earned experience with scaling web components! A lot of the insights are all about using the standards! It's a real breath of fresh air when one can just read code and not have to look-up some library/framework to understand what it does!

Collapse
 
btopro profile image
Bryan Ollendyke

I need a 100% agree button on dev.to 😁. Yes. Readability and following standards are amazing for mental anguish reduction. I sleep better since we switched to this world