Lately I've been looking for a light-weight library that helps to develop UI components using native browser technologies like Web Components, Shadow DOM etc. I see most of them surviving out there like Angular, React are not completely based on native technologies and not only that they have quite some learning curve. Also, there is always this fear that, am I doing this in the right way?. I found libraries like LitElement compelling but finally I ended up creating something simple, light-weight that focuses only on solving the component building business and nothing else. I created a small library called tiny that utilizes decorators and a simple base class to create components using native technologies. I'm pleased by the outcome. Though it doesn't support fancy data bindings and other cool features (for now) it turned out to be good!. In this article, I would like to share some of the learnings I got in the process of building that library and hopefully someone can use them to bui
Concept
The below code reveals the concept I had in my mind. It represents a simple web component that displays different emojis in different sizes based on inputs.
@element('my-smiley, `<span></span>`)
class SmileyElement extends BaseElement {
@input()
type: string = 'happy';
@input()
size = 'small' | 'medium' | 'large' = 'medium';
@query('span')
spanEl;
onChanges(changes) {
// TODO: perform DOM operations
}
}
First of all, I love decorators! I hope you do too. I want to leverage them as much as possible to do the reusable business. You can see in the above snippet that we've applied a few decorators like element, input and query.
The element decorator transforms the class into a web component. The input decorator as the name is used to mark the properties as inputs. The query decorator is used to automatically query and return the child element on accessing the applied property. We can have more fun with decorators, how about one to auto bind events to a function? Yes, we can! Let's keep things simple for now. Please check out the tiny github repo to refer to more decorators.
The other important thing to notice is, the SmileyElement extends from a base class BaseElement. To make the decorators work we got to do some plumbing and not only that we have other works too.. like rendering the passed template, helper methods to work with DOM etc. Most importantly, to register a class as a web component it should extend from HTMLElement or one of the built-in elements and the BaseElement extends from HTMLElement.
The base class also provides some life-cycle hooks for the component to intercept and act. As you see there is an onChanges method that'll be invoked every time there is a change in inputs and it is the place you need to perform the DOM operations. Since we don't have those cool data bindings we need to do the DOM updates manually. Don't worry, the base class provides a bunch of helper methods to make that process easier, effective and with absolute control.
Alright, let's set up the project and see how we can build those decorators first and then the base class.
Setting-up the Project
Pick your favourite editor (WebStorm is mine) and create a new project with the name "base-element". We need TypeScript and Webpack to get the development going. First, initialize the "package.json" by running the below command from the terminal.
npm init
You'll be asked with a string of questions like project name, license etc. Type the details as you wish and once the package.json file is created run the below command to install the development dependencies.
npm i typescript ts-loader webpack webpack-cli webpack-dev-server --save-dev
To configure the typescript you need to create a "tsconfig.json" file. Create it and paste the below stuff into it. The important thing to note down is the experimentalDecorators flag which should be set to true to make the decorators work. Also, we should include the "es2018", "dom" and "dom.iterable" packages in the lib property.
{
"compilerOptions": {
"baseUrl": "./",
"outDir": "dist",
"skipLibCheck": true,
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"composite": true,
"declaration": true,
"declarationMap": true,
"forceConsistentCasingInFileNames": true,
"downlevelIteration": true,
"module": "commonjs",
"noEmitOnError": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"noUnusedLocals": false,
"experimentalDecorators": true,
"noImplicitOverride": false,
"noImplicitAny": false,
"pretty": true,
"sourceMap": true,
"strict": true,
"strictNullChecks": false,
"target": "es2020",
"incremental": true,
"newLine": "LF",
"lib": ["es2018", "dom", "dom.iterable"]
},
"files": ["dev.ts"],
"include": [
"lib/**/*.ts"
]
}
To test our SmileyElement we need an "index.html" file and of course a web server to launch it. Create the "index.html" file and fill it with the below contents. Don't miss the script reference to "app.js" which is important.
<html lang="en">
<head>
<title>Simplifying Creating Web Components Using TypeScript Decorators</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Pacifico&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Comfortaa:wght@300&family=Pacifico&display=swap" rel="stylesheet">
<style>
body {
margin: 0 auto;
display: flex;
text-align: center;
justify-content: center;
flex-direction: column;
background-color: #f9f9f9;
font-family: 'Comfortaa', cursive;
}
h1 {
font-family: 'Pacifico', cursive;
}
pre {
font-family: 'courier';
color: gray;
margin: 2rem 0;
}
footer {
color: gray;
font-size: 0.6rem;
}
</style>
</head>
<body>
<h1>My life of emotions</h1>
<main>
<div class="app-container"></div>
<pre>
<my-smiley type="happy"></my-smiley>
</pre>
<footer>
Demo of building UI components using native browser technologies leveraging typescript decorators.<br> Please look into <a target="_blank" href="https://github.com/vjai/tiny">tiny</a> library for real-world development of UI components.
</footer>
</main>
<script src="app.js"></script>
</body>
</html>
Create the webpack file (webpack.config.js) to run the development server so we can test our SmileyComponent once it's ready.
const path = require('path');
module.exports = {
mode: 'development',
entry: { app: './dev.ts' },
module: {
rules: [
{
test: /\.js[x]?$/,
exclude: /(node_modules)/,
use: {
loader: 'babel-loader'
}
},
{
test: /\.ts$/,
exclude: /(node_modules)/,
use: {
loader: 'ts-loader'
}
}
]
},
resolve: {
modules: [path.resolve(__dirname), 'node_modules'],
extensions: ['.ts', '.js', '.jsx', '.json']
},
devServer: {
static: {
directory: path.resolve(__dirname)
}
},
devtool: 'source-map'
};
Finally add the below commands in the "scripts" section of your "package.json" to run and build the project.
"scripts": {
"start": "webpack-dev-server --config webpack.config.js",
"build": "tsc --build tsconfig.json"
}
Phew... our project setup is done. Let's go and create the decorators.
Create the Decorators
Create a new folder called "lib" to keep all our decorators, base class and other component building stuff.
Decorators help to add custom metadata to a class, method or property. This is how a typical class decorator looks like,
export function decoratorName(...options): ClassDecorator {
return (target: any) => {
};
}
Basically it's a function that returns a function, yeah sort of closure! We are gonna use decorators here to specify metadata information about a component like selector name, template, inputs, query accessors and more.
Alright, first let's create a class called ElementMetadata to store the component information like selector name, template, inputs and other things. Create a new file "element.metadata.ts" under the "lib" folder and drop the below class.
export class ElementMetadata {
name: string = null;
tpl: string = null;
accessors = new Map<string, { selector: string; }>();
inputs = new Set<{property: string; attribute: boolean; dataType: AttributeValueDataType; }>();
}
The accessors property stores the details of the properties used for querying and inputs to store the details of the input properties.
Create a file with the name "decorators.ts" under the "lib" folder. Let's start creating the element decorator.
The element decorator basically accepts the selector name and an optional html template and stores it in the metadata. Along with that, it also registers it as a web component by calling the native customElements.define method.
export function element(
name: string,
tpl?: string
): ClassDecorator {
return (target: any) => {
if (window.customElements.get(name)) {
throw new Error(`Already an element is registered with the name ${name}`);
}
window.customElements.define(name, target);
setMeta(target, Object.assign(getMeta(target), { name, tpl }));
};
}
function getMeta(target: Function) {
return target[ELEMENT_META_KEY] || new ElementMetadata();
}
function setMeta(target: Function, meta: ElementMetadata) {
target[ELEMENT_META_KEY] = meta;
}
The metadata is stored as a static property in the component class. We could also use reflect-metdata library to store the metadata but we are not doing this here to avoid a production dependency and also to keep our bundle size minimum. The ELEMENT_META_KEY is a constant and you can either create a separate constant file to keep it or drop it in the "element.metadata.ts" file itself.
export const ELEMENT_META_KEY = '__ELEMENT_INFO__'
Let's see the other two decorators. They are quite simple though, all they do is accept the passed parameters and store it in the metadata.
export function input(attribute = false, dataType = AttributeValueDataType.STRING): PropertyDecorator {
return (target: object, property: string | symbol) => {
const metadata = getMeta(target.constructor),
{ inputs } = metadata;
if (inputs.has(property)) {
throw new Error(
`Input decorator is already applied for the property ${
property as string
}`
);
}
inputs.add({ property, attribute, dataType });
setMeta(target.constructor, metadata);
};
}
export function query(selector: string): PropertyDecorator {
return (target: object, property: string | symbol) => {
const metadata = getMeta(target.constructor),
{ accessors } = metadata;
if (accessors.has(property)) {
throw new Error(
`Already a CSS selector is assigned for the property ${
property as string
}`
);
}
accessors.set(property, { selector });
setMeta(target.constructor, metadata);
};
}
The input decorator takes a couple of properties: attribute and dataType. The attribute property says whether the input property value should be read from the DOM element attribute initially and should be kept in sync. The dataType tells the type of the property which we need to parse the value correctly when we read from the attribute.
The query decorator takes a single parameter which is nothing but the selector. Our decorators are ready and it's time to write the base class.
The Base Class
To convert a class into a webcomponent we have to not only register it by calling the customElements.define method and also it has to extend from the native HTMLElement or any other built-in html element. We are gonna extend our base class from HTMLElement.
Create a file with the name "base-element.ts" and drop the below class into it.
class BaseElement extends HTMLElement {
}
We've to do quite a bit of work here. First, we've to read the template and render the component. Second, we've to override the getters and setters of those properties to which we applied the decorators so the component can detect whenever there is any change in the inputs to refresh the UI or to query and return the child element whenever they access any of the properties decorated with the query decorator.
To summarize, the following are the main things we need to do.
- Read the template from metadata and render it.
- Override the getters and setters of the decorated properties.
- Everytime there is a change in the inputs queue that change and trigger a timer to invoke the onChanges method in the next tick.
- Create helper methods to perform DOM operations like add/remove css classes, add/remove styles etc.
Alright, let's first see how we can read the template from the metadata and render it.
class BaseElement extends HTMLElement {
private readonly _metadata: ElementMetadata = null;
private _rendered: boolean = false;
protected constructor() {
super();
this._metadata = this.constructor[ELEMENT_META_KEY];
}
protected connectedCallback() {
if (!this._rendered) {
this.render();
this._rendered = true;
}
}
protected render() {
if (!this._metadata.tpl) {
return;
}
const template = document.createElement('template');
template.innerHTML = this._metadata.tpl;
this.appendChild(template.content.cloneNode(true));
}
}
The important thing to note down is we've hooked into the native connectedCallback lifecycle handler of the web component to render the template. We've also created a flag _rendered to make sure to render only once.
Next we need to override the getters and setters of the decorated properties. Let's see how we can make the query decorator work first. The query decorator turns the applied property into a CSS selector means every time you access the property it automatically queries and returns the matched DOM element. To make this happen we need to override the getter of the property to query and return the DOM element matching the CSS selector.
To override the getter/setter of a property in any object you can use the Object.defineProperty method.
Object.defineProperty(obj, propName, {
get() {
},
set(value) {
},
});
Here is our updated code.
export type UIElement = string | BaseElement | HTMLElement;
class BaseElement extends HTMLElement {
...
private _initialized: boolean = false;
private _applyAccessors() {
[...this._metadata.accessors].forEach(
([prop, { selector }]) => {
Object.defineProperty(this, prop, {
get() {
return this.$(selector);
}
});
}
);
}
private _element(el: UIElement): UIElement {
if (arguments.length === 0 || el === 'self') {
return this;
}
if (el instanceof HTMLElement) {
return el;
}
return this.$(el as string);
}
protected connectedCallback() {
if (!this._rendered) {
this.render();
this._rendered = true;
}
if (!this._initialized) {
this._applyAccessors();
this._initialized = true;
}
}
$<T extends HTMLElement>(selector: string, element: UIElement = this): T {
const el = this._element(element) as HTMLElement;
if (!el) {
return <any>this;
}
if (el === this) {
return this.querySelector(selector);
}
if (el instanceof BaseElement) {
return el.$(selector);
}
return el.querySelector(selector) as T;
}
}
What we are doing in the _applyAccessors method is basically iterating over each of the applied properties and overriding the getter to query and return the child DOM element matching the passed selector in the decorator. The $ method returns the child element of the component or from the passed parent (element) matching the selector.
Let's see how we can make the input decorator work. This is a little complicated. Every time an input property changes we have to take the change and push to an internal queue and trigger the timer to update the UI in the next tick using setTimeout. Not only, if the attribute flag is true then we have to read the initial value from the DOM attribute and parse it correctly using the dataType.
function isVoid(val) {
return val === null || val === undefined;
}
export type ElementChanges = Map<string, { oldValue: any; newValue: any }>;
class BaseElement extends HTMLElement {
...
private _changes = new Map<string, { oldValue: any; newValue: any }>();
private _props = new Map<string, any>();
private _updateTimer: any = null;
private _applyInputs() {
[...this._metadata.inputs].forEach(({ property, attribute, dataType }) => {
let value;
if (attribute) {
let attrValue: any = this.getAttr(property);
if (attrValue !== null) {
if (
dataType === AttributeValueDataType.NUMBER &&
!isNaN(parseFloat(attrValue))
) {
attrValue = parseFloat(attrValue);
} else if (dataType === AttributeValueDataType.BOOLEAN) {
attrValue = attrValue === 'true' || attrValue === '';
}
value = attrValue;
} else {
value = this[property];
}
if (!isVoid(value) && value !== attrValue) {
this.setAttr({ [property]: value });
}
} else {
value = this[property];
}
this._pushChange(property, value);
this._props.set(property, value);
const target = this;
Object.defineProperty(this, property, {
get() {
return target._props.get(property);
},
set(value) {
if (attribute) {
if (value) {
target.setAttr({
[property]: !isVoid(value) ? value.toString() : value
});
} else {
target.removeAttr(property);
}
}
target._pushChange(property, value);
target._props.set(property, value);
target._initialized && target._triggerUpdate();
}
});
});
}
private _pushChange(prop: string, value: any) {
if (!this._changes.has(prop)) {
this._changes.set(prop, { oldValue: this[prop], newValue: value });
return;
}
const { oldValue, newValue } = this._changes.get(prop);
if (oldValue === newValue && this._initialized) {
this._changes.delete(prop);
return;
}
this._changes.set(prop, { oldValue, newValue: value });
}
private _triggerUpdate() {
if (this._updateTimer) {
return;
}
this._updateTimer = setTimeout(() => this.refresh(), 0);
}
protected connectedCallback() {
if (!this._rendered) {
this.render();
this._rendered = true;
}
if (!this._initialized) {
this._applyAccessors();
this._applyInputs();
this._initialized = true;
}
this.refresh();
}
protected onChanges(changes) {}
protected refresh() {
this.onChanges(this._changes);
this._changes.clear();
this._updateTimer && window.clearTimeout(this._updateTimer);
this._updateTimer = null;
}
}
The _changes property is used to store the queue of changes to the inputs. The _props stores the latest value of those properties. That's all the work we've to do to make the decorators work.
Let's add some methods that help to manipulate the DOM.
export interface KeyValue {
[key: string]: any;
}
import { isVoid } from "./util";
import { KeyValue, UIElement } from "./base-element";
class BaseElement extends HTMLElement {
...
addClass(
classes: string | Array<string>,
element: UIElement = this
): BaseElement {
const el = this._element(element) as HTMLElement;
if (!el) {
return this;
}
el.classList.add(...(Array.isArray(classes) ? classes : [classes]));
return this;
}
removeClass(
classes: string | Array<string>,
element: UIElement = this
): BaseElement {
const el = this._element(element) as HTMLElement;
if (!el) {
return this;
}
el.classList.remove(...(Array.isArray(classes) ? classes : [classes]));
return this;
}
addStyle(styles: KeyValue, element: UIElement = this): BaseElement {
const el = this._element(element) as HTMLElement;
if (!el) {
return this;
}
Object.entries(styles).forEach(([k, v]) => {
if (k.startsWith('--')) {
el.style.setProperty(k, v);
} else if (v === null) {
this.removeStyles(k, el);
} else {
el.style[k] = v;
}
});
return this;
}
removeStyles(
styles: string | Array<string>,
element: UIElement = this
): BaseElement {
const el = this._element(element) as HTMLElement;
if (!el) {
return this;
}
(Array.isArray(styles) ? styles : [styles]).forEach(
style => (el.style[style] = null)
);
return this;
}
getAttr(name: string, element: UIElement = this): string {
const el = this._element(element) as HTMLElement;
if (!el) {
return '';
}
return el.getAttribute(name);
}
setAttr(obj: KeyValue, element: UIElement = this): BaseElement {
const el = this._element(element) as HTMLElement;
if (!el) {
return this;
}
Object.entries(obj).forEach(([key, value]) =>
isVoid(value) ? this.removeAttr(key) : el.setAttribute(key, value)
);
return this;
}
removeAttr(
attrs: string | Array<string>,
element: UIElement = this
): BaseElement {
const el = this._element(element) as HTMLElement;
if (!el) {
return this;
}
(Array.isArray(attrs) ? attrs : [attrs]).forEach(attr =>
el.removeAttribute(attr)
);
return this;
}
updateHtml(html: string, element: UIElement = this): BaseElement {
const el = this._element(element) as HTMLElement;
if (!el) {
return this;
}
el.innerHTML = !isVoid(html) ? html : '';
return this;
}
}
You can see there is an onChanges protected method that takes the changes parameter that is not implemented and this method should be overridden by the derived classes to perform the DOM operations using the helper methods.
Our base class is pretty much ready, as a final touch let's expose two additional life-cycle hooks that can be used by derived classes to add custom logic whenever the element is connected to or disconnected from DOM.
protected connectedCallback() {
...
this.onConnected();
this.refresh();
}
protected disconnectedCallback() {
this.onDisconnected();
}
protected onConnected() {}
protected onDisconnected() {}
Complete the Smiley Element
We've done all the work to create custom web components. Below is the complete code of our SmileyElement we saw earlier that displays different emojis based on the passed input.
import { BaseElement, element, ElementChanges, input, query } from './lib';
enum sizeRemMap {
'small' = 1,
'medium' = 2,
'large' = 3,
}
enum smileyMap {
'happy'= '😀',
'lol' = '😂',
'angel' = '😇',
'hero' = '😎',
'sad' = '😞',
'cry' = '😢',
'romantic' = 'ðŸ˜',
'sleep' = '😴',
'nerd' = 'ðŸ¤"'
}
@element('my-smiley', `<span></span>`)
class SmileyElement extends BaseElement {
@input(true)
type: string = 'happy';
@input(true)
size: 'small' | 'medium' | 'large' = 'medium';
@query('span')
spanEl;
onChanges(changes: ElementChanges) {
if (changes.has('type')) {
this.updateHtml(smileyMap[this.type || 'happy'], this.spanEl);
}
if (changes.has('size')) {
this.addStyle({ 'font-size': `${sizeRemMap[this.size]}rem`}, this.spanEl);
}
}
}
We've passed the attribute parameter as true in the input decorators to pass those values as attributes in HTML. You can see the onChanges method that we are checking if the input parameters like type or size is changed and updating the DOM accordingly.
Let's create a small app component to render multiple smileys.
@element('my-app', `
<my-smiley type="happy"></my-smiley>
<my-smiley type="lol"></my-smiley>
<my-smiley type="angel"></my-smiley>
<my-smiley type="hero"></my-smiley>
<my-smiley type="sad"></my-smiley>
<my-smiley type="cry"></my-smiley>
<my-smiley type="romantic"></my-smiley>
<my-smiley type="sleep"></my-smiley>
<my-smiley type="nerd"></my-smiley>
`)
class App extends BaseElement {
}
Finally to render the App component we need to wire-up a handler to the DOMContentLoaded event of the document.
document.addEventListener('DOMContentLoaded', () => {
const app = document.createElement('my-app');
document.querySelector('.app-container').appendChild(app);
});
Finally, we've done the development. Let's launch the "index.html" file and see everything works by running the below command.
npm start
If everything went well you should see the below screen,
What's next?
This is a small attempt to create our very own UI library to build components using pure native concepts leveraging decorators. For real-world usage please look into the tiny project that provides more decorators and tons of helper methods to work with DOM. Please give the repo a star and feel free to fork it. You can also try something new like how about building simple data binding in templates so we don't have to do manual DOM operations. Go on and give it a try! It's a lot of fun to build something of your own and use it in real apps.
Source Code: https://github.com/vjai/base-element
Tiny: https://github.com/vjai/tiny