Skip to main content

Published – last updated skip to updates

lol I made my own component framework

why did I do this (oh yeah because of the pandemic)

Example: /examples/lol-web-components

This website is a playground for me, so I decided to rewrite my Blog Admin code to use Web Components. It worked, and I liked it! 🎉 But then I didn’t: writing HTML in JavaScript strings isn’t great. Why can’t we have both HTML and JavaScript defined in the same file and encapsulated separate from the rendered page?

Table of contents

In this article, we will:

  1. Design our own component HTML file
  2. Create a Web Component that imports the component HTML file
  3. Add the template, style, and script from the component HTML file into the ShadowRoot of the Web Component
  4. Ensure the script executes
  5. Export a View class from the script
  6. Turn the script into an importable module
  7. Import and initialise the View class with the ShadowRoot
  8. Change the template dynamically
  9. Updates
  10. Notes

That sounds like the HTML Imports spec

(HTML Imports explained beautifully on html5rocks.com)

Unfortunately, you may have heard, HTML Imports has been abandoned – the only browsers that supported it were Blink-based (Chrome, Opera, Edge) and now they’ve deprecated it. The reasons why are explained succinctly by the proposal to replace it: HTML Modules W3C proposal to replace HTML Imports.

Namely:

  1. Global object pollution
  2. Inline scripts block the document parsing
  3. Inability for script modules to access declarative content

(A wonderfully worded question, and some very interesting answers, provide more information about the current situation of HTML Imports)

So what can we do? It would be so nice (super nice) to have everything about a component all in the same file: HTML, CSS (<style> and <link rel=stylesheet>), and JavaScript.

cough Vue – hm? What’s that? cough Svelte cough – huh? Oooooohhhhhhhhh, Frameworks.

Yeah... nah 🙃

I’m not hating on frontend frameworks

They’re great! Easy! Convenient! Well supported and fantastic browser compatibility! Fun to write in! And I would absolutely choose a framework for a production system because writing something custom will be painful, no doubt 😅.

However, I wanted to keep my website free of a frontend build step, so I could write and commit HTML, CSS, and JavaScript anywhere at any time. This is a Jekyll site, but that compilation is handled for me by GitHub Pages, so I can commit via git or the GitHub file editor.

If you don’t mind a build step, that’s great! You can probably get a lot more functionality out of something from “The Simplest Ways to Handle HTML Includes” on css-tricks.com than this weird little mismash of code that you’re about to see here 🙃

...because writing something custom will be painful, no doubt 😅”

We’re here for fun, so let’s do it anyway!

What do we want? HTML and JavaScript defined in the same file!
When Where do we want it? In the same file! We just said!

Component HTML file design

So something like this then?

<template>
  <p>This is a lovely component</p>
</template>

<style>
  p {
    color: red;
  }
</style>

<script>
  alert('It works!');
</script>

👉 👈👀

lol yes ok, this is pretty much a Vue single-file component. We could use VueJS (or Svelte) – but again, that would require a build step, so ❌ (buzzer sound)

Anyway, this can very easily go into a Web Component: we have the <template>, and everything else is regular HTML ✅

How do we import a HTML file?

The Fetch API is our friend here: we can fetch() the HTML file from the webserver* as plain text, perfect for inserting into a holding element to get the browser to build the HTML for us:

fetch('/test.component.html')
  .then(response => response.text())
  .then(html => {
    const holder = document.createElement('div');
    holder.innerHTML = html;
    const template = holder.querySelector('template'); // our <template> from above
  });

Let’s put this into a Web Component:

// test.component.js
export class TestComponent extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: 'open' });

    fetch('/test.component.html')
      .then(response => response.text())
      .then(html => {
        const holder = document.createElement('div');
        holder.innerHTML = html;

        const template = holder.querySelector('template');
        shadowRoot.appendChild(template.content.cloneNode(true));
      });
  }
}
<!-- index.html -->
<body>
  <test-component></test-component>
  <script type="module">
    import { TestComponent } from './test.component.js';
    customElements.define('test-component', TestComponent);
  </script>
</body>

Browser dev tools showing the paragraph content from the test web component correctly loaded into the DOM

Great! It works!

Add the CSS and JavaScript from the component file

const style = holder.querySelector('style');
const script = holder.querySelector('script');
shadowRoot.appendChild(style);
shadowRoot.appendChild(script);

Browser dev tools showing the red paragraph style correctly applied to the test component

The <style> is working, but there was no alert: the <script> didn’t run 🤔 Why is that?

Ensure the script executes

Oh: the HTML5 spec on innerHTML says

Note: script elements inserted using innerHTML do not execute when they are inserted.

Thanks to Daniel Crabtree’s article: Gotchas with dynamically adding script tags to HTML

But we can execute inline JavaScript as long as we use document.createElement('script'), then we can insert the contents with innerHTML:

const script = holder.querySelector('script');
const newScript = document.createElement('script');
script.getAttributeNames().forEach(name => {
  // Clone all attributes.
  newScript.setAttribute(name, script.getAttribute(name));
});
// Clone the content.
newScript.innerHTML = script.innerHTML;
// Adding will execute the script.
shadowRoot.appendChild(newScript);

Browser alert saying "It works!"

