Published – last updated – skip to updates
lol I made my own component framework
why did I do this (oh yeah because of the pandemic)
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.
- Global object pollution
- Inline scripts block the document parsing
- 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>
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);
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);
š š š
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>
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.