Chapter 8. The Shadow DOM

This chapter covers

  • Component and class encapsulation
  • How the Shadow DOM protects your component’s DOM
  • The open and closed Shadow DOM
  • Shadow DOM terminology: shadow root, shadow boundary, and shadow host
  • Polyfilling with the Shady DOM

In the last chapter, we peeked briefly at the Shadow DOM to introduce the concept of slots. If you recall, slots are a way of taking templated content and adding placeholder values that can be replaced by your Web Component’s end user. We marked the areas that can accept new HTML content as slots.

While the <template> tag is a standalone concept and available in all modern browsers, the <slot> tag is not. In fact, the <slot> tag is dependent on the Shadow DOM. We’ve covered every core feature of Web Components so far, except for the Shadow DOM.

There’s a reason I’m covering it last, and that’s because I want to show that it’s not entirely necessary to the Web Component story, as awesome as it is. In the past several chapters, we’ve covered Custom Elements, templates, and HTML Imports, as well as non-Web Component-based techniques to back them up, like ES2015 modules and template literals. All these concepts are either available now for all modern browsers or easily polyfilled.

The Shadow DOM is a little more complicated. In terms of browser adoption, we’re only just now seeing near-universal coverage in modern browsers, with Microsoft releasing its Chromium-backed latest version of Edge as a developer preview. This is after Firefox’s October 2018 release with full Web Component support.

Even with spotty adoption until recently, a lot of Web Component hype these past several years has been targeted squarely at the Shadow DOM. I agree that it’s a groundbreaking browser feature for web development workflows, but Web Components are much more than this one feature. Regardless, part of the disappointment in the community around Web Components has been the slowness of Shadow DOM adoption, coupled with how problematic it is to polyfill.

And that’s why I haven’t gotten into the Shadow DOM until now in this book. For me, it’s an optional feature in my daily work, used only when I’m not concerned about browser support, and I wanted to reflect that here. This concern has greatly diminished over the past few months, given that we’re waiting for a single browser (Edge); meanwhile the Polymer team has been hard at work on LitElement and lit-html, which promise polyfill integration and support even in IE11.

You can be a Web Component developer and pick and choose which features you use, the Shadow DOM included. That said, once it’s shipped with all modern browsers, I plan to use it all the time—and that day is quickly approaching and will likely have arrived by the time this book is published!

8.1. Encapsulation

In terms of hype for the Shadow DOM, the claims I’ve seen are that it removes the brittleness of building web apps, and that it finally brings web development up to speed with other platforms. Does it live up to those claims?

I’ll let you decide, because like anything, the answer depends on your project and needs. However, both claims are made with one central theme in mind: encapsulation.

When people talk about encapsulation, they typically mean two things. The first is the ability to wrap up an object in a way that it looks simple from the outside; but on the inside, it might be complex, and it manages its own inner workings and behavior.

Thus far, everything we’ve learned about Web Components supports them being a great example of this encapsulation definition. Web Components offer

  • A simple way to include themselves on an HTML page (custom elements)
  • Multiple ways to manage their own dependencies (ES2015 modules, templates, and even the now-obsolete HTML Imports feature, which can be easily polyfilled)
  • A user-defined API to control them, with either attributes or class-based methods, getters, and setters

This is all great, but often, when people talk about encapsulation, they attach a larger definition to it. Encapsulation is what we just discussed; but it can also mean that your encapsulated object is protected from end users interacting with it, even unintentionally, in ways you didn’t intend, as figure 8.1 shows.

Figure 8.1. Encapsulation means hiding the inner workings of an object, but it often includes choosing how and where to provide access from the outside in.

8.1.1. Protecting your component’s API

In the appendix, I mention a couple ways to make your variables private in your Web Component class. What’s important is that you, as a developer, have thought about how your class is used and made some effort to restrict outside usage of your properties and methods to only how you intend them to be used.

