Jason Pan

潘忠显 / 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

Lachezar Nickolov

Lachezar NickolovFollow

Aug 5, 2018 · 13 min read

img

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:

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:

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

img

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.

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:

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.

img

References: