If you use Vue’s Single File Components and Web Components, then chances are you have wished Web Components could be packaged like those sweet sweet .vue
files:
<template></template>
<script></script>
<style scoped></style>
The web platform doesn't offer anything for doing that. It almost did, but didn't. All is not lost though. If you put each of the Web Component pieces together in a file - the HTML, CSS, and JavaScript - and get just a tiny bit creative, you can have a Web Component that is delightfully similar to Vue Single File Components.
This Single File Web Component or SFWC can be done as a two-part hack: the component file and the import.
The Component File
Let's go with a simple counter component to demonstrate.
The filename is arbitrary, but using the tag name makes the most sense, so x-counter.html
is the name. My SFWC structure (this is real working code btw):
<sfwc>
<template>
<style></style>
</template>
<script></script>
</sfwc>
Each section is as follows:
<sfwc>
is an "unknown" custom HTML tag that identifies this as a Single File Web Component and acts as a wrapper to contain the component's parts, which comes in handy later.<template>
is an HTML template element, which can be used independent of Web Components or as the template for a Custom Element’s shadow DOM. We are using it for the latter.<style>
defines scoped styles for the component. It must be nested in the template. This is a native feature of Web Components.<script>
contains the Custom Element definition.
That’s it and 100% vanilla.
Let’s add the basics, then we’ll make some interesting additions that take things up a notch.
<sfwc>
<template>
<div class="count"></div>
<button>Increment</button>
<style>
.count { color: red }
</style>
</template>
<script>
customElements.define('x-counter', class extends HTMLElement {
#template = document.currentScript.prevElementSibling;
constructor() {
super();
const content = this.#template.content.cloneNode(true);
this.attachShadow({ mode: 'open' }).appendChild(content);
}
connectedCallback() {
this.querySelector('button').addEventListener('click', e => this.increment(e));
}
observedAttributes() { return ['count'] }
attributeChangedCallback(name, prevVal, newVal) {
if (name === 'count') {
this.querySelector('.count').textContent = newVal;
}
}
get count() {
return Number(this.getAttribute('count'));
}
set count(val) {
val ? this.setAttribute('count', val) : this.removeAttribute('count');
}
increment(e) {
this.count = this.count + 1;
}
})
</script>
</sfwc>
Here's everything that got us:
A new Custom Element named
x-counter
witha
count
property and attribute whose value getsrendered in a
<div>
along witha button that, when clicked, will increase the count.
The component's styles are also scoped, which means the
.count
rule is confined tox-counter
.
And all of it is wrapped up in a nice old-school .html file.
Global styles in the shadow DOM...
One limitation, which is only sometimes desirable, is the component's shadow DOM is not fully stylable from the outside, i.e. from your website's stylesheet. That could be your own stylesheet or a 3rd-party's like Tailwind, either way, those classes and other style rules cannot be used by your component. Some basic styles like body color are inherited from light DOM to shadow DOM, and slotted content is fully stylable, but much of what you would want to be available is not.
The way to overcome this is by simply importing any stylesheet you want, like this:
<template>
<div class="count"></div>
<!-- Your global button styles now apply -->
<button>Increment</button>
<style>
@import "https://mywebsite.com/styles.css";
.count { color: red }
</style>
</template>
And yes as you might hope, if the stylesheet has already been downloaded by the browser, it will not be requested again. This is some legit vanilla CSS magic that Just Works. Use the platform🔥
Let's bind the template onevent
attributes
Okay, so far, so good, but I want more. I want to write HTML templates with onevent attributes bound to Custom Element methods like frameworks do. There is no built-in feature for doing this, but with a little JavaScript you can achieve a similar result:
<template>
<button onclick="increment">Increment</button>
</template>
<script>
customElements.define('x-counter', class extend HTMLElement {
constructor() {
...
}
connectedCallback() {
// Replaces onevent attributes with working event listeners
[...this.shadowRoot.children]
.flatMap(element => [...element.attributes])
.forEach(attr => {
// Only event attributes can start with "on"
if (attr.name.startsWith('on') && this[attr.value]) {
attr.ownerElement.addEventListener(attr.name.substring(2), e => this[attr.value](e));
attr.ownerElement[attr.name] = undefined;
}
});
}
increment(e) {
this.count = this.count + 1;
}
})
</script>
Those eight lines nullify each onevent attribute from the template and adds a listener for that event which is bound to the method named in the attribute.
In practice, this would be implemented more like:
<script>
// Another source file defines a faster and more robust
// global function for binding the onevent attributes.
function bindTemplateEvents(customElement) {
for (const element of customElement?.shadowRoot.children) {
for (const attr of element.attributes) {
if (attr.name.startsWith('on')) {
if (!customElement[attr.value] || typeof customElement[attr.value] !== 'function') {
console.error(`Failed to add event listener. Template set ${attr.name}="${attr.value}", which is not a method of this component.`);
return
}
attr.ownerElement.addEventListener(attr.name.substring(2), e => customElement[attr.value](e));
attr.ownerElement[attr.name] = undefined;
}
}
}
}
</script>
<script>
// Then back in our Custom Element it's just...
connectedCallback() {
window.bindTemplateEvents(this);
}
</script>
It's tempting to add that function to the customElements
registry, like customElements.bindTemplateEvents(this)
, but I wouldn't suggest doing that. Perhaps we'll get something similar in the future.
So that's my version of a Single File Web Component file, plus two extra goodies to make Web Components even more like the Vue SFC experience.
Let's now look at how to import them.
The Import
As I mentioned earlier, the web platform doesn't offer anything for importing Web Components. Apparently, the spec authors believed JavaScript modules would somehow be a better replacement, and those can work in a JavaScript-centric way, but the original vision for HTML imports was a really easy beginner-friendly method for packaging a complete Web Component in an HTML file, similar to what we just did above, and including it like <link rel="import" href="my-component.html">
. But it unfortunately never got fully implemented.
There are a few other hacks for importing HTML available, but I don't like them. Too much extra stuff and in some cases the imported component code is not accessible to the debugger. That's a deal-breaker. So, here's what I did:
<body>
<x-counter></x-counter>
<!-- Import a SFWC file with the object tag -->
<object data="x-counter.html" type="text/html"></object>
</body>
Yup. The old object element. I vaguely remember using it maybe once back in the Adobe Flash days, I think. Anyway, you can use it just like that and it works! It absolutely works and you could import multiple SFWCs or a single bundle of components.
This object import hack satisfies the three must-haves I was looking for:
Vanilla HTML
Works with the SFWC files
Debuggable code
I can't make any promises about this being a fully vetted solution, so if you know of any special considerations here, please shout it out in the comments. So far, I know of just two.
The first is simply hiding object elements. They take up visual space, so style them to be zero pixels big. Unfortunately display: none
and the hidden
attribute prevent it from loading its data, so you can't use them. Setting all object elements to zero width and height in a global stylesheet seems reasonable since these are so rare, but do what works best for your project.
The second has to do with scope. Registering Custom Elements imported this way scopes them to that object element's document, not the main document, so you have to refer to the top-level window's CustomElementsRegistry like this:
top.customElements.define() // Or window.top if you prefer
In fact, you will always need to refer to the top-level window when accessing any window property, like document
, from your SFWC if you include them this way (the one exception is getting a reference to the template element). Extremely easy to do, but requires a little mental muscle memory change.
What comes next?
It's fun to see what you can do with vanilla HTML if you put some leverage on it. I am a bit worried about the memory footprint of having a second document and I'd like to compare the performance of this to other options (I think this is the fastest).
If everything checks out, my next step is to look at testability. I can't imagine any blockers, after all it's 100% standards-based code. Existing test frameworks should work. Bundling and minification is another area to explore, but again existing tools should work. (UPDATE: all of this works as expected.)
See anything wrong here? Have a better or different approach? Let me know what you think in the comments!