One important distinction is between actually restricting properties and methods and restricting them by convention only. A good example of restricting by convention is using the underscore on properties and variables in your class.

For example, someone on your team may hand you a component that has a method to add a new list item element to its UI:

addItemToUI(item) {
        this.appendChild(`<li>${item.name}</li>`);
}

When you use this component for the first time, you might think, “Hey, I’ll just use this function to add a new item to my list!” What you don’t know is that the component’s class has an internal array of the item data. As a consumer of this component, you’re supposed to use the add() method, which adds an item to the data model and then calls the addItemToUI function to then add the <li> element:

add(item) {
    this.items.push(item);
    this.addItemToUI(item);
}

When the component is resized or collapsed/hidden and shown again, these list elements are destroyed and then redrawn using the internal data model. As someone using this component for the first time, you didn’t know that would happen! When you used addItemToUI instead of add, the component was redrawn, and that item you added is now missing.

In this example, the addItemToUI method shouldn’t be used by the component consumer; it should be used only internally, by the component. If the original component developer took the time and effort to make the method private, it would have been impossible to call at all.

Alternately, the component developer could make the method private by convention. The most popular way of doing so is using the underscore, in which case the method would be named _addItemToUI. You could still call the method as a user of the component, but with the underscore, you’d know you really shouldn’t.

There is more to Web Component encapsulation. This notion of protecting your component for real, or just doing so by convention, comes into play beyond your component’s class definition.

8.1.2. Protecting your component’s DOM

Protecting your custom Web Component class’s methods and properties is likely the least of your concerns! What else in your component should be protected? Consider the component in the following listing.

Listing 8.1. A bare-bones, sample component
<head>
   <script>
       class SampleComponent extends HTMLElement {          1
           connectedCallback() {
               this.innerHTML =
               `<div class="inside-component">My Component</div>`
           }
       }

       if (!customElements.get('sample-component')) {
           customElements.define('sample-component', SampleComponent);
       }

   </script>
</head>
<body>
   <sample-component></sample-component>
</body>

  • 1 A dead simple Web Component placed on a web page

As you might notice, there’s not much to this component. It simply renders a <div> with the text “My Component” inside, shown in figure 8.2.

Figure 8.2. A simple Web Component rendering a short string in the browser

In terms of encapsulation, how protected is that <div> tag from the outside? It turns out, not at all. We can add a <script> tag right after our component:

<script>
   document.querySelector('.inside-component').innerHTML +=
   ' has been hijacked';
</script>

In figure 8.3, our browser output shows that our component’s innerHTML has indeed been set from the outside. Breaking down what happened, an outsider successfully query-selected the <div> inside our component and set its innerHTML.

Figure 8.3. Setting the innerHTML of our component’s DOM from the outside

Before we talk about what can be done to solve this problem, we should break it down into two parts. In part one, I’m pretending to have malicious intent when using this component in a way it shouldn’t be used by deliberately breaking its functionality and structure from the outside. In this example, I specifically know there is a <div> with a class named inside-component, I know it has some text that it’s displaying, and I’m purposely changing it.

Part two is of a less malicious nature. What if we did something similar accidentally? When a simple custom tag like <sample-component> is on the page, it’s easy to forget it can contain any number of elements, like an additional button, all with class names you’ve used over and over again. For example, what if your page had the following HTML, and you wanted to add a click listener to the button when your component already has a button inside?

<sample-component></sample-component>
<button>Click Me</button>

Given that in this short snippet, the Click Me button is the button in the page source, you might be tempted to do this:

document.querySelector('button').addEventListener('click', . . .);

In the hypothetical situation depicted in figure 8.4, our <sample-component> already contains a button, and worse, it’s styled to not even look like a button! As a result, you’ve query-selected the wrong button and are completely confused why your button click doesn’t work when you try it in your browser.

Figure 8.4. Query-selecting a button on the page, but unintentionally picking up a button in our Web Component

8.2. Enter the Shadow DOM

