Torsten Müller

Web Components: A Closer Look

published Jan 1st, 2021

In the previous post, I covered the basics of web components, starting with how to set up a web component, styling and how to provide spaces in your component to be filled dynamically or from the template, using <slot> tags. In this continuation, I’m going to explore lifecycle hooks provided by the Web Components API as well as look at how we can, from the HTML, provide parameters to the web component. Let’s get started.

Life-cycle Hooks

At the current state, our class constructor instantiates the Shadow DOM API, stores data for the current page to localStorage, starts a timer to periodically update the last-viewed stories and appends the CSS styles as well as the UI elements for the list of stories. The basic code looks like this:

last-viewed-stories.js
class LastViewedStories extends HTMLElement {

  shadowRoot = null;

  constructor() {
    super();
    this.addCurrentPageToList();
    this.shadow = this.attachShadow({mode: 'open'});

    this.interval = setInterval(this.reloadAndRerender.bind(this), 60000);

    this.shadow.appendChild(this.generateComponentStyles());
    this.shadow.appendChild(this.renderUiElement());
  }
  ...
}

This works, but we’re doing two different things in the constructor: It calls the base class’ constructor (here HTMLElement) with super() sets up the Shadow DOM and the proceeds to set up the timer as well as render the component.

Web Components offer a number of events covering the life cycle of the component. The ones discussed here are the connectedCallback() and disconnectedCallback(), which get executed when a component is added and removed from the DOM, respectively.

Since we are using a timer, it would be prudent to stop the timer when the component is removed from the DOM, to avoid potential memory leaks. Thus, we can implement the disconnectedCallback() like this:

last-viewed-stories.js
interval = null;

constructor() {
  super();
  this.addCurrentPageToList();
  this.shadow = this.attachShadow({mode: 'open'});

  this.interval = setInterval(this.reloadAndRerender.bind(this), 60000);

  this.shadow.appendChild(this.generateComponentStyles());
  this.shadow.appendChild(this.renderUiElement());
}

disconnectedCallback() {
  clearInterval(this.interval);
}

As you see, the interval gets populated by the constructor via a call to setInterval(). The resulting interval timer then gets cleared in the disconnectedCallback() method.

The other detail is that in its current form, the creation of the web component via invocation of its constructor causes the entire rendering process to take place. A potential problem lies in the possibility that not all resources required by the web component are available at that time — It also burdens the constructor with too much heavy lifting.

For this purpose, the Custom Element paradigm provides an event handler function connectedCallback() which is used to separate the main instantiation from the setup tasks to render the component and start its business logic.

So in this case, the functionality can be split up in the following manner:

last-viewed-stories.js
shadowRoot = null;
interval = null;

constructor() {
  super();
  this.shadowRoot = this.attachShadow({mode: 'open'});
}

connectedCallback() {
  this.addCurrentPageToList();
  this.interval = setInterval(this.reloadAndRerender.bind(this), 60000);

  this.shadowRoot.appendChild(this.generateComponentStyles());
  this.shadowRoot.appendChild(this.renderUiElement());
}

disconnectedCallback() {
  clearInterval(this.interval);
}

This causes the instantiation to only set up the Shadow DOM and invoke the parent class’ constructor when the Web Component is instantiated, setting it up to fulfill its other duties.

On the other hand, the connectedCallback() method will be executed when this Web Component is added to the DOM. At that point, the latest point possible, we can be assured that all the browser environment is set up, and we then perform the tasks required to set up the data and render the component to the DOM.

In addition to not overwhelming the constructor with unlike functionality, the use of these two event handler methods also has cleaned up the code and made it easier to follow.

Properties on the Web Component Tag

So far, the element rendered by this component takes all its cues for visual presentation from its embedded CSS. It sometimes is nice to affect the presentation or functionality of an element through its attributes — As we do on <a> tags, for example, which provide attributes to specify the link destination via href and rel for the relationship of the linked URL or the various attributes, or the <form> tag with its many attributes.

We can do the same thing with our web components. For this example, I’m going to provide two attributes which affect the element’s max height as well as its width:

