In this part we will explore how custom elements work. We will not be looking into attributes, the shadow DOM, slots and other web component parts here, that will come later. For now we are only interested in understanding what is going on when you add a tag to HTML.
When a page starts to load the browser goes and fetches the many parts it requires, the HTML pages, the JavaScript files, the CSS styles, images, and data from APIs. While this is all going on the browser will try to show the page with the parts it has gotten.
The browser will find our custom element tag and link it to the JavaScript class. It will then create an instance of the class and call its constructor function.
But what happens if the HTML is loaded first and the JavaScript file, with the web component class inside, is delayed. What does the browser do then?
In the below example we will test this.
The HTML has the <delayed-load>
tag but the delayed-load.js
file, that contains the web
component JavaScript code, is not loaded.
So there is no link between them, which makes the tag unknown to the browser.
There is a Load
button, that when pressed will import (load into the browser) the JavaScript file we need.
Before the Load
button is pressed, the <delayed-load>
element does not contain anything inside.
It is empty.
The browser just treats it like a standard undefined DIV
element.
<!DOCTYPE html> <html lang="en"> <head>...</head> <body> <p>...</p> <button onclick="loadButtonClick()">Load</button> <delayed-load></delayed-load> <script type="module">...</script> </body> </html>
After the Load
button is pressed, the browser will import the JavaScript file, then it will do its magic and
the result will be that the <delayed-load>
element will end up with some text inside.
<!DOCTYPE html> <html lang="en"> <head>...</head> <body> <p>...</p> <button onclick="loadButtonClick()">Load</button> <delayed-load>custom element created</delayed-load> <script type="module">...</script> </body> </html>
The browser does not wait around for all the web components to be loaded before it starts to render the page. It can handle undefined tags, leaving them blank and empty until the code linked to them gets loaded in.
There are some limitations to the name you can give your custom element tag.
HTML tags in general are case insensitive, so <MY-TAG>
is the same as <my-tag>
, and
even <My-Tag>
is the same too.
However, when calling the customElements.define
function, to define your custom element, the name you give
has some requirements you need to follow.
The rules are as follows.
There are also a list of keywords that are already predefined that you cannot use. These include the following.
Have a look at the example below. You can test creating some custom elements with valid and invalid tag names.
For the invalid tag names you will get the same error message.
To create a custom element you call the customElements.define
function.
But what happens if you call it more than once for the same custom element or for a tag that already exists (has be defined before).
Take a look at the below example.
Creating the <test-tag>
custom element for the first time works without any problems.
But if you press the Create <test-tag> custom element
button again, it will try to
create the same tag and custom element again, and this time it will fail.
The same problem will happen if you try to define a different custom element but with the same tag name.
You can not create (define) a custom element more than once.
So you need to be careful where you call the customElements.define
function.
However, you can import the same JavaScript file more than once and it will not be repeatedly evaluated, so as long as you
only call the function once for that tag in your source code (and not in another source file) then you should be fine.
But what happens if you are using different libraries from different places that happen to be using the same custom element tag name? One will work and the other will fail, depending on which one was loaded into the browser first. Well, sadly, there isn't anything you can do, apart from rewriting parts of the library yourself to give it unique tag names.
This does lead to an important issue about selecting good names for your custom element tags.
If the custom element is going to be used by other people, then choose the tag names to be more unique.
But if the custom elements are being used internally for a single project, then shorter more tailored names can be used.
For example, a custom element that shows barcodes, something that you want to give away as a library, could
be named <my-business-name-barcode>
.
A custom element button, showing the "Save" text, to be used on an internal project, could simply be called <button-save>
.
Web components come with two callback functions that the browser calls when it is attached and detached from the DOM.
These are connectedCallback
and disconnectedCallback
.
To better understand when these functions are called, we need to perform a little test.
Take a look at the following code for the custom element.
class ConnectedDisconnected extends HTMLElement { constructor() { super(); window.customLogEvent('constructor'); } connectedCallback() { window.customLogEvent('connectedCallback'); } disconnectedCallback() { window.customLogEvent('disconnectedCallback'); } } customElements.define('connected-disconnected', ConnectedDisconnected);
When a new instance of the custom element is created and the constructor function is called, we run the
customLogEvent
function.
Later you will see that this function will show the log events on the page, but it also sends it to the
browser console.
Both the connectedCallback
and disconnectedCallback
functions log their events too.
In the HTML we will import the JavaScript file, but we will not use the tag as we have before.
Instead will we create all the elements we need for our test using the createElement
function and give
the user to option to attach and detach it to and from the DOM.
There are two elements, a parent and our custom element as a child. We can attach and detach the parent from the DOM, and we can attach and detach the custom element from the parent. The results are interesting.
Attaching the custom element to the parent, that is not attached to the DOM, leads to the connectedCallback
function not being called.
However, if we then attach the parent to the DOM then the connectedCallback
function is being called.
You will see after playing with the example for a bit that the two callback functions are only ever used if the custom element
is directly linked to the DOM itself.
You can use a custom element in a number of different way.
The primary way is to use the tag within some HTML text.
But you can create them using the document.createElement
function.
But in doing so you can break things.
It seems that using the document.createElement
function puts the element into some type state where it is not really
part of the DOM, so calling functions linked to the DOM will throw and error.
These issues do not happen if the element is already part of the DOM, like when it is used with a tag.
Therefore, if your web component is likely to be used with the document.createElement
function then you will need to be careful
what you do in the constructor function.
Here are some of the things you must do and cannot do.
You will not see anything on the page but in the console area there will be an error logged.
This is a DOM exception. It was unable to run our custom element's constructor function. Not sure what the children part has to do with anything though.
There are limitations around the interaction of the DOM in the constructor function, so the question is, what can we do in the constructor and what should be moved into the connected and disconnected callback functions? Generally you should only put initialization code in the constructor, setting up variables, lists and so on. You should not be creating other elements, creating events, making API calls to the server, or anything that takes a long time to do.
However, that is in generally, and there are times you can bend the rules. If you create and attach a Shadow DOM in the constructor, then you can do DOM related tasks on it. But when it comes to handling events, we need to keep away from the constructor.
To look into events lets create a simple custom event that contains a number and two buttons, one to add and one to minus the number. The internal HTML of the custom element is going to be like this.
<div id="count">0</div> <button id="add">Add</button> <button id="minus">Minus</button>
In the connectedCallback function we set the internal HTML (if we haven't done so already), then get each of the elements using their ID values. After that we set the click events.
connectedCallback() { // Add internal HTML ... // Get Elements this._countElement = document.getElementById('count'); this._plusElement = document.getElementById('add'); this._minusElement = document.getElementById('minus'); // Add click events this._plusElement.addEventListener('click', this._plusClickEvent); this._minusElement.addEventListener('click', this._minusClickEvent); } _plusClickEvent() { this._count++; this._countElement.innerText = this._count.toString(); } _minusClickEvent() { this._count--; this._countElement.innerText = this._count.toString(); }
The click events are functions that are part of the custom element. In each one they have access to the instance of the custom element that the click event is connected to. They use the keyword "this" to access the instance. However this is not how it works automatically and therefore requires us to manually bind the click event functions to the custom element "this" object.
// Bind the click event functions to the "this" object this._plusClickEvent = this._plusClickEvent.bind(this); this._minusClickEvent = this._minusClickEvent.bind(this);
If we did not bind the click event functions then the "this" value inside the event functions would point to something else.
This all work fine, but we need to handle what happens when the custom element is removed from the DOM.
disconnectedCallback() { // Remove click events this._plusElement.removeEventListener('click', this._plusClickEvent); this._minusElement.removeEventListener('click', this._minusClickEvent); }
When the custom element is removed from the DOM the disconnectedCallback
event is called.
In here we remove the click events from the add and minus button elements.
When it comes to the connected and disconnected callback functions, you want to view one as adding things and the other as removing them, and try to match them up both together. It is important when, adding events in the connected callback function, to also remove them from the disconnected callback function at the same time, so you do not forget. If you do add events and forget to remove them, then you will end up with memory leaks. Not always a bad thing, but not ideal.
In the below example you can attach and detach the custom element. You can detach it in a way that will remove the plus and minus element's click events, or you can detach it so that it keeps them, as if you had forgotten to add the code to do it. You can use this to monitor what type of memory leaks you could end up with.
After some tests, when detaching the custom element without removing the click events, it looks like there
are two HTMLButtonElement
objects still in memory that have not been removed.
Before adding event functions inside our custom element, we had to bind them to the "this" object. But what if we use the arrow function => method instead? One of the benefits of arrow functions is that they automatically bind the given function to the "this" object. Lets see at what this would look like.
// Add click events this._plusElement.addEventListener('click', (event) => { this._count++; this._countElement.innerText = this._count.toString(); }); this._minusElement.addEventListener('click', (event) => { this._count--; this._countElement.innerText = this._count.toString(); });
The functions look cleaner, and as long as you are not doing too much inside, it seems an ideal approach.
However, there is a big problem.
The event functions are never removed.
The only place we can add these functions is in the connected callback function.
So if the custom element is attach, detached and then attached again, it will create a duplicate click event.
There would be two plus click events, each getting fired when the Plus
button is pressed, and each one increasing the amount by 1.
In the above example you can see this happening.
Press to attach the custom element and add and minus the number.
Everything will look as it should.
But detach and attach it again, and when you press the Add
and Minus
buttons, it will increase and decrease the
count value by 2 instead of one.
All the custom elements we have created are known as autonomous and have been derived from the HTMLElement class. But there is another type, a slightly different method of extending existing HTML tags. These custom elements are derived from other element classes.
class CustomAnchorElement extends HTMLAnchorElement { constructor() { super(); this.innerText = 'Custom anchor element'; this.href = '/'; } } customElements.define('custom-a', CustomAnchorElement, { extends: 'a'});
The first line creates the class CustomAnchorElement
but instead of deriving (extends) it from HTMLElement
,
this time we derive it from HTMLAnchorElement
.
This means we take on all the attributes and extra functions that are part of the anchor element.
We set the inner text but we can also set the link address, the HREF attribute setting.
The last extra thing that needs to be done, is to include and third parameter object to the customElements.define
function.
This only contains one setting, extends, which is used to state which tag name you are deriving your custom element from.
In this case it is the <a>
tag.
<a is="custom-a"></a>
The HTML is different too.
Instead of using the tag as <custom-a></custom-a>
, we are using the normal anchor tag
name <a...></a>
, but adding the "is" attribute with the name of our custom element tag.
The above example show some custom elements derived from an anchor, button and an input. I am not sure there are any good use cases for this approach but maybe some ideas will come to me in the future.
With autonomous custom elements you are limited to only being able to derive it from the HTMLElement
class, but
it is possible to create your own base class and derive from that.
You can create a base custom element that is derived (extends) from HTMLElement
and then create other custom
elements that derive from your base one.
As you can see from the above diagram, the DerivedCustomElement
is derived from BaseCustomElement
, which
itself is also derived from HTMLElement
.
So how does this look in code.
// This is the base custom element class BaseCustomElement extends HTMLElement { constructor() { super(); this.style.backgroundColor = 'lightgray' this.innerText = 'base custom element'; } } customElements.define('base-custom-element', BaseCustomElement); // Derived from <base-custom-element> element class DerivedCustomElement extends BaseCustomElement { constructor() { super(); this.innerText = 'derived custom element'; } } customElements.define('derived-custom-element', DerivedCustomElement);
In this example we first create the BaseCustomElement class, which is derived from HTMLElement. Remember, for autonomous custom elements, we must end up having the base class been derived from the HTMLElement class. The base custom element sets its inner text and changing the background color. On its own it may not do must. Your own base class would perform a set of default functions or set some default styles, which all the derived custom elements will take on and benefit from.
We then create the DerivedCustomElement class which is derived from our base class. Here we are only setting the inner text. We are not touching the background color, but it will be set by the base class.
In this example you can see the two custom element in action. Normally you wouldn't use the base custom element on its own, only the custom element derived from it.
This could be a good approach if you have a group of very similar custom elements that all perform the same type of functions.
For example, you could have a base button custom element, and then create
a ButtonSave
, ButtonUpdate
, ButtonDelete
custom elements.
The base button could include all the button related functions while the derived custom elements just set the inner
text as required.
This would reduce the amount of code you would need to write.
What happens if the custom element includes HTML that also contains its own tag. We need to see some code to understand what I mean.
class IncludeItself extends HTMLElement { constructor() { super(); } connectedCallback() { this.innerHTML = '<include-itself></include-itself>'; } } customElements.define('include-itself', IncludeItself);
Here we are creating the custom element IncludeItself
and giving it the tag <include-itself>
.
When the custom element is attached to the DOM, it will set its inner HTML. The HTML contains the <include-itself>
tag.
The custom element will continually include a copy of itself again and again forever, in an infinite tree of custom element within custom element. See what happens in the example below.
After creating and adding the custom element to the DOM the browser will throw an error.
It runs out of stack space. Though it is interesting that it goes on for 10 levels before reaching the limit. It does seem you could create a custom element that does include itself as long you have it stop including itself at some point. Still, you really should not have any need to do this yourself.