The Shadow DOM attempts to solve both problems but comes up a little short for malicious users. To explain, let’s try it out!

What we can try first is not allowing the <div> in our previous example to be hijacked. Using the Shadow DOM, we can easily block normal access to this <div>, and for this, we just need to change two lines in our connectedCallback, as follows.

Listing 8.2. Using the Shadow DOM in a simple component
connectedCallback() {
   this.attachShadow({mode: 'open'});                       1
   this.shadowRoot.innerHTML =
   `<div class="inside-component">My Component</div>`       2
}

  • 1 Creates an open Shadow DOM and attaches it to our component
  • 2 Sets our component’s HTML

There’s not much code here, but it does bear some explanation. The first thing we’re doing is creating a shadow root and attaching that shadow root to our component. In this example, we’re using a mode of open to create it. Please note that this is a required parameter. Because browser vendors couldn’t agree on what the default should be, closed or open, they’ve passed this issue on to you rather than take a position themselves. It’s easier to explain the difference between these modes after exploring what’s going on in the code first.

Aside from being closed or open, what is the shadow root? Remember back to chapter 7 and our discussion of the <template> tag. Recall that the basis of the template was the document fragment. A document fragment is an entirely separate DOM tree that is not rendered as part of your main page. The shadow root is, in fact, a document fragment. This means that the shadow root is an entirely separate DOM! It’s not actually the same DOM as the rest of your page.

We can view the shadow root in action in this example by opening Chrome’s dev tools, as figure 8.5 shows. What you might not expect is seeing that elements you use every day have their own shadow root.

Figure 8.5. Viewing the Shadow DOM and associated shadow root in Chrome’s dev tools

Let’s take a peek at a video tag. We don’t have to properly set it up with a video source to see its shadow root and the rest of its Shadow DOM. Simply drop a <video></video> tag in your HTML. Inspecting it in Chrome using the default settings won’t reveal much. To reveal its Shadow DOM, you’ll need to allow it to show the “user agent Shadow DOM,” as in figure 8.6. Essentially, Chrome will reveal any Shadow DOM you create, but will hide it by default in the normal browser elements that use it. The <select> tag is another one that has its own Shadow DOM you can view in this manner.

Figure 8.6. Viewing the user agent Shadow DOM/root for everyday elements

8.2.1. The shadow root

As we get into proper terminology like “shadow root,” familiarize yourself with the related terms shown in figure 8.7:

  • Shadow root—The document fragment containing the separate DOM.
  • Shadow tree—The DOM contained by the shadow root.
  • Shadow host—The node of your page DOM that parents the shadow tree/root. For our purposes, this is your Web Component, though it could easily be used outside of a custom element.
  • Shadow boundary—Imagine this as a line between your shadow host and shadow tree. If we reach into the shadow tree from our component and set text on a button, for example, we could say we’re crossing the “shadow boundary.”
Figure 8.7. The Shadow DOM, host, root, and boundary (the dotted line)

Terminology aside, the important takeaway is that we’re dealing with a new DOM inside a document fragment. Unlike a document fragment used by the <template> tag, however, this fragment is actually rendered in the browser, yet still maintains its independence.

Once created, we can use the new and automatically created property of our component, shadowRoot, to access any of our element’s properties, like innerHTML. This is what we did in our example:

this.shadowRoot.innerHTML =
  `<div class="inside-component">My Component</div>`

With just this change, we’ve now protected our component from accidental intrusions. When we now run the same query selector and try to set the innerHTML, it fails:

document.querySelector('.inside-component').innerHTML +=
  ' has been hijacked';

Our error reads

Uncaught TypeError: Cannot read property 'innerHTML' of null

What happens now? Query-selecting our inside-component class comes up with nothing, and setting the innerHTML property is attempted on a null object, as figure 8.8 shows. That’s because we’ve isolated the HTML inside our component with the Shadow DOM.

Figure 8.8. Attempting to query-select inside the Shadow DOM

