潘忠显 / 2021-04-19
“JavaScript 工作原理”系列文章是翻译和整理自 SessionStack 网站的 How JavaScript works。因为博文发表于2017年,部分技术或信息可能已经过时。本文英文原文链接,作者 Alexander Zlatkov,翻译 潘忠显。
How JavaScript works: Under the hood of custom elements + Best practices on building reusable components
Aug 5, 2018 · 13 min read
This is post # 19 of the series dedicated to exploring JavaScript and its building components. In the process of identifying and describing the core elements, we also share some rules of thumb we use when building SessionStack, a JavaScript application that needs to be robust and highly-performant to help users see and reproduce their web app defects real-time.
If you missed the previous chapters, you can find them here:
- An overview of the engine, the runtime, and the call stack
- Inside Google’s V8 engine + 5 tips on how to write optimized code
- Memory management + how to handle 4 common memory leaks
- The event loop and the rise of Async programming + 5 ways to better coding with async/await
- Deep dive into WebSockets and HTTP/2 with SSE + how to pick the right path
- A comparison with WebAssembly + why in certain cases it’s better to use it over JavaScript
- The building blocks of Web Workers + 5 cases when you should use them
- Service Workers, their life-cycle, and use cases
- The mechanics of Web Push Notifications
- Tracking changes in the DOM using MutationObserver
- The rendering engine and tips to optimize its performance
- Inside the Networking Layer + How to Optimize Its Performance and Security
- Under the hood of CSS and JS animations + how to optimize their performance
- Parsing, Abstract Syntax Trees (ASTs) + 5 tips on how to minimize parse time
- The internals of classes and inheritance + transpiling in Babel and TypeScript
- Storage engines + how to choose the proper storage API
- The internals of Shadow DOM + how to build self-contained components
- WebRTC and the mechanics of peer to peer connectivity
Overview
In one of our previous posts we discussed the Shadow DOM API and a few other concepts which are all parts of a bigger picture — web components. The whole idea behind the web components standard is to be able to extend the built-in capabilities of HTML by creating small, modular, and reusable elements. It’s a relatively new W3C standard that has already been approved by all major browsers and can be seen in production environments… of course with the help of a polyfill library (which we’re going to talk about later in the post).
As you might already know, the browser provides us with a few very important tools for building websites and web applications. We’re talking about HTML, CSS and JavaScript. You use HTML for structuring your application, CSS to make it look beautiful, and JavaScript to bring the action. However, before web components were introduced there was no easy way to associate JavaScript behavior to the HTML structure.
In this post we’re going to get to the foundations of web components — custom elements. In a nutshell the custom elements API allows you to create (as the name suggests) custom HTML elements with built-in JavaScript logic and CSS styles. Many people confuse custom elements with shadow DOM. But they are two completely different concepts and they’re actually complementary instead of interchangeable.
Some frameworks (e.g. Angular, React) try to solve the same problem by introducing their own concepts. You can compare the custom elements to Angular’s directives or React’s components. However, custom elements are native to the browser and require nothing more than vanilla JavaScript, HTML, and CSS. Of course, this doesn’t mean necessarily that it’s a replacement for a typical JavaScript framework. Modern frameworks give us more than just being able to simulate the behavior of custom elements. So the two can work side by side.
API
Before we dive in, let’s take a quick look over what the API actually looks like. The customElements
global object gives you a few methods:
define(tagName, constructor, options)
— Defines a new custom element. Takes three arguments: A valid tag name for a custom element, class definition for the custom element, and an options object. Only one option is supported currently:extends
which is a string specifying the name of a built-in element to extend. Used to create a customized built-in elements.get(tagName)
— Returns the constructor of a custom element if the element is defined and returns undefined otherwise. Takes a single argument: A valid tag name for a custom element.whenDefined(tagName)
— Returns a promise which is resolved once a custom element is defined. If the element is already defined, it will be resolved immediately. The promise is rejected if the tag name is not a valid custom element name. Takes a single argument: A valid tag name for a custom element
How to create a custom element
Creating a custom element is actually a piece of cake. You need to do two things: Create a class definition for the element which should extend the HTMLElement
class and register that element under a name of your choice.
class MyCustomElement extends HTMLElement {
constructor() {
super();
// …
}
// …
}
customElements.define('my-custom-element', MyCustomElement);
Or if you want, you can use anonymous class in case you don’t want to clutter the current scope
customElements.define('my-custom-element', class extends HTMLElement {
constructor() {
super();
// …
}
// …
});
As you can see from the examples, custom elements are registered by using the customElements.define(...)
method.
What issues are custom elements solving
So what’s the problem actually. Div soups are part of it. What is a div soup you might ask — it’s a very common structure in modern web apps where you have multiple nested div elements (div inside a div inside a div and so on).
This kind of structure is used since it makes the browser render the page as it should. However, it makes the HTML unreadable and very hard to maintain.
So for example we might have a component which is supposed to look like this
And traditionally the HTML might look like the following.
But imagine if we could make it look like this instead
The second example is much better, if you ask me. It’s more maintainable, readable, and it makes sense both for the browser and the developer. It’s just simpler.
The other issue is reusability. Our job as developers requires not only to write working code but also a maintainable one. And one thing that makes some code maintainable is being able to easily reuse a piece of code instead of writing it again and again.
I’ll give you a simple example but you’ll get the idea. Let’s say we have the following element:
If we need to use this elsewhere we’d need to write the same HTML all over again. Now imagine that we need to do a change that needs to apply to each of those elements. We’d need to find each place in the code and do the exact same change again and again. Bummer…
Wouldn’t it be better if we could just do the following
But a modern web application is not just static HTML. You need to interact with it. And this comes from the JavaScript. Usually, what you might do is create some elements and then attach whatever event listeners you need in order for them to react to the input of the user. Whether they’re clicked, dragged, hovered and so on.
var myDiv = document.querySelector('.my-custom-element');
myDiv.addEventListener('click', _ => {
myDiv.innerHTML = '<b> I have been clicked </b>';
});
With the custom elements API all this logic can be encapsulated into the element itself. The example below does exactly the same as the one above.
class MyCustomElement extends HTMLElement {
constructor() {
super();
var self = this;
self.addEventListener('click', _ => {
self.innerHTML = '<b> I have been clicked </b>';
});
}
}
customElements.define('my-custom-element', MyCustomElement);
At first it may seem like the custom elements approach requires more lines of JavaScript. But in a real-life application you rarely have this kind of scenario where you create a single element which is not reused. One more thing which is typical for modern web apps is that most elements are created dynamically. So you’d need to handle separate cases for when the element is added dynamically using JavaScript or it’s defined previously in the HTML structure. And you get all of this out of the box with custom elements.
So to summarize, custom elements make your code easier to understand and maintain, and splits it into small, reusable and encapsulated modules.
Requirements
Now before you go on and create your first custom element, you should know that there are special rules that must be followed.
- The name must contain a dash (-) in it. This way the HTML parser can tell which elements are custom and which are built-in. It also ensures that there won’t be a name collision with built-in elements (either now or in the future when other ones are added). For example,
<my-custom-element>
is a valid name while<myCustomElement>
and<my_custom_element>
are not. - Registering the same tag name more than once is forbidden. This will cause the browser to throw a
DOMException
. You cannot override custom elements. - Custom elements cannot be self-closing. The HTML parser allows only a handful of built-in elements to be self-closing (e.g.
<img>
,<link>
,<br>
) .
Capabilities
So what actually can you do with custom elements? And the answer is — many things.
One of the best features is that the class definition of the element is actually referring to the DOM element itself. This means that you can use this
directly to attach event listeners, access its properties, access child nodes, and so on.
class MyCustomElement extends HTMLElement {
// ...
constructor() {
super();
this.addEventListener('mouseover', _ => {
console.log('I have been hovered');
});
}
// ...
}
This, of course, gives you the ability to overwrite the child nodes of an element with new content. But this is generally not recommended, since it might lead to unexpected behavior. As a user of a custom element that’s not implemented by yourself you will be surprised if your own markup inside the element is replaced by something else.
There are a few hooks that you can define for executing code at specific times of the element’s lifecycle.
constructor
The constructor is called once the element is created or upgraded (we’ll talk about this in a bit). It’s most commonly used for state initialization, attaching event listeners, creating a shadow DOM, etc. One thing to keep in mind is that you should always call super()
in the constructor.
connectedCallback
The connectedCallback
method is called each time the element is added to the DOM. It can be used (it’s also recommended) to delay some work until the element is actually on the page (e.g. fetching a resource).
disconnectedCallback
Analogous to connectedCallback
, the disconnectedCallback
method is called once an element is taken out of the DOM. Usually used for freeing up resources. One thing to have in mind is that the disconnectedCallback
is never called if the user closes the tab. So be careful what you’re initializing in the first place.
attributeChangedCallback
This method is called once an attribute of the element has been added, removed, updated or replaced. It’s also called once the element is being created by the parser. However, note that this applies only for attributes which are whitelisted in the observedAttributes
property.
addoptedCallback
The addoptedCallback
method is called once the document.adoptNode(...)
method is called in order to move it to a different document.
Note that all of the callbacks above are synchronous. For example, the connected callback is called immediately after the element is added to the DOM and nothing else happens in the meantime.
Property reflection
Built-in HTML elements provide one very handy capability: property reflection. This means that the values of some properties are directly reflected back to the DOM as an attribute. Such example is the id
property.
myDiv.id = 'new-id';
Will also update the DOM to
And it applies in the opposite direction as well. This is very useful since it allows you to configure elements declaratively.
Custom elements don’t get this kind of functionality out of the box but there is a way to implement it on your own. In order to achieve the same behavior in our custom elements we can define getters and setters for the properties.
class MyCustomElement extends HTMLElement {
// ...
get myProperty() {
return this.hasAttribute('my-property');
}
set myProperty(newValue) {
if (newValue) {
this.setAttribute('my-property', newValue);
} else {
this.removeAttribute('my-property');
}
}
// ...
}
Extending elements
The custom elements API allows you not only to create new HTML elements but to also extend existing ones. And it works perfectly fine both for built-in elements and other custom ones. And it’s done just by extending its class definition.
class MyAwesomeButton extends MyButton {
// ...
}
customElements.define('my-awesome-button', MyAwesomeButton);
Or in the case of built-in elements we need to also add a third parameter to the customElements.define(...)
function which is an object with a property extends
and a value the tag name of the element that’s being extended. This tells the browser which element exactly is being extended since many built-in elements share the same DOM interface. Without specifying which element exactly you’re extending, the browser won’t know what kind of functionality is being extended.
class MyButton extends HTMLButtonElement {
// ...
}
customElements.define('my-button', MyButton, {extends: 'button'});
An extended native element is also called a customized built-in element.
What you can use as a rule of thumb is to always extend existing elements. And do this progressively. This allows you to keep all of the previous features (properties, attributes, functions).
Note that customized built-in elements are only supported by Chrome 67+ right now. It will be implemented in the other browsers as well but Safari has chosen not to implement it at all.
Upgrading elements
As mentioned above, we use the customElements.define(...)
method to register a custom element. But this doesn’t mean that it’s the first thing that you have to do. Registering a custom element can be postponed for some time in the future. Even after the element itself is added to the DOM. This process is called element upgrade. To let you know when the element is actually defined, the browser provides you the customElements.whenDefined(...)
method. You pass it the tag name of the element it returns a promise which is resolved once the element is registered.
customElements.whenDefined('my-custom-element').then(_ => {
console.log('My custom element is defined');
});
For example, you might want to delay something until all child elements are defined. Which can be really useful if you have nested custom elements. Sometimes the parent element might rely on the implementation of its children. In this case, you need to make sure that the child elements are defined before their parent.
Shadow DOM
As we already said, custom elements and shadow DOM are meant to be together. The former is used to encapsulate JavaScript logic into an element while the latter is used to create an isolated environment for a piece of DOM which is not affected by the outside world. I suggest you check out one of our previous blog posts devoted to shadow DOM to get a much better understanding of the concept.
And to use shadow DOM for your custom element you simply need to call this.attachShadow
class MyCustomElement extends HTMLElement {
// ...
constructor() {
super();
let shadowRoot = this.attachShadow({mode: 'open'});
let elementContent = document.createElement('div');
shadowRoot.appendChild(elementContent);
}
// ...
});
Templates
We’ve briefly talked about templates in one of our previous posts and they alone deserve a post of their own. Here we’re going to give a simple example how you can incorporate templates into the creation of your custom elements. Using the <template>
you can declare a DOM fragment tag which is parsed but not rendered on the page.
let myCustomElementTemplate = document.querySelector('#my-custom-element-template');
class MyCustomElement extends HTMLElement {
// ...
constructor() {
super();
let shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.appendChild(myCustomElementTemplate.content.cloneNode(true));
}
// ...
});
So now that we have combined custom elements with shadow DOM and templates we get an element which is isolated in its own scope and has a nice separation of the HTML structure and the JavaScript logic.
Styling
So we went through the HTML and JavaScript but what about CSS. Obviously, we need a way to style our elements. We can add CSS stylesheets inside the shadow DOM but then you might ask how do we style the elements from the outside as users of the element. And the answer is simple — you style them the same way as you do with built-in elements.
Note that a style defined from the outside is with a higher priority and it will override the style defined from the element.
You know how sometimes you can actually see the page render and you see for a brief moment some flash of unstyled content (FOUC). You can avoid this by defining styles for undefined components and use some kind of transition when they become defined. To do this you can use the :defined selector.
Unknown elements vs undefined custom elements
The HTML specification is very flexible and allows declaration of whatever tag you want to. And if the tag is not recognized by the browser it will be parsed as HTMLUnknownElement
.
var element = document.createElement('thisElementIsUnknown');
if (element instanceof HTMLUnknownElement) {
console.log('The selected element is unknown');
}
However, this does not apply for custom elements. Remember when we talked that there are specific naming rules for defining custom elements? The reason is that if the browser sees a valid name for a custom element it will parse it as an HTMLElement
and is considered by the browser to be an undefined custom element.
var element = document.createElement('this-element-is-undefined');
if (element instanceof HTMLElement) {
console.log('The selected element is undefined but not unknown');
}
While there might not be any visual differences between the HTMLElement and HTMLUnknownElement there are other things to keep in mind. They are treated differently by the parser. An element with a valid custom element name is expected to have a custom implementation. And until that implementation is defined it’s treated just like an empty div element. While an undefined element does not implement any method or property of any built-in element.
Browser support
The first version of custom elements was introduced in Chrome 36+. It was the so called custom components API v0 which is now deprecated and considered a bad practice although still available. Although, if you want to learn more about v0 you can read about it in this blog post. The custom elements API v1 is available since Chrome 54 and Safari 10.1 (although partially). Microsoft’s Edge is in its prototyping phase and Mozilla has it since v50 but it’s not available by default and needs to be enabled explicitly. At the moment only webkit browsers support it fully. However, as mentioned above, there’s a polyfill that allows you to use custom elements across all browsers. Yes, even IE 11.
Checking for availability
To make sure that the browser supports custom elements you can do a simple check whether the customElements
property exists in the window
object.
const supportsCustomElements = 'customElements' in window;
if (supportsCustomElements) {
// You can safely use the Custom elements API
}
Or in case you’re using the polyfill library:
function loadScript(src) {
return new Promise(function(resolve, reject) {
const script = document.createElement('script');
script.src = src;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
// Lazy load the polyfill if necessary.
if (supportsCustomElements) {
// Browser supports custom elements natively. You're good to go.
} else {
loadScript('path/to/custom-elements.min.js').then(_ => {
// Custom elements polyfill loaded. You're good to go.
});
}
So to summarize, the custom elements part of the web components standard gives you the following:
- Associating JavaScript behavior and CSS styling to an HTML element
- Allows you to extend already existing HTML elements (both built-in and other custom ones)
- Requires no library or framework to get you started. You just need vanilla JavaScript, HTML and CSS and optionally a polyfill library in order to support older browsers.
- It’s built to work seamlessly with other web components features (shadow DOM, templates, slots, etc.)
- Tightly integrated with the browser’s dev tools.
- Leverage existing accessibility features.
Custom elements are not that different from what we’ve been using until now after all. It’s just another way to make things more convenient while developing web apps. So it opens up the possibility to build very complex apps at a faster pace. But the higher the complexity the higher the chance of introducing an issue that’s hard to track down and reproduce. That’s why debbuging them requires more context and a tool like SessionStack makes a difference.
SessionStack gets integrated into into web apps to collect data such as user events, network data, exceptions, debug messages, DOM changes, and so on, and to send this data to our servers.
After that, the collected data is processed in order to create a video like experience so you can see your users interacting with your product. This, alongside all of the technical information SessionStack provides, gives you the ability to reproduce issues you’ve never been able to track down before.
So in order to ensure that SessionStack will always produce pixel perfect session replay we need to keep up with the arising technologies, frameworks, and web standards.
There is a free plan if you’d like to give SessionStack a try.
References: