• Encapsulating Style and Structure with Shadow DOM
  • Written by Caleb Williams
  • Translation from: The Gold Project
  • This article is permalink: github.com/xitu/gold-m…
  • Translator: Xuyuey
  • Proofreader: No god, Ziyin Feng

This is the fourth part of a five-article series that discusses the Web Components specification. In the first part, we gave a comprehensive overview of the specification and what Web Components do. In Part 2 we started building a custom modal box and created the HTML template, which will evolve into our custom HTML elements in Part 3.

Series of articles:

  1. Introduction of Web Components
  2. Write reusable HTML templates
  3. Create custom elements starting at 0
  4. Use the Shadow DOM wrapper style and structure (In this paper,)
  5. Advanced tools for Web Components

Before you begin reading this article, we recommend that you read the first three articles in this series, as they are the foundation upon which the work of this article is built.

The dialog component we implemented above has a specific look, structure, and behavior, but it relies heavily on the outer DOM, requiring the consumer to understand its basic look and structure, not to mention allowing the consumer to write their own style (which ultimately changes the global style of the document). Because our dialog relies on the contents of the template element with ID “one-Dialog,” we can only have one instance of the modal box per document.

The current restrictions on our dialog components are not necessarily bad. Consumers familiar with the inner workings of dialog boxes can easily work with them by creating their own

What is a Shadow DOM?

In the introduction we said that shadow DOM “isolates CSS and JavaScript, much like the

However, unlike

Add the Shadow DOM to our dialog

To add a shadow root (base node of the shadow tree/document fragment), we need to call the element’s attachShadow method:

class OneDialog extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open'}); this.close = this.close.bind(this); }}Copy the code

We save a reference to shadow root in the element’s element. ShadowRoot property by calling the attachShadow method and setting mode: ‘open’. The attachShadow method will always return a reference to shadow root, but we won’t use it here.

If we call the attachShadow method and set the parameter mode: ‘closed’, the element will not store any references. We must use a WeakMap or Object to do the storage and retrieval, setting the node itself as the key and shadow root as the value.

const shadowRoots = new WeakMap();

class ClosedRoot extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: 'closed' });
    shadowRoots.set(this, shadowRoot);
  }

  connectedCallback() {
    const shadowRoot = shadowRoots.get(this);
    shadowRoot.innerHTML = `<h1>Hello from a closed shadow root!</h1>`;
  }
}
Copy the code

We can also store a reference to shadow root on the element itself by making it private using Symbol or other keys.

In general, there are native elements (such as

There may be legitimate use cases for the user to actively use Shadow Root in closed mode, but they are few and vary in purpose, so we will stick with Shadow Root in open mode in our dialog.

After implementing the new Open mode Shadow Root, you may notice that our element is now completely unusable when we try to run it:

Look at the example dialog box in CodePen: Use template and Shadow root.

This is because everything we had before was added and manipulated in the traditional DOM (which we call the Light DOM). Now that we have a shadow DOM bound to our element, there is no exit from the Light DOM to render. We can solve this problem by placing the content in the Shadow DOM:

class OneDialog extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.close = this.close.bind(this);
  }
  
  connectedCallback() {
    const { shadowRoot } = this;
    const template = document.getElementById('one-dialog');
    const node = document.importNode(template.content, true);
    shadowRoot.appendChild(node);
    
    shadowRoot.querySelector('button').addEventListener('click', this.close);
    shadowRoot.querySelector('.overlay').addEventListener('click', this.close);
    this.open = this.open;
  }

  disconnectedCallback() {
    this.shadowRoot.querySelector('button').removeEventListener('click', this.close);
    this.shadowRoot.querySelector('.overlay').removeEventListener('click', this.close);
  }
  
  set open(isOpen) {
    const { shadowRoot } = this;
    shadowRoot.querySelector('.wrapper').classList.toggle('open', isOpen);
    shadowRoot.querySelector('.wrapper').setAttribute('aria-hidden', !isOpen);
    if (isOpen) {
      this._wasFocused = document.activeElement;
      this.setAttribute('open'.' ');
      document.addEventListener('keydown', this._watchEscape);
      this.focus();
      shadowRoot.querySelector('button').focus();
    } else {
      this._wasFocused && this._wasFocused.focus && this._wasFocused.focus();
      this.removeAttribute('open');
      document.removeEventListener('keydown', this._watchEscape); }}close() {
    this.open = false;
  }
  
  _watchEscape(event) {
    if (event.key === 'Escape') {
        this.close();   
    }
  }
}

customElements.define('one-dialog', OneDialog);
Copy the code

The major changes to our dialog box so far are actually relatively small, but they make a big difference. First, all of our selectors (including our style definitions) are in internal scope. For example, our dialog template has only one button inside, so our CSS is only for button {… }, and these styles do not affect the Light DOM.

However, we still rely on the template outside the element. Let’s change this by removing these tags from the template and putting them in the innerHTML of shadow Root.

Example of viewing a dialog box in CodePen: Use shadow root only.

Render the content from the Light DOM

The Shadow DOM specification includes a way to allow content outside of the Shadow root to be rendered within our custom elements. It’s similar to the Ng-transclude concept in AngularJS and the use of props. Children in React. In Web Components, we can do this by using the

element.

Here’s a simple example:

<div> <span>world <! -- this would be inserted into the slot element below --></span> <#shadow-root><! -- pseudo code -->
    <p>Hello <slot></slot></p>
  </#shadow-root>
</div>
Copy the code

A given shadow root can have any number of slot elements, which can be distinguished by the name attribute. The first slot without a name in Shadow Root will be the default slot, and all unallocated content will be displayed in the document flow (left to right, top to bottom) within that node. Our dialog does need two slots: the title and some content (which we’ll set as the default slot).

Look at the example dialog box in CodePen: use shadow root and slot.

Go ahead and change the HTML part of the dialog and see the results. Anything inside the Light DOM is put into the slot assigned to it. The inserted content remains in the Light DOM, although it is rendered as if it were in the Shadow DOM. This means that the content and style of these elements can be defined by the user.

Shadow Root users use the CSS ::slotted() pseudo-selector to style the content on the Light DOM to a limited extent. However, the DOM tree in the slot is collapsed, so only simple selectors work. In other words, in the flat DOM tree of the previous example, we cannot set the style of the element inside the

element.

Best of both worlds

Our dialog box is in good shape: it has encapsulation, semantic markup, style, and behavior; However, some users still want to define their own templates. Fortunately, by combining the two techniques we learned, we can allow users to define external templates selectively.

To do this, we will allow each instance of the component to reference an optional template ID. First, we need to define a getter and setter for the component’s Template.

get template() {
  return this.getAttribute('template');
}

set template(template) {
  if (template) {
    this.setAttribute('template', template);
  } else {
    this.removeAttribute('template');
  }
  this.render();
}
Copy the code

Here, by binding it directly to the corresponding property, we’ve done something very similar to what we did with the open property. But at the bottom, we’ve introduced a new method to our component: Render. Now we can use the render method to insert the content of the Shadow DOM and remove the behavior from the connectedCallback; Instead, we will call the render method when we connect the elements:

connectedCallback() {
  this.render();
}

render() {
  const { shadowRoot, template } = this;
  const templateNode = document.getElementById(template);
  shadowRoot.innerHTML = ' ';
  if (templateNode) {
    const content = document.importNode(templateNode.content, true);
    shadowRoot.appendChild(content);
  } else{ shadowRoot.innerHTML = `<! -- template text -->`; } shadowRoot.querySelector('button').addEventListener('click', this.close);
  shadowRoot.querySelector('.overlay').addEventListener('click', this.close);
  this.open = this.open;
}
Copy the code

Not only does our dialog now have some very basic styling, but it also allows the consumer to define a new template for each instance. We can even update this component with attributeChangedCallback based on the template it is currently pointing to:

static get observedAttributes() { return ['open'.'template']; }

attributeChangedCallback(attrName, oldValue, newValue) {
  if(newValue ! == oldValue) { switch (attrName) { /** Boolean attributes */case 'open':
        this[attrName] = this.hasAttribute(attrName);
        break;
      /** Value attributes */
      case 'template':
        this[attrName] = newValue;
        break; }}}Copy the code

Look at the example dialog box in CodePen: Use Shadow root, slot, and template.

In the example above, changing the template property of the

element changes the design used when the element is rendered.

Shadow DOM style policy

Currently, the only way to define a shadow DOM node style is to add a

Inside these style tags, we can use CSS custom properties to provide apis for defining component styles. Custom properties can penetrate the boundary of a shadow and affect the content within a shadow node.

You may ask: “Can we use the element inside shadow root”? In fact, we can. However, problems can arise when trying to reuse this component across multiple applications because the CSS file may not be stored in the same location across all applications. However, if we determine the location of the element stylesheet, then we can use the element. The same is true for including the @import rule in the style tag.

It is worth mentioning that not all components need to be styled like this. Using CSS’s :host and :host-context selectors, we can simply define more primitive components as block-level elements and allow the user to define styles such as background colors, font Settings, etc. by providing class names.

On the other hand, unlike listboxes (consisting of labels and checkboxes), which can only be displayed as a combination of native elements, our dialog box is quite complex. This works just as well as style policies because styles are more specific (such as the purpose of the system, where all checkboxes might look the same). It depends a lot on your usage scenario.

CSS custom properties

