upgraded-component

A simple base class for using native web components

Usage no npm install needed!

<script type="module">
  import upgradedComponent from 'https://cdn.skypack.dev/upgraded-component';
</script>

README

<upgraded-component>

UpgradedComponent is a simple and accessible base class enabling the use of native web components. It has no dependencies.

The class brings various features to make your components predictable and maintainable. Encapsulate your HTML and styles in a shadow root, manage state using properties, tap into lifecycle methods, and more.

Additionally, UpgradedComponent implements the same light-weight virtual dom used in reef, built by Chris Ferdinandi. The result is lightning fast render times (under a millisecond)! โšกโšกโšก

๐Ÿ•น Table of Contents

  1. Getting Started
  2. Install
  3. API
  4. Browser Support
  5. Under the Hood

Getting Started

Creating a new component is easy. Once you've installed the package, create your first component:

// fancy-header.js

import { UpgradedComponent, register } from "./upgraded-component" // include `.js` for native modules

class FancyHeader extends UpgradedComponent {
  static get styles() {
    return `
      .is-fancy {
        font-family: Baskerville; 
        color: fuchsia; 
      }
    `
  }

  render() {
    return `<h1 class='is-fancy'><slot></slot></h1>`
  }
}

// No need to export anything as custom elements aren't modules.

register("fancy-header", FancyHeader)

Import or link to your component file, then use it:

<fancy-header>Do you like my style?</fancy-header>

You can even use it in React:

import React from "react"
import "./fancy-header"

const SiteBanner = props => (
  <div class="site-banner">
    <img src={props.src} alt="banner" />
    <fancy-header>{props.heading}</fancy-header>
  </div>
)

Install

You can install either by grabbing the source file or with npm/yarn.

NPM or Yarn

Install it like you would any other package:

$ npm i upgraded-component
$ yarn i upgraded-component

Then import the package and create your new component, per Getting Started above. ๐ŸŽ‰

Source

IIFE (browsers) / ES Module / CommonJS

Import directly:

// fancy-header.js

import { UpgradedComponent, register } from "./upgraded-component.js"

Then link to your script or module:

<script type="module" defer src="path/to/fancy-header.js"></script>

API

UpgradedComponent has its own API to more tightly control things like rendering encapsulated HTML and styles, tracking renders via custom lifecycle methods, and using built-in state via upgraded class properties.

Of course, it also extends HTMLElement, enabling native lifecycle callbacks for all extenders. Be sure to read about the caveats in the native callbacks section below.

Render

You can render HTML into your component shadow root by creating the method render, which should return stringified HTML (it can also be a template string):

render() {
  const details = { name: "Joey", location: "Nebraska" }
  return `Greetings from ${details.location}! My name is ${details.name}.`
}

Styles

Similar to above, to add encapsulated styles, all you need to do is create a static getter called styles that returns your stringified stylesheet:

static get styles() {
  return `
    :host {
      display: block;
    }

    .fancy-element {
      font-family: Comic Sans MS;
    }
  `
}

Properties

Properties are integral to UpgradedComponent. Think of them as informants to your component's render state, similar to how state works in React.

To add properties, create a static getter called properties that returns an object, where each entry is the property name (key) and configuration (value). Property names should always be camelCased.

Example:

static get properties() {
  return {
    myFavoriteNumber: {
      default: 12,
      type: "number",
    },
    myOtherCoolProp: {
      default: (element) => element.getAttribute("some-attribute"),
      type: "string",
      reflected: true,
    }
  }
}

Configuration Options

The configuration is optional. Simply setting the property configuration to an empty object - {} - will be enough to upgrade it.

If you wish to enumerate the property with more detail, these are the options currently available:

  • default (string|function): Can be a primitive value, or callback which computes the final value. The callback receives the this of your component, or the HTML element itself. Useful for computing from attributes or other methods on your component (accessed via this.constructor).
  • type (string): If given, compares with the typeof evaluation of the value. Default values are checked, too.
  • reflected (boolean): Indicates if the property should reflect onto the host as an attribute. If true, the property name will reflect in kebab-case. E.g., myProp becomes my-prop.

By default, all entries to properties will be upgraded with internal accessors, of which the setter will trigger a render, componentPropertyChanged, and componentAttributeChanged (if reflected). See lifecycle methods below.

Managed Properties

There's also the option to skip accessor upgrading if you decide you'd rather control that logic yourself. This is referred to as a 'managed' property.

Here's a quick example:

static get properties() {
  return {
    // NOTE: This will be ignored!
    cardHeadingText: { type: "string", default: "Some default" }
  }
}

constructor() {
  super()

  // provide a default value for the internal property
  this._cardHeadingText = "My cool heading"
}

// Define accessors

set cardHeadingText(value) {
  if (!value || value === this.cardHeadingText) return

  this.validateType(value)

  const oldValue = this.cardHeadingText
  this._cardHeadingText = value

  this.componentPropertyChanged("cardHeadingText", oldValue, value)
  this.setAttribute("card-heading-text", value)
  this.requestRender()
}

get cardHeadingText() {
  return this._cardHeadingText
}

Worth noting is that setting your managed property via properties won't do anything so long as you've declared your own accessors, as indicated above.

Because the property is managed, you can optionally then tap into internal methods to re-create some or all of the logic included in upgraded properties. Note that requestRender is asynchronous. See Internal Methods and Hooks below.

Lifecycle

As mentioned previously, UpgradedComponent provides its own custom lifecycle methods, but also gives you the option to use the native callbacks as well. There is one caveat to using the native callbacks, though.

The purpose of these is to add more developer fidelity to the existing callbacks as it pertains to the render/update lifecycle. See using native lifecycle callbacks for more details.

