https://18alan.space/posts/how-hard-is-it-to-build-a-frontend-framework.html
home about [logo]
Building a Frontend Framework; Reactivity and Composability With Zero
Dependencies
13th May, 2023
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:
and instead allows us to write magical HTML and JavaScript code such
as this (Vue):
{{ coolPara }}
or this (React):
export default function Para() {
const coolPara = 'Lorem ipsum';
return { coolPara }
;
}
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 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:
Lorem ipsum.
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...
Reactivity
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:
* The proxy method is generalized, and handlers can be reused,
which means that...
* Any value you set on a proxied object can be recursively
converted into a proxy, which means that...
* You now have this magical object with the ability to react to
data updates no matter how nested it is.
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.
Updating the UI
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:
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) {
el.innerText = value;
}
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...
Composibility
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.
Defining components using template and slot
The tags are used to contain markup which is not rendered
by the browser. For instance, you can add the following markup in
your HTML:
Will not render!
and it won't be rendered. You can think of them as invisible
containers for your components.
The next building block is the 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.
Before we get to using our components--like the red h1 above, we need
to register them.
Registering the Components
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:
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:
customElements.define(
componentName,
class 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:
* We are calling customElements.define with two arguments.
* First argument is the component name (i.e. "red-h1").
* Second argument is a class that defines our custom component as
an HTMLElement.
What we are doing in the class constructor is using a copy of the
template red-h1 to set the shadow DOM tree.
What's the Shadow DOM?
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.
This will render in red!
Onto putting these two concepts together!
Composability + Reactivity
A quick recap, we did two things:
1. We created a reactive data structure i.e. the proxy objects which
on setting a value can update any element we have marked as
appropriate.
2. We defined a custom component red-h1 which will render it's
content as a red h1.
We can now put them both together:
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:
1. No dependencies.
2. No build-step before it can be used.
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. [?]