This is the technical implementation of the idea and concept described in my article “Why don’t we use HTML to author web components?”
Instead of using template literals, constructors and other specifics to define CustomElements, this is a proposal to just use standard HTML to define CustomElements.
The goal is to import CustomElements like this:
<head>
<link rel="import" href="custom-search.html">
</head>
<body>
<custom-search></custom-search>
</body>
Or alternatively like this (similar to how native elements like <img>
, <iframe>
or <video>
load external resources):
<body>
<custom-element src="custom-search.html" name="custom-search"></custom-element>
</body>
Both techniques have been implemented and can be tested in these demos: #demos
To achieve the goal of importing CustomElements from HTML files, we’ll need a small runtime (see below), a CustomElement (HTML file) and an import syntax.
Here, we use the proposed, but not implemented, HTML Import syntax in the head
of the document and add the runtime at the end of the body
tag.
<html>
<head>
<link rel="import" href="custom-search.html">
</head>
<body>
<input type="search" placeholder="Search value">
<custom-search value="dog food"></custom-search>
<script src="runtime.js" type="text/javascript"></script>
</body>
</html>
HTML already offers syntax for everything that a CustomElement definition needs:
<head>
: Here we can import external assets including CSS, fonts, etc.<title>
: We’ll use the title as the default name for the CustomElement<body>
: This is our template for the CustomElement<style>
: CSS is defined in a style tag<script>
: Functionality for the CustomElement can be authored as a regular script tag.
What makes CustomElements special is that they are isolated and need to provide mechanisms to communicate with the outside world. So while we can author CustomElements as standalone HTML pages, in order to make them interoperable, they need to define their "API", which is done through attributes.
Usually, CustomElements require us to watch attributes an define getters/setters for each attribute.
static get observedAttributes() {
return ["value", "placeholder"];
}
Since our goal is to just use normal HTML and DOM JavaScript for our CustomElements, we won’t be able to define those through a JavaScript Class. Instead, the attributes are defined through standard HTML.
One idea is to use these attributes on the body
tag":
<body value="Hello" placeholder="Search"></body>
While these attributes are irrelevant when the HTML document is viewed in isolation, they could serve as the information we’re looking for. They also provide default values. Wether we should use standard attributes or custom ones is an open question.
Another idea would be to use a meta
tag:
<meta name="attributes" content="value, placeholder">
<html>
<head>
<title>custom-search</title>
</head>
<body value placeholder>
<form>
<label>
<span id="search-label">Custom Search</span>
<input type="search" />
</label>
</form>
<style>
figure {
max-width: 100%;
}
</style>
<script>
if (attributes) {
let input = document.querySelector("input");
/**
* We intend to use this document as a CustomElement.
* Since we’ll then be using ShadowDOM, we need to expose relevant
* data to the outside world (the implementing document).
* When the value of the <input type="search"> changes, we send
* a CustomEvent of type "attribute.changed". The implementing document
* can then listen for those changes and update the CustomElement’s
* attributes accordingly.
*
*/
if (attributes) {
if (attributes.value) {
input.value = attributes.value.value;
input.addEventListener("input", (event) => {
document.dispatchEvent(
new CustomEvent("attribute.changed", {
bubbles: true,
composed: true,
detail: {
value: event.target.value
}
})
);
});
}
}
/**
* For the case that this document is used as a CustomElement,
* we can react to attribute changes. Here, we can map the "value"
* of the CustomElement instance to the value of the <input type="search">
*/
document.addEventListener("attribute.changed", (event) => {
input.setAttribute(event.detail.name, event.detail.value);
if (event.detail.name == "value") {
input.value = event.detail.value;
}
});
}
</script>
</body>
</html>
The runtime takes care of converting a standard HTML document into a CustomElement. Our goal is to make this process fully automatic and rely on some conventions.
async function init() {
Array.from(document.querySelectorAll("link[rel='import']")).forEach((link) => {
fetch(link.getAttribute("href")).then((response) => {
response.text().then(async (html) => {
await mountComponentFromHTML(html);
postUpgrade();
});
});
});
}
init();
// We can take the HTML, parse it, extract parts and re-assemble it inside the CustomElement.
async function mountComponentFromHTML(html) {
let dom = new DOMParser().parseFromString(html, "text/html");
// We use the <title> of the HTML as the name for the component
let name = dom.head.querySelector("title").innerText;
// We get the attributes from the <body> tag
let namedAttributesMap = dom.body.attributes;
let attributes = [];
for (let attribute of namedAttributesMap) {
attributes.push(`"${attribute.name}"`);
}
attributes = `[${attributes}]`;
// We will inject the <head> into the Shadow DOM so that external resources like fonts are loaded
let headText = dom.head.innerHTML;
// We will later inject the script (this demo assumes only a one script tag per file)
let script = dom.body.querySelector("script");
let scriptText = script.innerText;
// We will later inject the style (this demo assumes only a one style tag per file)
let style = dom.body.querySelector("style");
let styleText = style.innerText;
// In order to get raw "template", we’ll remove the style and script tags.
// This is a limitation / convention of this demo.
script.remove();
style.remove();
// The <body> is our template
let template = dom.body.outerHTML;
let construct = `customElements.define(
'${name}',
class HTMLComponent extends HTMLElement {
constructor() {
super();
var shadow = this.attachShadow({ mode: "open" });
let head = document.createElement("head");
head.innerHTML = \`${headText}\`;
shadow.appendChild(head);
let body = document.createElement("body");
body.innerHTML = \`${template}\`;
shadow.appendChild(body);
let style = document.createElement("style");
style.innerText = \`${styleText}\`;
body.appendChild(style);
new Function("document", "attributes", \`${scriptText}\`)(
this.shadowRoot,
this.attributes
);
}
static get observedAttributes() {
return ${attributes};
}
attributeChangedCallback(name, oldValue, newValue) {
this.shadowRoot.dispatchEvent(
new CustomEvent("attribute.changed", {
composed: true,
detail: { name, oldValue, newValue, value: newValue }
})
);
}
}
);
`;
await import(`data:text/javascript;charset=utf-8,${encodeURIComponent(construct)}`);
}
We can use the CustomEvent attribute.changed
to listen for events from the CustomElement. When we make changes to the attributes of a CustomElement’s instance, we can also listen for those (see above).
<script>
function postUpgrade() {
let customSearch = document.querySelector("custom-search");
let nativeSearch = document.querySelector("input[type='search']");
// Forward inputs to the outer search field to the CustomElement
nativeSearch
.addEventListener("input", (event) => {
customSearch.setAttribute("value", event.target.value);
});
// Listen for changes from the CustomElement
customSearch.addEventListener("attribute.changed", (event) => {
nativeSearch.value = event.detail.value;
});
}
</script>
While we can load stylesheets within ShadowDOM using link tags, we can’t simply use script tags to load JavaScript and only execute it inside the ShadowDOM. That means that if we want to use external libraries (e.g. petite-vue) we will need to move scripts from our custom element to the main document.
Right now, the runtime extracts all script
tags from the CustomElement and move it to the main document (updated version in the Demos). This approach seems to work and the benefit is that duplicate dependencies will only ever load once (in contrast to 3rd party components that might bundle a framework).
There might be some incompatibilities: if a library expects a selector string
, e.g. #counter
to mount and use document.querySelector()
on the global scope, it won’t find the element within the ShadowDOM. Instead, selectors need to be provided as real DOM Elements
within the CustomElement’s script. Because there, document
actually refers to the ShadowRoot
. This is an ongoing implementation detail.
For now, a prototype implementation mounts the CustomElement after the first script
it depends on is loaded. In the future, a proper loading queue needs to be implemented that ensures that all required dependencies are loaded.
We could also think about a syntax that borrows from native elements such as image, video, iframe, … all of those elements use a src
attribute to refer to external resource.
<custom-element src="./custom-search.html"></custom-element>
Then there would be two possible implementations:
a) A general purpose "custom-element" CustomElement that handles the fetching of the external resource and mounts a CustomElement named "Component". The problem here would be that it might not be possible to dynamically register many different "Component" CustomElements with different observedAttributes
.
b) A placeholder "custom-element" CustomElement that will be replaced with dynamically defined CustomElements (name derived from the external resource as described above). This has the advantage that the actual CustomElements will show up in the DOM and attributes can be observed.
This seems to be the way to go.
https://codepen.io/getflourish/pen/fadd1a447759c193ce39b82baea986d6
https://codepen.io/getflourish/pen/7252f447941886de27674b5854d0662c
https://codepen.io/getflourish/pen/7252f447941886de27674b5854d0662c
https://codepen.io/getflourish/pen/fba6681e122e50e233a1061ece90c89c
https://codepen.io/getflourish/pen/a721fbd12caf190153451b3a7ff3acef
I LOVE 💕 how clean this is: everything in one file, hosted anywhere, imported using a single attribute. This is like the ideal of HTML transclusion that was never really realized, except here it's directed at components. I think this is perfect.
Related to how I think about building components as well keeping it simple, using just HTML, CSS and JavaScript as files. I implemented these ideas in my bang framework but also gets you a sort of syntax for custom elements without an end tag you might want to check it out here: https://github.com/i5ik/_____
But I love how your idea seems more universal more down to the metal of HTML CSS and JavaScript which is what I strive for too and doesn't require a framework. I love that! mine is like a thousand lines of JavaScript because it also includes templating and other complexities like a class for component functionality a syntax for adding event listeners.
@getflourish, what's your idea on how to do collection components and templating of values with this sort of approach?