last-viewed-stories.js

  maxHeight;
  elementWidth;

  constructor() {
    super();
    this.maxHeight = this.getAttribute('height') || '350px';
    this.elementWidth = this.getAttribute('width') || '250px';
    this.shadow = this.attachShadow({mode: 'open'});
  }

  generateComponentStyles() {
    const styleElement = document.createElement('style');
    styleElement.setAttribute('type', 'text/css');

    styleElement.textContent = `
       ol {
         max-height:${this.maxHeight};
         overflow-y: scroll;
         padding:0 13px;
       }
       ...
    `

The basic functionality is straight-forward: In our constructor, I’m reading the values of the height and/or width attributes and assign the contained values to their corresponding object properties. I also provide sensible (?) defaults for the values considering that the main use of this web component will be in a sidebar. The populated properties will then be used in our method that generates the CSS for this web component.

The use of this feature in HTML looks exactly like we’re used to from regular, standard HTML tags:

use-component.html
<div class="sidebar">
  <last-viewed width="400px" height="200px"></last-viewed>
</div>

Application of external styles

Web Components behave like a closed system when it comes to styling, but there are a few finer points about when external styles will be correctly applies and when they will be ignored.

For this sections, let’s assume the component last-view specifies the following template in its JavaScript class:

web-component-template.html
<div>
  <slot name="headline"><h3>Recent stories ...</h3></slot>
</div>

and the template using the web component looks like this, where I have added a style section where any of the “external” styling would be applied in this example:

site-template.html
<div class="sidebar" style="width:150px">
  <last-viewed></last-viewed>
</div>

<style> ... </style>

Case 1: Trying to overwrite component-internal styles from HTML

As the code stands right now, we do not specify an override of the web component’s headline so the default <h3> tag included within the <slot> tag pair will be used. If we’re trying to style that headline from the outside with something like this

external-stylesheet.css
h3 {
  font-size: 24px;
  font-style: italic;
  color: #941645;
}

It will lead to an output as shown on the left side of the following screenshot overview. This demonstrates that any external styles do not affect the styling of elements inside the web component — The two are separate!

Comparison of three setups for styling web components.

Screenshots of the first three cases discussed: 1. Define styles outside the web component (left), 2. provide content overwrite within component tags, w/o specifying styles and 3. Defining global styles and providing tag between web component tag pair.

Case 2: Providing a slot override without external styling

Let’s now remove the external styling in the template which embeds the web component and instead provide a custom headline as a child of the component tags:

site-template.html
<div class="sidebar" style="width:150px">
  <last-viewed>
    <h3 slot="headline">Some other headline</h3>
  </last-viewed>
</div>
<style></style>

In this case, the element renders as shown in the middle of the three screenshots seen above. It shows that the headline we provide replaces the default headline in the web component, albeit without taking on any of its styling — There are no margins, no bottom border etc.

This means that our internal styles for the h3 element are not applied to the node coming from the outside of the web component.

Case 3: Providing a slot override and external styling

In this case, we’re providing both a headline element and the styling for the containing web application/template. The code for the screenshot on the far right looks like this:

site-template.html
<div class="sidebar" style="width:150px">
  <last-viewed>
    <h3 slot="headline">Some other headline</h3>
  </last-viewed>
</div>
<style>
  h3 {
    font-size: 24px;
    font-style: italic;
    color: #949645;
  }
</style>

In this case, the externally defined style is applied to the externally provided h3 tag, which means we’re overriding both the content and the styling from the outside.

Case 4: Styling a web component from the outside

So this strict separation between shadow DOM and the page DOM allows/condemns us to deal with styling separately since the style rules don’t "break the barrier." Or do they?

The latest CSS specification includes the var() construct, which permits to globally define CSS variables and use them throughout the site — even inside a web component/custom element. This, in essence, is what CSS preprocessors have been providing for a while: Define variables and use them in your stylesheet.

The basic approach is to define some variables in your global stylesheet. Variables can be named however you like, but they are prefixed with two hyphens, so for example: --my-magic-size. You would define them ideally in your global stylesheet for the html element, like this:

site-wide.css
html {
  --site-font-size: 24px;
  --site-font-style: italic;
  --site-color: #949645;
}

Now that these values are defined, you can use the CSS var() function to read them and at the same time specify a default value — in case the global stylesheet does not define a value for it. The Mozilla developer docs for var() describes the approach:

web-component-style.css
h3 {
  font-size: var(--site-font-size, 15px);
  ...
}

In this style definition, the web component attempts to use the --site-font-size value defined in the rules for the page’s html tag and if that is not defined falls back to use 15px as the value for the font size.

This approach is a "safe" approach, as the author of a web component gets to decide, which styles the component accepts from the outside for styling its content. It thus does not break encapsulation as it would if global styles were universally applied to web components.

Summary

In this short post, I’ve expanded on the capabilities of web components by providing life cycle hooks to perform actions at critical points of a component’s life cycle and have implemented attributes on the web component tags to permit the user of these components to affect the way a browser renders a component on a page. The final part examined the separation of the barrier between the shadow DOM and the regular DOM as it applies to CSS rules and how one can use CSS var() to define global styles that a web component can choose to apply.