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?

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>

šŸ‘‰Vue JS šŸ‘ˆšŸ‘€

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 related 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.


Footnotes

  1. 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. ā†µ
  2. StackOverflow is not the kindest of places, brought to my attention in Suffering on StackOverflow, by April Wensel. Itā€™s also where Iā€™ve found answers for a lot of 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. ā†µ