Before I start—to set some context—by frontend framework what I mean is, a framework that allows us to avoid having to write regular old HTML and JavaScript such as this:
<p id="cool-para"></p>
<script>
const coolPara = 'Lorem ipsum.';
const el = document.getElementById('cool-para');
.innerText = coolPara;
el</script>
and instead allows us to write magical HTML and JavaScript code such as this (Vue):
<script setup>
const coolPara = 'Lorem ipsum.';
</script>
<template>
<p>{{ coolPara }}</p>
</template>
or this (React):
export default function Para() {
const coolPara = 'Lorem ipsum';
return <p>{ coolPara }</p>;
}
and the benefit of such a framework is understandable. Remembering words
or phrases such as document
, innerText
, and
getElementById
are difficult—so many syllables!
Okay, syllable count isn’t the main reason.
Reactivity ✨
The first main reason is that, in the second and third examples,
we can just set or update the value of the variable
coolPara
and the markup—i.e. the <p>
element—is updated without without explicitly having to set its
innerText
.
This is called reactivity, the UI is tied to the data in such a way that just changing the data updates the UI.
Composability ✨
The second main reason is the ability to define a component and reuse it without having to redefine it every time we need to use it. This is called composability.
Regular HTML + JavaScript does not have this by default. And so the following code does not do what it feels like it should:
<!-- Defining the component -->
<component name="cool-para">
<p>
<content />
</p>
</component>
<!-- Using the component -->
<cool-para>Lorem ipsum.</cool-para>
Reactivity and composability are the two main things the usual frontend frameworks such as Vue, React, etc give us.
These abstractions aren’t granted for free, one has to front-load a bunch of framework specific concepts, deal with their leakiness when things work in inexplicably magical ways, and not to mention, a whole load of failure-prone dependencies.
But, it turns out that using modern Web APIs these two things aren’t very hard to achieve. And most use cases we might not actually need the usual frameworks and their cacophony of complexities…
A simple statement that explains reactivity is when the data updates, update the UI automatically.
The first part is to know when the data updates. This
unfortunately is not something a regular object can do. We can't
just attach a listener called ondataupdate
to listen to data
update events.
Fortunately JavaScript has just the thing that would allow us to do this,
it’s called
Proxy
.
Proxy
Objects
Proxy
allows us to create a proxy object from a
regular object:
const user = { name: 'Lin' };
const proxy = new Proxy(user, {});
and this proxy object can then listen to changes to the data.
In the example above we have a proxy object, but it is not really
doing anything when it comes to know that name
has changed.
For that we need a handler, which is an object that tells the proxy object what to do when the data is updated.
// Handler that listens to data assignment operations
const handler = {
set(user, value, property) {
console.log(`${property} is being updated`);
return Reflect.set(user, value, property);
,
};
}
// Creating a proxy with the handler
const user = { name: 'Lin' };
const proxy = new Proxy(user, handler);
Now whenever we update name
using the proxy
object, we’ll get a message saying
"name is being updated"
.
If you’re wondering, What’s the big deal, I could’ve done this using a regular old setter, I’ll tell you the deal:
Other than this you can handle several other access events such as when a property is read, updated, deleted, etc.
Now that we have the ability to listen to listen to operations, we need to react to them in a meaningful way.
If you recall, The second part of reactivity was update the UI automatically. For this we need to fetch the appropriate UI element to be updated. But before that that we need to first mark a UI element as appropriate.
To do this we’ll use data-attributes, a feature that allows us to set arbitrary values on an element:
<div>
<!-- Mark the h1 as appropriate for when "name" changes -->
<h1 data-mark="name"></h1>
</div>
The nicety of data-attributes are that we can now find all the appropriate elements using:
document.querySelectorAll('[data-mark="name"]');
Now we just set the innerText
of all the
appropriate elements:
const handler = {
set(user, value, property) {
const query = `[data-mark="${property}"]`;
const elements = document.querySelectorAll(query);
for (const el of elements) {
.innerText = value;
el
}
return Reflect.set(user, value, property);
,
};
}
// Regular object is omitted cause it's not needed.
const user = new Proxy({ name: 'Lin' }, handler);
That’s it, that’s the crux of reactivity!
Because of the general nature of our handler
, for
any property of user
that is set, all the
appropriate UI elements will be updated.
That’s how powerful the JavaScript Proxy
features are, with
zero dependencies and some cleverness it can give us these magical
reactive objects.
Now onto the second main thing…
Turns out, browsers already have an entire feature dedicated to this called Web Components, who knew!
Few use it cause it’s a bit of a pain in the ass to use (and also because most reach out for the usual frameworks as a default when starting a project, irrespective of the scope).
For composability we first need to define the components.
template
and slot
The <template>
tags are used to contain markup which is
not rendered by the browser. For instance, you can add the following
markup in your HTML:
<template>
<h1>Will not render!</h1>
</template>
and it won’t be rendered. You can think of them as invisible containers for your components.
The next building block is the <slot>
element which
defines where the content of a component will be placed in it. This
enables a component to be reused with different content, i.e it becomes
composable.
For example, here’s an h1 element that colors its text red.
<template>
<h1 style="color: red">
<slot />
</h1>
</template>
Before we get to using our components—like the red h1 above, we need to register them.
Before we can register our red h1 component, we need a name to register it
by. We can just use the name
attribute for that:
<template name="red-h1">
<h1 style="color: red">
<slot />
</h1>
</template>
And now, using some JavaScript we can get the component and its name:
const template = document.getElementsByTagName('template')[0];
const componentName = template.getAttribute('name');
and then finally register it using
customElements.define
:
.define(
customElements,
componentNameclass extends HTMLElement {
constructor() {
super();
const component = template.content.children[0].cloneNode(true);
this.attachShadow({ mode: 'open' }).appendChild(component);
}
}; )
There is a lot going on in the block above:
customElements.define
with two arguments.
"red-h1"
).
HTMLElement
.
What we are doing in the class constructor is using a copy of the template
red-h1
to set the shadow DOM tree.
The shadow DOM is what sets the styling of a several default elements such as a range input, or a video element.
The shadow DOM of an element is hidden by default which is why we can’t
see it in the dev console, but here’re we’re setting the
mode
to 'open'
.
This allows us to inspect element and see that the red colored h1 is
attached to the
#shadow-root
.
Calling customElements.define
will allow us to use the
defined component like a regular HTML element.
<red-h1>This will render in red!</red-h1>
Onto putting these two concepts together!
A quick recap, we did two things:
red-h1
which will render it’s
content as a red h1.
We can now put them both together:
<div>
<red-h1 data-mark="name"></red-h1>
</div>
<script>
const user = new Proxy({}, handler);
.name = 'Lin';
user</script>
and have a custom component render our data and update the UI when we change the data.
Of course the usual frontend frameworks don’t just do this, they have specialized syntax such the template syntax in Vue, and JSX in React that makes writing complex frontends relatively more concise that it otherwise would be.
Since this specialized syntax is not regular JavaScript or HTML, it is not parsable by a browser and so they all need specialized tools to compile them down to regular JavaScript, HTML, and CSS before the browser can understand them. And so, no body writes JavaScript any more.
Even without specialized syntax, you can do a lot of what the usual
frontend framework does—with similar conciseness—just by using
Proxy
and WebComponents
.
The code here is an over simplification and to convert it into a framework you’d have to flesh it out. Here’s my attempt at doing just that: a framework called Strawberry.
As I develop this, I plan on maintaining two hard constraints:
And a soft constraint of keeping the code base tiny. At the time of writing it’s just a single file with fewer than 400 CLOC, let’s see where it goes. ✌️
Also, here's the HN discussion for this post.