Component-based UI is all the rage these days. Did you know that the web has its own native component module that doesn't require the use of any libraries? True story! You can write, publish, and reuse single-file components that will work in any* good browser and in any framework (if that's your bag).
In our last post, we learned about lit-html
, a new functional UI library from Google, and it's associated custom-element base class LitElement
.
Let's Build Web Components! Part 5: LitElement
Benny Powers ๐ฎ๐ฑ๐จ๐ฆ ใป Oct 22 '18
Today we'll implement <gluon-lazy-image>
using @ruphin's Gluon library. Like LitElement
, Gluon components use lit-html
to define their templates, but the Gluon base class is much "closer to the metal": it prefers to remain lightweight, leaving fancy features like observed or typed properties up to the user.
If you didn't catch last week's article on lit-html and LitElement, take a look now before we dive in.
<gluon-lazy-image>
- Element Template
- Properties and Attributes
- Rendering and Lifecycle
- Other Niceties
- Complete Component
<gluon-lazy-image>
Our refactor of <gluon-lazy-image>
will be, as you might have expected, a mashup of the vanilla <lazy-image>
component with <lit-lazy-image>
from last week. Let's start by importing our dependencies and defining our class.
import { GluonElement, html } from '/node_modules/@gluon/gluon/gluon.js';
class GluonLazyImage extends GluonElement {/*..*/}
customElements.define(GluonLazyImage.is, GluonLazyImage);
One small convenience to notice right off the bat is that Gluon prepares a static is
getter for us that returns the camel-cased class name. It's a small kindness, but will make refactoring easier if we ever decided to change our element's name. Of course, if we wanted to override the element name, we could just override the static getter.
Next up, we'll define the template in an instance getter:
Element Template
class GluonLazyImage extends GluonElement {
get template() {
return html`<!-- template copied from LitLazyImage -->`;
}
}
Properties and Attributes
For the properties, we'll implement observedAttributes
and property setters ourselves, just like we did with vanilla <lazy-image>
:
static get observedAttributes() {
return ['alt', 'src'];
}
/**
* Implement the vanilla `attributeChangedCallback`
* to observe and sync attributes.
*/
attributeChangedCallback(name, oldVal, newVal) {
switch (name) {
case 'alt': return this.alt = newVal
case 'src': return this.src = newVal
}
}
Rather than declaring types statically, note how we coerce the value in the setter, this is how you do typed properties with Gluon.
/**
* Whether the element is on screen.
* @type {Boolean}
*/
get intersecting() {
return !!this.__intersecting;
}
Just like in vanilla <lazy-image>
, we'll use guarded property setters to reflect to attributes.
/**
* Image alt-text.
* @type {String}
*/
get alt() {
return this.getAttribute('alt');
}
set alt(value) {
if (this.alt != value) this.setAttribute('alt', value);
this.render();
}
Rendering and Lifecycle
Gluon elements have a render()
method which you call to update the element's DOM. There's no automatic rendering, so you should call render()
in your property setters. render()
batches and defers DOM updates when called without arguments, so it's very cheap.
set intersecting(value) {
this.__intersecting = !!value;
this.render();
}
set src(value) {
if (this.src != value) this.setAttribute('src', value);
this.render();
}
render()
returns a promise. You can also force a synchronous render with render({ sync: true })
.
The notion of component lifecycle is similarly simplified. Rather than introduce new callbacks like LitElement
does, if you want to manage your element's DOM etc, you just wait on the render()
promise.
const lazyImage = document.querySelector('gluon-lazy-image');
(async () => {
// Force and wait for a render.
await lazyImage.render();
// Do whatever you need to do with your element's updated DOM.
console.log(lazyImage.$.image.readyState);
})();
Other Niceties
Gluon will pack your element's $
property with references to id'd elements in the shadow root at first render. So in our case we could get lazyImage.$.image
or lazyImage.$.placeholder
if we needed references to the inner image or placeholder elements.
Also, like LitElement
you can override the createRenderRoot
class method to control how your component renders. Return this
to render your component's DOM to the Light DOM instead of in a shadow root:
class LightElement extends GluonElement {
get template() {
return html`Lightness: <meter min="0" max="1" value="1"></meter>`;
}
createRenderRoot() {
return this;
}
}
Complete Component
import { GluonElement, html } from 'https://unpkg.com/@gluon/gluon/gluon.js?module';
const isIntersecting = ({isIntersecting}) => isIntersecting;
class GluonLazyImage extends GluonElement {
get template() {
return html`
<style>
:host {
position: relative;
}
#image,
#placeholder ::slotted(*) {
position: absolute;
top: 0;
left: 0;
transition:
opacity
var(--lazy-image-fade-duration, 0.3s)
var(--lazy-image-fade-easing, ease);
object-fit: var(--lazy-image-fit, contain);
width: var(--lazy-image-width, 100%);
height: var(--lazy-image-height, 100%);
}
#placeholder ::slotted(*),
:host([loaded]) #image {
opacity: 1;
}
#image,
:host([loaded]) #placeholder ::slotted(*) {
opacity: 0;
}
</style>
<div id="placeholder" aria-hidden="${String(!!this.intersecting)}">
<slot name="placeholder"></slot>
</div>
<img id="image"
aria-hidden="${String(!this.intersecting)}"
.src="${this.intersecting ? this.src : undefined}"
alt="${this.alt}"
@load="${this.onLoad}"
/>
`;
}
static get observedAttributes() {
return ['alt', 'src'];
}
/**
* Implement the vanilla `attributeChangedCallback`
* to observe and sync attributes.
*/
attributeChangedCallback(name, oldVal, newVal) {
switch (name) {
case 'alt': return this.alt = newVal
case 'src': return this.src = newVal
}
}
/**
* Whether the element is on screen.
* Note how we coerce the value,
* this is how you do typed properties with Gluon.
* @type {Boolean}
*/
get intersecting() {
return !!this.__intersecting;
}
set intersecting(value) {
this.__intersecting = !!value;
this.render();
}
/**
* Image alt-text.
* @type {String}
*/
get alt() {
return this.getAttribute('alt');
}
set alt(value) {
if (this.alt != value) this.setAttribute('alt', value);
this.render();
}
/**
* Image URI.
* @type {String}
*/
get src() {
return this.getAttribute('src');
}
set src(value) {
if (this.src != value) this.setAttribute('src', value);
this.render();
}
/**
* Whether the image has loaded.
* @type {Boolean}
*/
get loaded() {
return this.hasAttribute('loaded');
}
set loaded(value) {
value ? this.setAttribute('loaded', '') : this.removeAttribute('loaded');
this.render();
}
constructor() {
super();
this.observerCallback = this.observerCallback.bind(this);
this.intersecting = false;
this.loading = false;
}
connectedCallback() {
super.connectedCallback();
this.setAttribute('role', 'presentation');
this.initIntersectionObserver();
}
disconnectedCallback() {
super.disconnectedCallback();
this.disconnectObserver();
}
/**
* Sets the `intersecting` property when the element is on screen.
* @param {[IntersectionObserverEntry]} entries
* @protected
*/
observerCallback(entries) {
if (entries.some(isIntersecting)) this.intersecting = true;
}
/**
* Sets the `loaded` property when the image is finished loading.
* @protected
*/
onLoad(event) {
this.loaded = true;
// Dispatch an event that supports Polymer two-way binding.
this.dispatchEvent(new CustomEvent('loaded-changed', {
bubbles: true,
composed: true,
detail: { value: true },
}));
}
/**
* Initializes the IntersectionObserver when the element instantiates.
* @protected
*/
initIntersectionObserver() {
// if IntersectionObserver is unavailable, simply load the image.
if (!('IntersectionObserver' in window)) return this.intersecting = true;
// Short-circuit if observer has already initialized.
if (this.observer) return;
// Start loading the image 10px before it appears on screen
const rootMargin = '10px';
this.observer = new IntersectionObserver(this.observerCallback, { rootMargin });
this.observer.observe(this);
}
/**
* Disconnects and unloads the IntersectionObserver.
* @protected
*/
disconnectObserver() {
this.observer.disconnect();
this.observer = null;
delete this.observer;
}
}
customElements.define(GluonLazyImage.is, GluonLazyImage);
The file comes in at 190 LOC (diff), equivalent to the vanilla component, which makes sense considering Gluon's hands-off approach.
Conclusions
If you're looking for a custom element base class that doesn't hold your hand, but gives you the power of lit-html for templating, Gluon
is a great choice!
Pros | Cons |
---|---|
Super lightweight and unopinionated | You need to implement many high-level features yourself |
Based on the web components standards, so there are few specific APIs to learn | Simplistic lifecycle model means there's potential for lots of repetition. |
We've seen how Gluon components straddle the boundary between totally vanilla low-level APIs and library conveniences. Join us next time for something completely different as we dive into one of the most fascinating web component libraries yet published - hybrids
.
See you then ๐
Would you like a one-on-one mentoring session on any of the topics covered here?
Acknowledgements
It's a pleasure to once again thank @ruphin for donating his time and energy to this blog series and this post in particular.
Check out the next article in the series
Top comments (2)
Hello Benny! Nice to hear that the hybrids is the next library on your map! Yesterday I gave a talk at a great ConFrontJS conference in Warsaw about the library. It will be available on YouTube soon, but before that, you can read a presentation, that I prepared. It might help you to even better understand architecture decisions behind it.
You can find it here: slides.com/smalluban/future-with-w...
As you know, you are very welcome to ask any questions during article creation :)
Awesome. This will be very helpful. I'm excited to dive in to hybrids. I'm planning on taking longer than just one week to prepare my writeup, since I'm traveling now. Expect my questions soon