🎉 🎉 🎉

That’s it!

All done now. Nothing else to do. Bye-bye, see you later 👋







………






Oh, you wanted interactivity in your component? 😅 Sure ok, let’s continue!

Export a View class from the script

class View {
  constructor(el) {
    this.el = el;

    this.el.addEventListener('click', () => {
      alert('I was clicked');
    });
  }
}

Ok. That’s good and all, very generic. Seems like a view that tells you when anything inside it is clicked. Great!

But… how do we give it the template?

Turn the script into an importable module

Maybe we can export it?

<!-- test.component.html -->
<script type="module">
  export class View {
    // ...
  }
</script>

Ok, now let’s import it and give it the element! Let’s just say that we always need to export a View class, so we don’t have to do any kind of special detection: if a module exports View, we use it.

So, back in our Web Component, after appending the script to the shadowRoot, let’s import this one:

// test.component.js
export class TestComponent extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: 'open' });

    fetch('test.component.html')
      .then(response => response.text())
      .then(html => {
        const holder = document.createElement('div');
        holder.innerHTML = html;

        const template = holder.querySelector('template');
        const style = holder.querySelector('style');
        shadowRoot.appendChild(template.content.cloneNode(true));
        shadowRoot.appendChild(style);

        const script = holder.querySelector('script');
        const newScript = document.createElement('script');
        script.getAttributeNames().forEach(name => {
          newScript.setAttribute(name, script.getAttribute(name));
        });
        newScript.innerHTML = script.innerHTML;
        shadowRoot.appendChild(newScript);

        import { View } from newScript; // this is an error
      });
  }
}

Hold on: how can we import the script tag?

Firstly, we cannot use static imports, so we must use dynamic imports:

import(newScript).then(module => {
  module.View; // our view class
});

Secondly, that doesn’t work because the script variable is an HTMLScriptElement, not an importable string.

Hhmmm. This sounds like HTML Imports Problem No.3:

Inability for script modules to access declarative content

Import and initialise the View class with the ShadowRoot

/me searches “inline script module export” … … … a-ha!

I found an example of “Inlining ECMAScript Modules in HTML” on StackOverflow. In it, the contents of the script can be turned into an “Object URL”, which we can use to import!

So let’s do that before adding the script to the shadowRoot:

// test.component.js
const script = holder.querySelector('script');
const newScript = document.createElement('script');
newScript.innerHTML = script.innerHTML;

const scriptBlob = new Blob([newScript.innerHTML], {
  type: 'application/javascript'
});
newScript.src = URL.createObjectURL(scriptBlob);
shadowRoot.appendChild(newScript);

Now we should be able to import and initialise it:

// test.component.js
import(newScript.src).then(module => {
  new module.View(shadowRoot);
});

Change the template dynamically in the View class

Let’s change our view to act on the element:

<!-- test.component.html -->
<script type="module">
  export class View {
    constructor(el) {
      el.querySelector('p').innerText = 'The view has initialised!';
    }
  }
</script>

Browser showing "The view has initialised!" text appended to our component

All done!
dusts off hands

🎉 🎉 🎉

Notes

This example was not intended for good (or even average!) performance or browser compatibility. I’m the only person using code like this, on two very specific devices. Other users of my site don’t even receive this code: it’s an Admin interface that is only loaded if I’m logged-in (see my other post: “I can write this from my phone”).

The Web Component in this example can be totally generic by taking an import url as an input, so you don’t have to create a new Web Component – which, to be honest, is a bit of a pain – for every HTML component you have. In fact, that’s exactly what I’ve done with my Admin interface: I can put <html-import data-href="./amazing.component.html"></html-import> anywhere and it will load that component 🎉

You can read more about it here: my blog admin interface HTML component on GitHub – it’s a lot more complicated than this example at the time of writing, and probably doesn’t need to be (I don’t know if it will ever be finished 😅)


Updates

2020-04-17

Imports weren’t working from inside a component script: they would raise a TypeError. For example, when creating an object url for the following and importing it:

<script type="module">
  import { message } from './test.module.js';
</script>

we would get the following error:

TypeError: Failed to resolve module specifier "./test.module.js".
Invalid relative url or base scheme isn't hierarchical.

I thought that was because of the blob: object url, shown by logging import.meta.url. So I tried to rewrite the imports to be relative to that url – e.g. resolve ./test.module.js from blob:http://localhost:4000/886be17a-b416-4699-8aed-e23162932feb – but I got the same error.

But now I have figured it out! Turns out I had the right idea, but I was resolving the relative paths against the wrong url: it should’ve been against the import.meta.url of the code that’s doing the importing!

This article’s example has been updated. You can see the changes, included in the commit for this update, by viewing the history in the share section below. And here’s the same import fix applied to my current blog admin interface.


* Thankfully Jekyll doesn’t transform it with a layout since it doesn’t have any front matter (yaml at the top of the file), so the response will only be the contents of the file.

StackOverflow is not the kindest of places, brought to my attention by April Wensel (here is an example). It’s also where I’ve found answers for the majority of my problems. So, for me at least, reading it is usually fine; but interacting with it – posting, commenting, answering – can be a dire experience. I suggest reading April Wensel to find out more.


Share and respond