8.2.2. Closed mode

Here’s the thing, though. If we wanted to be malicious, we still could be. The same shadowRoot property is available from the outside. We could adjust our query selector to be more complex and still set the innerHTML of that <div>:

document.querySelector('sample-component').shadowRoot.querySelector
 ('.inside-component').innerHTML += ' has been hijacked';

Here, we’re showing JS that easily sets our component’s innerHTML. Can we stop those malicious users from coming in and manipulating our component in ways we don’t want? The answer appears to be no, but that’s where closed mode comes in. Curtailing malicious users is the intention behind having two modes. To explain, let’s set mode to closed in the following listing.

Listing 8.3. Setting the shadow mode to closed
connectedCallback () {
   this.attachShadow({mode: 'closed'});          1
   this.shadowRoot.innerHTML =
      `<div class="inside-component">My Component</div>`
}

  • 1 Sets the shadow mode to closed

This won’t work as intended, however, without changing something else! With the shadow root closed, the shadowRoot property doesn’t exist (it’s null), so we can’t set the innerHTML through it. How, then, can we interact with our own component when working from the inside?

The call to attachShadow does return a reference to the shadow root, whether you’re in open or closed mode. If you only need a reference in the same function where you created the shadow root, you can simply declare a variable, as follows.

Listing 8.4. Using a variable to reference the shadow root
connectedCallback () {
   const root = this.attachShadow(             1
      {mode: 'closed'});
   root.innerHTML = `<div class="inside-component">My Component</div>`
}

  • 1 Sets a variable to the newly created shadow root

If that’s the only interaction point with your component’s Shadow DOM, problem solved! You’ve taken steps to close off your component from malicious users . . . except for one more thing. Let’s pretend we are malicious and will stop at nothing to sabotage this component. We can change the function definition of attachShadow after the component class is declared:

SampleComponent.prototype.attachShadow = function(mode) { return this; };

This is being very tricky indeed, but what we’ve done is change the attachShadow function so that it doesn’t actually create a shadow root and instead does nothing but pass back the Web Component’s natural scope. The original component creator, who intended to create a closed shadow DOM, is not creating a shadow DOM at all. The shadow root reference is what they were supposed to get back, but it ended up really just being the component’s scope. This trickery still works the same because this, and the shadow root, have approximately the same API.

And now we’re back to our original, easy way of taking over the component:

document.querySelector('.inside-component').innerHTML +=
   ' has been hijacked';

Should you expect people who use your component to try to break in in this way? Probably not. But they could. It’s not real security because it’s so easily bypassed.

Recall at the start of this chapter when we talked about protecting your component for real or doing so by convention. There, we discussed using the underscore to protect private variables and methods in your class instead of using more secure ways. Here, it’s the same thing, but instead of variable and methods, we’re talking about your component’s DOM.