One advantage of using CSS custom properties (also known as CSS variables) is that they can be passed into the Shadow DOM. In design, it provides an interface to component consumers, allowing them to externally define the theme and style of the component. However, it is worth noting that changes to custom styles inside Shadow Root do not backflow due to CSS cascading.

Check out the CSS custom styles and shadow DOM in CodePen.

Continue to comment or remove the variables set in the CSS panel in the example above to see how it affects the rendered content. If you look at the innerHTML of the Shadow DOM, it doesn’t affect the light DOM, no matter how the shadow DOM defines its own properties.

Constructible style sheets

At the time of this writing, there is a proposed Web feature that allows for more modular definitions of shadow DOM and Light DOM styles using constructible stylesheets. This feature is already in Chrome 73, and there’s been a lot of positive news from Mozilla.

This feature allows consumers to define style sheets in their JavaScript files, similar to how normal CSS is written and shared between multiple nodes. Therefore, a single stylesheet can be added to multiple Shadow roots as well as documents.

const everythingTomato = new CSSStyleSheet();
everythingTomato.replace('* { color: tomato; } ');

document.adoptedStyleSheets = [everythingTomato];

class SomeCompoent extends HTMLElement {
  constructor() {
    super();
    this.adoptedStyleSheets = [everythingTomato];
  }
  
  connectedCallback() { this.shadowRoot.innerHTML = `<h1>CSS colors are fun</h1>`; }}Copy the code

In the example above, the everythingTomato stylesheet can be applied to both the Shadow root and the body of the document. Useful for teams that want to create design systems and components that can be shared by multiple applications and frameworks.

In the next example, we can see a very basic example of how constructible stylesheets can be used and the power they provide.

See an example of a constructible style representation in CodePen.

In this example, we construct two stylesheets and add them to the document and custom elements. After three seconds, we remove a stylesheet from shadow Root. However, for those three seconds, the document and shadow DOM share the same style sheet. With the polyfill included in this example, there are actually two style elements, but Chrome works quite naturally.

The example also includes a form that shows how to asynchronously and effectively change the rules of the worksheet as needed. This addition to the Web platform can be a powerful ally for users who want to provide themes for their websites, or who want to create design systems that span multiple frameworks or urls.

There is also a proposal for a CSS module that could eventually be used in conjunction with the adoptStyleSheets feature. If implemented in its current form, the proposal would allow CSS to be imported as a module, just like ECMAScript modules:

import styles './styles.css';

class SomeCompoent extends HTMLElement {
  constructor() { super(); this.adoptedStyleSheets = [styles]; }}Copy the code

Parts and Themes

Another feature used to style Web components is the ::part() and ::theme() pseudo-selectors. The :: Part () specification allows consumers to define part of their custom elements by providing the following style definition interface:

class SomeOtherComponent extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>h1 { color: rebeccapurple; }</style>
      <h1>Web components are <span part="description">AWESOME</span></h1>
    `;
  }
}
    
customElements.define('other-component', SomeOtherComponent);
Copy the code

In our global CSS, we can locate any element whose part attribute has a value of Description by calling the CSS ::part() selector.

other-component::part(description) {
  color: tomato;
}
Copy the code

In the above example, the

tag’s main message is colored differently from the description section, allowing consumers of custom elements to expose their component’s style API and maintain control over the parts they want to maintain control over.

The difference between ::part() and ::theme() is that ::part() must act on a specific selector, and ::theme() can be nested at any level. The following example has the same effect as the CSS code above, but it also applies to any other element that contains part=”description” in the entire document tree.

:root::theme(description) {
  color: tomato;
}
Copy the code

Like constructible stylesheets, ::part() is already available in Chrome 73.

conclusion

Our dialog component is now complete. It has its own markup, style (without any external dependencies), and behavior. This component can now be included in projects that use any current or future framework because they are built according to browser specifications rather than third-party apis.

Some of the core controls are a bit verbose and more or less rely on some knowledge of how the DOM works. In our final article, we’ll discuss higher-level tools and how they can be used in conjunction with popular frameworks.

Series of articles:

  1. Introduction of Web Components
  2. Write reusable HTML templates
  3. Create custom elements starting at 0
  4. Use the Shadow DOM wrapper style and structure (In this paper,)
  5. Advanced tools for Web Components

If you find any errors in the translation or other areas that need improvement, you are welcome to revise and PR the translation in the Gold Translation program, and you can also get corresponding bonus points. The permanent link to this article at the beginning of this article is the MarkDown link to this article on GitHub.


Diggings translation project is a community for translating quality Internet technical articles from diggings English sharing articles. The content covers the fields of Android, iOS, front end, back end, blockchain, products, design, artificial intelligence and so on. For more high-quality translations, please keep paying attention to The Translation Project, official weibo and zhihu column.