It’s a known fact that I’m betting on Web Components. In the last time that I delivered a session about Web Components, someone in the audience asked me how he can remove some of the boilerplate he needs to write in order to create a custom element. I answered that you can probably use a compiler such as Stencil or a library such as Polymer or even write your own TypeScript decorator to do that.
So… a few days ago I had some spare time to sit and play with both Custom Elements and TypeScript decorators. As a result I wrote a small code snippet that can help you to get started and build your own custom element decorator.
In this post I’ll share that snippet and explain how to use it. But first thing we will start by introducing decorators.
Let’s Decorate Our Code
Decorators are a proposed standard which is still in development. In a nutshell a decorator is a higher-order function that takes a class, function, property, or parameter as an argument and extend it without modifying it’s behavior. Decorators are very useful when you want to wrap something with some extra functionality. For example, you can use decorators for validation, instrumentation, logging or any other cross cutting concern.
You create a decorator with the @ symbol and an expression which should be a function reference. For example, the following code shows a CustomElement decorator declaration and usage:
const CustomElement = (target) => {
...
}@CustomElement()
class myClass {
}
Decorator Types
There are four types of decorators:
- Class — accepts a constructor function and returns a constructor function. We will use this kind of decorator later on in the example.
- Function — accepts 3 arguments which are the object that the function is defined on, the property key and a property descriptor which gives you access to the property. Function decorators return a property descriptor.
- Property — Same as function decorator but it doesn’t accept a property descriptor and it shouldn’t return anything.
- Parameter — accepts 3 arguments which are the object on which the function is defined, the function key and the index of the parameter in the function parameter list.
Decorator Factories
If you want that a decorator will receive parameters from the outside you can and will be generated according to those parameters you should build a decorator factory. A decorator factory is a function that returns relevant decorator at runtime after it applied some extra parameters to it. For example, the following code shows a decorator factory:
const CustomElement = (config) => (cls) => {
// use the config here to affect the class decorator behavior
}
Unfortunately, currently decorators aren’t a part of the JavaScript language and we will have to use a transpiler such as TypeScript when we want to use them.
Note: Be sure to install TypeScript on your machine or in your project before you continue to the main example.
Now that we are a little familiar with decorators, let’s start writing our new CustomElement decorator.
Creating a CustomElement Decorator
In custom element scenarios we will probably want to add a template element and add shadow DOM to our element. In our decorator we will accept a template string and a shadow DOM flag which will help us decide whether we should create shadow root on the element or not.
Note: I’m taking into account that you are familiar with the template element and shadow DOM. If not, there are good explanation about them in MDN.
Let’s start with the decorator configuration interface:
interface CustomElementConfig {
selector:string;
template: string;
style?: string;
useShadow?: boolean;
}
This configuration will be used by the decorator factory to change the behavior of the decorator we will produce. Pay attention that selector is the name of the custom element and style will get some css which will be attached to the template.
We will use a helper function to validate if the provided selector includes at least one dash. The dash check is part of the custom elements specs and each custom element must have at least one dash in it’s name. Here is the declaration of validateSelector function:
const validateSelector = (selector: string) => {
if (selector.indexOf('-') <= 0) {
throw new Error('You need at least 1 dash in the custom element name!');
}
};
Now for the decorator implementation:
const CustomElement = (config: CustomElementConfig) => (cls) => {
validateSelector(config.selector);
if (!config.template) {
throw new Error('You need to pass a template for the element');
}
const template = document.createElement('template');
if (config.style) {
config.template = `<style>${config.style}</style> ${config.template}`;
}
template.innerHTML = config.template;
const connectedCallback = cls.prototype.connectedCallback || function () {};
cls.prototype.connectedCallback = function() {
const clone = document.importNode(template.content, true);
if (config.useShadow) {
this.attachShadow({mode: 'open'}).appendChild(clone);
} else {
this.appendChild(clone);
}
connectedCallback.call(this);
};
window.customElements.define(config.selector, cls);
};
Let’s break up the implementation:
At first we validate the selector. Then, we create a template element and we attach to it the style we got from the configuration object (if it exists). The main thing happens next. We replace the connectedCallback of the class with a new one which will create the clone from the template and add it to our custom element. If useShadow is true, we make the element a shadow root and attach the cloned template to it. After that, we call the class real connectedCallback that we kept. Last but not least we register the extended class in the custom elements registry.
Using the CustomElement Decorator
The decorator is ready. How can you use it?
Here is a simple usage example:
@CustomElement({
selector: 'ce-my-name',
template: `<div>My name is Inigo Montoya</div>
<div>You killed my father</div>
<div>Prepare to die!</div>`,
style: `:host {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: #009cff;
padding: 16px;
border-top: 1px solid black;
font-size: 24px;
}`,
useShadow: true
})
class MyName extends HTMLElement {
connectedCallback() {
const elm = document.createElement('h3');
elm.textContent = 'Boo!';
this.shadowRoot.appendChild(elm);
}
}
In this example we send some configurations to the decorator and we also implement some logic in the class connectedCallback function.
We can put the element in our HTML:
<ce-my-name></ce-my-name>
And running the HTML will result in:
Now that you understand how to create your own CustomElement decorator, you can move on and add more functionality to it.
Adding Life Cycle Events
One of the features developers are used to having in frameworks/libraries is component life cycle events. Why not adding this functionality to the decorator we created?
In the decorator we will change the code that handles the connectedCallback function with the following code:
const connectedCallback = cls.prototype.connectedCallback || function () {};
const disconnectedCallback = cls.prototype.disconnectedCallback || function () {};
cls.prototype.connectedCallback = function() {
const clone = document.importNode(template.content, true);
if (config.useShadow) {
this.attachShadow({mode: 'open'}).appendChild(clone);
} else {
this.appendChild(clone);
}
if (this.componentWillMount) {
this.componentWillMount();
}
connectedCallback.call(this);
if (this.componentDidMount) {
this.componentDidMount();
}
};
cls.prototype.disconnectedCallback = function() {
if (this.componentWillUnmount) {
this.componentWillUnmount();
}
disconnectedCallback.call(this);
if (this.componentDidUnmount) {
this.componentDidUnmount();
}
};
Now we added the option to hook into regular custom element events and add our own functionality before and after the component is connected or disconnected.
Now we can change the usage example to the following code:
@CustomElement({
selector: 'ce-my-name',
template: `<div>My name is Inigo Montoya</div>
<div>You killed my father</div>
<div>Prepare to die!</div>`,
style: `:host {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: #009cff;
padding: 16px;
border-top: 1px solid black;
font-size: 24px;
}`,
useShadow: true
})
class MyName extends HTMLElement {
connectedCallback() {
const elm = document.createElement('h3');
elm.textContent = 'Boo!';
this.shadowRoot.appendChild(elm);
console.log('connected callback');
}
disconnectedCallback() {
console.log('disconnected callback');
}
componentWillMount() {
console.log('component will mount');
}
componentDidMount() {
console.log('component did mount');
}
componentWillUnmount() {
console.log('component will unmount');
}
componentDidUnmount() {
console.log('component did unmount');
}
}
As you can see, we will log to the console all the events we added. In order to test the code you can add the following code to the example to check also the call for disconnectedCallback:
window.addEventListener('DOMContentLoaded', () => {
const element = document.querySelector('ce-my-name');
setTimeout(()=> {
element.parentNode.removeChild(element);
}, 2000);
});
Running the code again will produce the following output in the console:
Summary
In the post I showed you how you can build your own TypeScript decorator in order to remove some of custom elements boilerplate code. This technique can also be used in other use cases such as cross cutting concerns or adding repetitive functionality.
You can find the decorator repository here.
Please share your thoughts about the post in the comments.