upgraded-component v0.0.6
\<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
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 thethis
of your component, or the HTML element itself. Useful for computing from attributes or other methods on your component (accessed viathis.constructor
).type
(string): If given, compares with thetypeof
evaluation of the value. Default values are checked, too.reflected
(boolean): Indicates if the property should reflect onto the host as an attribute. Iftrue
, the property name will reflect in kebab-case. E.g.,myProp
becomesmy-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 ofconnectedCallback
, 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 ofconnectedCallback
, once the shadow root / DOM is ready. Ideal for registering DOM events or performing other DOM-sensitive actions.componentDidUpdate
: Called on each render aftercomponentDidMount
. This includes: when an upgraded property has been set orrequestRender
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 byattributeChangedCallback
each time an attribute is changed. If the old value matched the new value, this method is not triggered.componentWillUnmount
: Called bydisconnectedCallback
, 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
- Calls
- ๐ณ
attributeChangedCallback
- Calls
componentAttributeChanged
- Calls
- ๐ณ
adoptedCallback
- TBD, no methods called.
- ๐จ
disconnectedCallback
:super
required- Calls
componentWillUnmount
- Calls
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.
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.
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
andrequestAnimationFrame
. 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