ARTICLE
The Shadow DOM
From Web Components in Action by Benjamin Farrell
This article 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
__________________________________________________________________
Take 37% off Web Components in Action. Just enter fccfarrell into the discount code box at checkout at manning.com.
__________________________________________________________________
Slots are a way of taking templated content and adding placeholder values that can be replaced by the end user of your Web Component.
The Shadow DOM is a little more complicated. In terms of browser adoption, well…it’s getting there. Both Chrome and Safari supported it for a while, and Firefox shipped in October 2018. The Edge team has indicated that development’s in progress now, as it’s a highly requested feature.
At the same time, a lot of Web Component hype these past several years has been targeted squarely on 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.
And that’s why I haven’t gotten into the Shadow DOM until now. For me, it’s an optional feature in my daily work, only used when I’m not concerned about browser support, and I wanted to reflect this here. This concern has greatly diminished in recent days given that we’re waiting for a single browser (Edge) and the Polymer team has been hard at work on LitElement and lit-html which promise polyfill integration and support even in IE11.
All that said, you can be a Web Component developer and pick and choose which features you use, the Shadow DOM included. Once it’s shipped with all modern browsers, I plan to use it all the time — and that day’s quickly approaching.
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 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. 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 where it looks simple from the outside, but on the inside, it can be complex, and it manages its own inner workings and behavior.
Web Component support is 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, even the now obsolete HTML Imports)
- A user-defined API to control them either with attributes or class-based methods, getters, and setters
That’s all great, but often, when people talk about encapsulation, they attach a larger definition to it. I’ve defined Encapsulation, but it can also mean that your encapsulated object’s protected from end users interacting with it, even unintentionally, in ways you didn’t intend as shown in Figure 1.
Protecting Your Component’s API
Consider how your class is used and make some effort to restrict outside usage on your properties and methods by you intend on how it will be used.
One important distinction is between restricting properties and methods, or 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 which 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 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's now missing.
In this example, the addItemToUI
method shouldn't be used by the component consumer; it should only be used 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.
Alternately, the component developer could make the method private by convention. The most popular way of doing this is using the underscore, when the method would be named _addItemToUI
. You could still call the method as a user of the component, but with the underscore, you know you shouldn't.
Web Component encapsulation is more thanthe notion of protecting your component for real, or doing it by convention; it comes into play beyond your component’s class definition.
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 Listing 1.
Listing 1 A bare bones, sample component
<head>
<script>
class SampleComponent extends HTMLElement { ❶
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>
❶ 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 2.
In terms of encapsulation, how protected is that <div>
tag from the outside? It turns out, not at all. We can a <script>
tag right after our component:
<script>
document.querySelector('.inside-component').innerHTML += ' has been hijacked';
</script>
Before we talk about what can be done to solve this problem, we should break it down into two parts. Part One: I’m pretending to have malicious intent when using this component by deliberately breaking its functionality and structure from the outside. In this example, I specifically know that there’s a <div>
with a class named "inside-component", and 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 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 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 depicted in Figure 4, our <sample-component>
contains a button, and worse, it's styled to not 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!
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!
connectedCallback() {
this.attachShadow({mode: 'open'}); ❶
this.shadowRoot.innerHTML = `<div class="inside-component">My Component</div>` ❷
}
❶ Creating and attaching an open Shadow DOM to our component
❷ Setting our component’s HTML
Although there’s not much code here, it bears 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 onto you rather than take a position. 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”? The basis of the template was the document fragment. A document fragment is an entirely separate DOM tree that isn’t rendered as part of your main page. The “shadow root” is, in fact, a document fragment. This means that the shadow root’s an entirely separate DOM! It’s not 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 shown in Figure 5. What you might not expect is that elements have their own shadow root.
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. 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 6. Chrome reveals any Shadow DOM you create, but hides it by default in the normal browser elements that use it. The <select> tag's another one that has its own Shadow DOM you can view in this manner.
The Shadow Root
As we get into proper terminology like “shadow root,” familiarize yourself with the related terms shown in Figure 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 is 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”
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, this fragment is rendered in the browser, yet still maintains its independence.
Once created, we can use the newly 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 only 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 is that the query selecting our “inside-component” class comes up with nothing and setting the innerHTML
property is attempted on a null object as shown in Figure 8. That's because we've isolated the HTML inside our component with the Shadow DOM.
Closed Mode
document.querySelector('sample-component').shadowRoot.querySelector('.inside-component').innerHTML += ' has been hijacked';
Above, we’re showing Javascript that easily sets the innerHTML
of our component. 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:
Listing 3 Setting the Shadow mode to closed
connectedCallback () {
this.attachShadow({mode: 'closed'}); ❶
this.shadowRoot.innerHTML = `<div class="inside-component">My Component</div>`
}
❶ Setting the Shadow mode to closed
The call to attachShadow
returns a reference to the shadow root, whether you're in open or closed mode. If you only need a reference in the same function that you create the shadow root, you can simply declare a variable.
Listing 4 Using a variable to reference the Shadow Root
connectedCallback () {
const root = this.attachShadow({mode: 'closed'}); ❶
root.innerHTML = `<div class="inside-component">My Component</div>`
}
❶ Setting 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; };
That’s being tricky indeed, but what we’ve done is change the attachShadow
function to prevent the creation of a shadow root, and instead does nothing but passes back the Web Component's natural scope. The original component creator, who intended to create a closed shadow DOM, isn't creating a shadow DOM at all. The shadow root reference is what they were supposed to get back, but ended up being the component's scope. This trickery still works the same because this,
or 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 easily bypassed.
Recall back to the start of this article when we talked about protecting your component for real or doing it by convention. 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, https://developers.google.com/web/fundamentals/web-components/shadowdom, on Web Components say you shouldn’t use closed mode. You’re closing the Shadow DOM off to make it secure, but you’re trusting that the folks who use your component won’t bypass it in some simple ways. In the end, you’re protecting your component by convention regardless of what you do; but the closed mode makes it more difficult to develop with.
Google claims that closed mode makes 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 goes against your best wishes as a component developer. It’s a signal to the developer that uses your component that they should do it 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 which you don't intend to be accessed at present.
Google’s second gripe with closed mode is the claim that it makes your component’s Shadow DOM inaccessible from inside your own component; it’s more complicated than that. The shadowRoot
propertyis no longer available in closed mode, but we can easily make a reference to it.
We can change our current example, from having a locally scoped variable in Listing 5:
Listing 5 Locally scoped Shadow Root variable
connectedCallback() {
const root = this.attachShadow({mode: 'closed'}); ❶
root.innerHTML = `<div class="inside-component">My Component</div>`
}
❶ locally scoped scoped Shadow Root variable
…to a property on your class in Listing 6:
Listing 6 A public property containing the Shadow Root
connectedCallback () {
this.root = this.attachShadow({mode: 'closed'}); ❶
this.root.innerHTML = `<div class="inside-component">My Component</div>`
}
❶ 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 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 used a stronger way of protecting your class properties, like using Weak Maps to make your properties private, it's still isn't foolproof, but it closes things off pretty well and allow internal access to your closed DOM.
It’s clear that a closed Shadow DOM isn’t worth the trouble for most cases. No bullet proof way to completely lock down your component exists. Protecting your component by convention using the open Shadow DOM is the way to go.
Your component’s constructor vs connectedCallback
The constructor isn’t useful for many things in your component initialization. This is because the constructor fires on your component before it has access to the DOM related property and methods of your component like innerHTML.
Nothing has changed in relation to the page’s DOM. Your component, when using the Shadow DOM still doesn’t 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 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 this far. It's important to keep this distinction in mind as the Shadow DOM is only one piece of the Web Component puzzle, and as such it's 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 in Listing 7:
Listing 7 Using the constructor instead of connectedCallback
<html>
<head>
<script>
class SampleComponent extends HTMLElement {
constructor() { ❶
super(); ❷
this.attachShadow({mode: 'open'});
this.shadowRoot.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>
</html>
❶ Constructor method
❷ Call to super() is required as we extend HTMLElement
❸ Setting the innerHTML in the constructor
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, it’s 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 recently. When Firefox shipped Web Components this past October with the knowledge that Edge is on the way, I’m now happily using the Shadow DOM.
What happens when the browser of your choice doesn’t have support for the Shadow DOM? The obvious answer is to use a polyfill, as with any other feature. Unfortunately, this answer is a bit complicated for the Shadow DOM specifically.
The biggest problem when polyfilling is being defensive against accidental intrusions into your component, and we’ve covered your component’s API and your component’s local DOM as accessed through Javascript. These are great to protect against through the encapsulation that the Shadow DOM gives us. I might argue that protecting against CSS rules that bleed through is the absolute best use of the Shadow DOM. The reason I love this 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 gotten more complex. Some fairly novel workarounds have been discovered, but the Shadow DOM completely negates this problem.
Currently, the effort to polyfill the Shadow DOM is divided up into these two use-cases. Polyfilling JS access to your DOM is easy. When polyfilling custom elements, we specifically use the custom element polyfill.
We can go a little broader, though, and cover everything that’s not supported. The polyfills found at https://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 npm install…
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 Listing 10.
Listing 10 Component with Polyfill
<html>
<head>
<script src="https://unpkg.com/@webcomponents/webcomponentsjs@2.0.0/webcomponents-loader.js"></script> ❶
<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); ❷
</script>
</body>
</html>
❶ Polyfill loaded from CDN
❷ Setting our component’s innerHTML from the outside
In Listing 10, we’re using the polyfill and then testing it out by attempting to set the innerHTML
of our component. 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 the Shady DOM works well for Javascript access to the DOM. Shady CSS is a different story, and one best left for another article.
Summary
In this article 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
- The Shadow DOM offers protection to your component’s inner DOM, and it’s most useful for accidental intrusions from the outside
- Though 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 in a pinch
- Differences between constructor and connectedCallback for working with your component’s DOM whether using the Shadow DOM or not
- Polyfill support with the Shady DOM and that there’s a separate solution for CSS encapsulation
That’s all for this article. If you want to learn more about the book, check it out on liveBook here and see this slide deck.
Originally published at https://freecontent.manning.com.