Methods

  • componentDidConnect: Called at the beginning of connectedCallback, when the component has been attached to the DOM, but before the shadow root and component HTML/styles have been rendered. Ideal for initializing any internal properties or data that need to be ready before the first render.

  • componentDidMount: Called at the end of connectedCallback, once the shadow root / DOM is ready. Ideal for registering DOM events or performing other DOM-sensitive actions.

  • componentDidUpdate: Called on each render after componentDidMount. This includes: when an upgraded property has been set or requestRender was called.

  • componentPropertyChanged(name, oldValue, newValue): Called each time a property gets changed. Provides the property name (as a string), the old value, and the new value. If the old value matched the new value, this method is not triggered.

  • componentAttributeChanged(name, oldValue, newValue): Called by attributeChangedCallback each time an attribute is changed. If the old value matched the new value, this method is not triggered.

  • componentWillUnmount: Called by disconnectedCallback, right before the internal DOM nodes have been cleaned up. Ideal for unregistering event listeners, timers, or the like.

Q: "Why does UpgradedComponent use lifecycle methods which seemingly duplicate the existing native callbacks?"

A: The primary purpose, as mentioned above, is adding more fidelity to the component render/update lifecycle in general. Another reason is for naming consistency and familiarity. As a developer who uses React extensively, I love the API and thought it made sense to mimic (in no subtle terms) the patterns established by the library authors.

Using Native Lifecycle Callbacks

UpgradedComponent piggybacks off the native lifecycle callbacks, which means if you use them, you should also call super to get the custom logic added by the base class. This is especially true of connectedCallback and disconnectedCallback, which triggers the initial render of any given component and DOM cleanup steps, respectively.

Here's a quick reference for which lifecycle methods are dependent on the native callbacks:

  • ๐Ÿšจ connectedCallback: super required
    • Calls componentDidConnect
    • Calls componentDidMount
  • ๐Ÿณ attributeChangedCallback
    • Calls componentAttributeChanged
  • ๐Ÿณ adoptedCallback
    • TBD, no methods called.
  • ๐Ÿšจ disconnectedCallback: super required
    • Calls componentWillUnmount

Calling super is a safe bet to maintain backwards compatibility, including the yet-to-be-integrated adoptedCallback. ๐Ÿ™‚

Internal Methods and Hooks

Because of the escape hatches taht exist with having managed properties and calling the native lifecycle callbacks directly, it's necessary to provide hooks for manually rendering your component in some cases.

requestRender

Manually schedules a render. Note that it will be asynchronous.

If you need to track the result of your manual requestRender call, you can set an internal property and checking its value via componentDidUpdate like so:

componentDidUpdate() {
  if (this._renderRequested) {
    this._renderRequested = false
    doSomeOtherStuff()
  }
}

someCallbackMethod() {
  this.doSomeStuff()
  this._renderRequested = true
  this.requestRender()
}

componentId

This is an internal accessor that returns a unique identifier. E.g., 252u296xs51k7p6ph6v.

validateType

The internal method which compares your property type. If you have a managed property that is reflected to the host, it's possible that the attribute can be set from the outside too. You can use this to validate the computed result (e.g., parseInt on the value, if you expect the type to be a "number").

DOM Events

To add event listeners, it's like you would do in any ES6 class. First, bind the callback in your component's constructor.

constructor() {
  this.handleClick = this.handleClick.bind(this)
}

Then you can register events using addEventListener in your componentDidMount lifecycle method, and likewise, deregister events using removeEventListener in your componentWillUnmount lifecycle.

handleClick() {
  // bound handler
}

componentDidMount() {
  this.button = this.shadowRoot.querySelector(".my-button")
  this.button.addEventListener("click", this.handleClick)
}

componentWillUnmount() {
  this.button.removeEventListener("click", this.handleClick)
}

Browser Support

This package uses symbols, template strings, ES6 classes, and of course, the various pieces of the web component standard. The decision to not polyfill or transpile is deliberate in order to get the performance boost of browsers which support these newer features.

To get support in IE11, you will need some combination of Babel polyfill, @babel/preset-env, and webcommponentsjs. For more details on support, check out the caniuse article which breaks down the separate features that make up the web component standard.

Enabling transpiling & processing: If you use a bundler like webpack, you'll need to flag this package as needing processing in your config. For example, you can update your exclude option in your script processing rule like so:

module.exports = {
  // ...
  module: {
    rules: [
      // ...
      {
        test: /\.js$/,
        exclude: /node_modules\/(?!upgraded-component)/,
        loader: "babel-loader",
      },
    ],
  },
}

Under the Hood

A few quick points on the design of UpgradedComponent:

Technical Design

The goal of UpgradedComponent is not to add special features. Rather, it's goal is to enable you to use web components with the tools that already exist in the browser. This means: no decorators, no special syntax, and no magic. Those would be considered pluggable features akin to webpack-contrib.

Rendering

Rendering for UpgradedComponent is a multi-step process.

  1. DOM: Rendering is handled using a small virtual DOM (VDOM) implementation, almost identical to the one used in reef. The main reasoning here is to reduce package size and make rendering cheap. Initial rendering typically takes a millisecond or less, with subsequent re-renders taking a fraction of that.

  2. Scheduling: All renders, with the exception of first render, are asynchronously requested to happen at the next animation frame. This is accomplished using a combination of postMessage and requestAnimationFrame. Read more about those here and here.

TODO: Batching multiple property changes into a single render. Unfortunately, every single property change triggers a re-render. This isn't horrible right now since re-renders are cheap, but it would improve performance in more complex cases.


If you like the project or find issues, feel free to contribute or open issues! <3