That’s why Google’s own documentation on Web Components says you shouldn’t use closed mode (https://developers.google.com/web/fundamentals/web-components/shadowdom). You’re closing off the Shadow DOM to make it secure, but you’re trusting that the folks who use your component won’t bypass it in some very simple ways. In the end, you’re protecting your component by convention regardless of what you do; it’s just that closed mode makes it more difficult to develop with.

Google claims that closed mode will make your component suffer for two reasons. The first is that by allowing component users into your component’s Shadow DOM through the shadowRoot property, you’re at least making an escape hatch. Whether you’re making private class properties with underscores or keeping the Shadow DOM open, it’s protecting your class or component by convention.

Despite your best intentions for your component, you likely won’t accommodate all use cases all the time. Having a way into your component allows some flexibility, but it’s also important to recognize that this way in goes against your better judgement as a component developer. It’s a signal to the developer who uses your component that they should do so at their own risk. That’s ill-advised, of course, but when deadlines are tight, and a web app needs to be shipped tomorrow, it’s nice to provide a path forward with an open Shadow DOM using the shadowRoot property to access things you don’t intend to be accessed at present. You’ll also see that an escape hatch with the open mode is rather nice for reaching in to perform automated testing, as we’ll discuss in chapter 13.

Google’s second gripe with closed mode is the claim that it makes your component’s Shadow DOM inaccessible from inside your own component. But it’s more complicated than that. The shadowRoot property is no longer available in closed mode, but we can easily make a reference to it.

Our current example has a locally scoped variable in the next listing.

Listing 8.5. Locally scoped shadow root variable
connectedCallback() {
   const root = this.attachShadow(             1
        {mode: 'closed'});
   root.innerHTML = `<div class="inside-component">My Component</div>`
}

  • 1 Locally scoped shadow root variable

Now let’s change it to having a property on your class.

Listing 8.6. A public property containing the shadow root
connectedCallback () {
   this.root = this.attachShadow(           1
        {mode: 'closed'});
   this.root.innerHTML = `<div class="inside-component">My Component</div>`
}

  • 1 The shadow root saved as a public property

On the other hand, making it a public property defeats the purpose. Again, you’re back to having a public reference to the Shadow DOM; it just happens to be named root (or any property name you choose) instead of the shadowRoot property, as created by an open Shadow DOM. And again, it’s easy to access your component’s DOM through it. That said, if you did use a stronger way of protecting your class properties, like using Weak Maps to make your properties private, it’s still wouldn’t be foolproof, but it would close things off pretty well and allow internal access to your closed DOM just fine. It might be worth speculating that a truly closed Shadow DOM might be achievable once we have native private class fields available in all browsers, but we just aren’t there yet.

It’s clear that a closed Shadow DOM isn’t worth the trouble for most cases. There is no bulletproof way to completely lock down your component, and protecting your component by convention using the open Shadow DOM is the way to go.

8.2.3. Your component’s constructor vs. connectedCallback

Back in chapter 4, when discussing the component API, I cautioned that the constructor wasn’t very useful for many things in your component initialization. This is because when the constructor fires on your component, it doesn’t yet have access to your component’s DOM-related property and methods, like innerHTML.

Now, with the Shadow DOM, nothing has changed in relation to the page’s DOM. Your component, when using the Shadow DOM, still does not have access to the DOM-related properties and methods for your element until it gets added to the page DOM with connectedCallback.

Despite this all being true, it’s no longer actually a concern. We’re no longer relying on the page’s DOM. We’re creating a separate mini DOM for our component when we call attachShadow. This mini DOM is immediately available, and we can write its innerHTML right away!

This is why you’ll see most examples of Web Components using the constructor to do all of the initialization work instead of the connectedCallback method, as we’ve been using so far. Going forward in this book, I’ll likely do everything in the constructor because I’ll be using the Shadow DOM. But it’s important to keep this distinction in mind, given that the Shadow DOM is just one piece of the Web Component puzzle and, as such, it is optional (even though you’ll probably want to use it from here on in). Let’s change our previous simple example slightly to reflect this.

Listing 8.7. Using the constructor instead of connectedCallback
<html>
<head>
    <script>
        class SampleComponent extends HTMLElement {
            constructor() {                                              1
                super();                                                 2
                this.attachShadow({mode: 'open'});
                this.shadowRoot.innerHTML =
                 `<div class="inside-component">My Component</div>`      3
            }
        }

        if (!customElements.get('sample-component')) {
            customElements.define('sample-component', SampleComponent);
        }
    </script>
</head>
<body>
    <sample-component></sample-component>
</body>
</html>

  • 1 Constructor method
  • 2 Call to super() is required as we extend HTMLElement
  • 3 Sets the innerHTML in the constructor

8.3. The Shadow DOM today

Though the Shadow DOM sounds pretty amazing, it has a history of being a bit unreliable. I’m not knocking the implementation or the spec, just the slow inclusion of it as a supported feature in all modern browsers, as I mentioned at the start of this chapter. I’ve personally been in a holding pattern until very recently. When Firefox shipped Web Components this past October, and with the knowledge that Edge is on the way, I’m now happily using the Shadow DOM in my newer projects.

What happens when the browser of your choice doesn’t have support for the Shadow DOM? The obvious answer is to use a polyfill, just like with any other feature. Unfortunately, this answer is a bit complicated for the Shadow DOM specifically.

The biggest problem when polyfilling is the subject of the next chapter. In terms of being defensive against accidental intrusions into your component, we’ve covered your component’s API and its local DOM as accessed through JS. These are great to protect against through the encapsulation that the Shadow DOM gives us. I might argue, however, that protecting against CSS rules that bleed through is the absolute best use of the Shadow DOM. The reason I love this so much is that web developers have been struggling with this problem since CSS was a thing, and it’s only gotten worse as web experiences have become more complex. There are some fairly novel workarounds, but the Shadow DOM completely negates this problem.

Currently, the effort to polyfill the Shadow DOM is divided up into these two use cases. We’ll talk about CSS and its polyfill in the next chapter. Polyfilling JS access to your DOM is really easy, though. Back in chapter 2, when polyfilling custom elements, we specifically used the custom element polyfill.

We can go a little broader, though, and cover everything that’s not supported. The polyfills found at www.webcomponents.org/polyfills offer some smart feature detection and fill in features where appropriate. That includes both custom elements and the Shadow DOM.

One option is to use

npm install @webcomponents/webcomponentsjs

and then add the <script> tag to your page:

<script src="node_modules/@webcomponents/webcomponentsjs/
    webcomponents-bundle.js"></script>

Additionally, a CDN option is available. In the end, we should have something that works in all modern browsers, as in the next listing.

Listing 8.8. Component with polyfill
<html>
<head>
    <script src="https://unpkg.com/@webcomponents/[email protected]/
     webcomponents-loader.js"></script>                                 1
    <script>
        class SampleComponent extends HTMLElement {
            constructor() {
                super();
                this.root = this.attachShadow({mode: 'open'});
            }

            connectedCallback() {
                if (!this.initialized) {
                    this.root.innerHTML = 'setting some HTML';
                    this.initialized = true;
                }
            }
        }

        if (!customElements.get('sample-component')) {
            customElements.define('sample-component', SampleComponent);
        }

    </script>
</head>
<body>

<sample-component></sample-component>

<script>
    setTimeout(function() {
        document.querySelector('sample-component').innerHTML =
        'Component is hijacked';
    }, 500);                                                              2
</script>
</body>
</html>

  • 1 Polyfill loaded from CDN
  • 2 Sets our component’s innerHTML from the outside

We’re using the polyfill and then testing it out by attempting to set our component’s innerHTML. I used a timer here to set the innerHTML to make sure we try to hijack the component after it tries to set its own text in the connectedCallback. Using the Shadow DOM in most browsers, setting the innerHTML from outside the component fails. With the polyfill and the “Shady DOM,” the same behavior happens in those that don’t support the Shadow DOM, like Microsoft’s Edge (with support coming soon) and IE.

As I alluded to before, however, the Shady DOM works pretty well for JS access to the DOM. Shady CSS is a different story, and one that we’ll jump right into in the next chapter!

Summary

In this chapter, you learned

  • What encapsulation is and how a self-contained object is only half the battle. Protecting and offering controlled access to your object is also important.
  • That the Shadow DOM offers protection to your component’s inner DOM and is most useful for accidental intrusions from the outside.
  • That although the Shadow DOM offers a closed mode, it’s impractical, and protecting your component by convention with an open Shadow DOM is the way forward, especially because it offers a way to bypass its protective boundary in a pinch.
  • Differences between constructors and connectedCallback for working with your component’s DOM when using or not using the Shadow DOM.
  • How to use polyfill support with the Shady DOM and that there is a separate solution for CSS encapsulation.
..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.145.36.10