iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
⚛️

Trying out Declarative Shadow DOM

に公開

The code is here.
https://github.com/musou1500/webcomponents-nodejs

I want to explore ways to integrate interactive UIs into applications that use template engines to render and return HTML.

While I could just use React, I don't want to complicate the build setup for simple tasks. Assuming a case where I want to manage things by simply hosting static files, I tried Declarative Shadow DOM + EJS.

What to make

I'll create a simple UI often seen in framework demos where entering a name displays a message like Hello, ${name}!.

The markup looks like this:

<app-greeter default-name="John">
  <template shadowrootmode="open">
    <input type="text" placeholder="Enter your name" id="input" />
    <p id="output"></p>
  </template>
</app-greeter>

Challenges

The shadowRoot property becomes null inside the connectedCallback method

Writing code like the following throws an exception:

    connectedCallback() {
      if (!this.shadowRoot) {
        throw new Error("shadow root is null");
      }

This was caused by placing the script tag before the app-greeter tag.

Writing markup every time is tedious

As shown in the markup code mentioned earlier, even the markup that you'd ideally want to be encapsulated within the component needs to be written by the consumer of the component.
While it would be ideal if it could work just by writing <app-greeter default-name="John" />, to achieve that, you would need to write JavaScript to handle the element initialization.
For now, it seems best to handle this using template engine features. In EJS, you can use include.

<!-- greeter.ejs -->
<app-greeter default-name="<%= locals?.defaultName %>">
  <template shadowrootmode="open">
    <input type="text" placeholder="Enter your name" id="input" />
    <(p id="output"></p>
  </template>
</app-greeter>

<!-- index.ejs -->
<%- include("greeter") -%>
<%- include("greeter", { defaultName: "John" }) -%>

About style definitions

I often use BEM, but elements inside the Shadow DOM are not affected by styles defined outside.

<style>
.inside {
  color: red;
}
.outside {
  color: red;
}
</style>

<app-custom-element>
  <template shadowrootmode="open">
    <!-- Won't turn red! -->
    <p class="inside">inside</p>
    <slot></slot>
  </template>
  <!-- This will turn red -->
  <p class="outside">outside</p>
</app-custom-element>

If you want to apply styles to elements inside the Shadow DOM, it's good to use CSS variables or Shadow Parts.

<style>
/* Use CSS variables... */
app-custom-element {
  --app-custom-element-color: red;
}

/* Use Shadow Parts */
app-custom-element::part(inside) {
  color: red;
}
</style>

<app-custom-element>
  <template shadowrootmode="open">
    <style>
    .inside {
      color: var(--app-custom-element-color);
    }
    </style>
    <p class="inside" part="inside">inside</p>
  </template>
</app-custom-element>

I was curious about what to do when I want to use existing style definitions, since elements inside the Shadow DOM are not affected by styles defined outside.

<link rel="stylesheet" href="/my-css-library.css" />
<app-custom-element>
  <template shadowrootmode="open">
    <!-- Styles will not be applied here -->
    <button class="button button--primary">primary button</button>
  </template>
</app-custom-element>

In such cases, you can just load the CSS inside the template tag.

<app-custom-element>
  <template shadowrootmode="open">
    <link rel="stylesheet" href="/my-css-library.css" />
    <!-- Styles are applied! -->
    <button class="button button--primary">primary button1</button>
  </template>
</app-custom-element>

<app-custom-element2>
  <template shadowrootmode="open">
    <link rel="stylesheet" href="/my-css-library.css" />
    <!-- Styles are applied! -->
    <button class="button button--secondary">secondary button</button>
  </template>
</app-custom-element2>

I was concerned that writing the link tag multiple times might cause issues with loading, parsing, or memory overhead, but it turned out to be fine.

https://web.dev/articles/declarative-shadow-dom?hl=ja
If the same stylesheet is present in multiple declarative shadow roots, it is only loaded and parsed once. The browser uses a single backing CSSStyleSheet shared by all of the shadow roots, eliminating duplicate memory overhead.

Thoughts

Using Declarative Shadow DOM was convenient because it allowed me to declaratively write element initialization logic, which tends to be cumbersome when written in plain JavaScript. However, I realized that I still need to write imperative logic for reflecting state changes to the DOM, which made me appreciate React's ability to handle even that part declaratively.

Discussion