How I Created My Own Frontend Framework
And Whether or Not You Should Do it Too
Introduction
In this final week's submission for Hashnode's #4articles4weeks Writeathon, I'm circling back to DCL, a custom javascript frontend develop-as-needed framework that I created while working on Projectree.
What Is DCL
DCL is short for Danidre's Client Library. I needed to spend July 2022 working on an open source project that uses PlanetScale, however, half of that time was spent working on the framework to use in the project.
DCL Features:
Since DCL was developed alongside Projectree, it is not a fully featured developer rich library. I started with rendering components and handling state with JavaScript, and expanded as I needed more features.
At the July 2022's release of Projectree, DCL supports:
- Nested components
- Routing
- Component props
- Component state
- Global context
- Component rerendering
- Input management*
Why I Created DCL
The last time I created a project with a frontend library (React; February 2020), I spent 2 months learning and applying what I learnt. Also, many build steps were involved, and tedious setup was required. For the hackathon, I only had 1 month. I wanted to work on an application with only HTML, CSS, and JavaScript. I also wanted full control of the application, instead of spending days trying to solve an error if I got stuck.
At the same time, I wanted to be able to reuse elements on a page instead of repeating code on multiple pages, and I wanted to be able to have values update without having to read and write from elements in JavaScript.
Instead of
let age = document.getElementById("age").value
anddocument.getElementById("age").value = newAge
for getting and setting values, I just wanted to change a variable once, and its represented element should change automatically.
Benefits of DCL
As a result, using DCL has many perks:
- Easy setup
- Minimal JavaScript file
- No build time required
- Develop, drag and drop to deploy
- No complex language necessary
- No relearning architecture
How I Created DCL
Components
The main class in DCL is the DCL
class. All other elements can extend this component class. This class handles nested components, unique component state, and more. Below is a simple overview of its functions:
class DCL {
constructor(props = {}) {
this.props = props;
this.state = {};
this._dclId = _generateUID();
this._refs = {
children: [],
stateFuncIDs: [],
createFuncIDs: [],
contextFuncIDs: []
};
}
setState(key, value) {}
static stateStore = {}
createFunc(func, ...args) {}
static funcStore = {}
onMount() {}
onUnmount() {}
_mount(parent) {}
_unmount() {}
render() {}
_getHTML() {}
_rerender() {}
static _validateDCLX(dclx = "") {}
monitorContext(key, callback, ...args) {}
static getContext(key) {}
static setContext(key, value) {}
static clearContext(key)
}
window.DCL = DCL;
Creating a Basic Component
A simple component can be created using the following:
class Button extends DCL {
constructor(props) {
super(props);
}
render() {
const onClick = this.props.onClick || "null";
return `<button onclick="${onClick}">${this.props.name}</button>`;
}
}
As possibly assumed, the
render
function describes how the component should look.
This Button
component renders a button element, with a name passed as a property. When the component is first created, the onMount
function is called. This is where developers can make requests to retrieve information from APIs or servers. However, since this Button
component does not have a need for it, nothing will happen here.
Creating an Interactive Component
The following component will give a better example of the methods available for each component:
class Todo extends DCL {
constructor(props) {
super(props);
this.state = {
taskname: "",
}
}
render() {
const updateTaskname = this.setState("taskname", (oldTaskname, evt) => {
return evt.target.value;
});
const displayTask = this.createFunc((evt) => {
alert(this.state.taskname);
});
return `
<h1>Task</h1>
<input onblur="${updateTaskname}" value="${this.state.taskname}"/>
${new Button({name:"Alert Task", onClick: displayTask})._mount(this)}
`
}
}
Many things are going on here:
- The
Todo
component represents a task that the user can edit. When created, the task (stored astaskname
) is empty. - Then, we try to render 3 elements to represent this component: an
h1
element representing the task label, aninput
element representing the task itself, and aButton
component that can display the task's name to the user.
If we try to run this, however, we get the following error:
Validating Components
Following React's convention, each DCL component should only return one top level element. Instead, the elements should be wrapped into a div
or other enclosing element:
return `
<div>
<h1>Task</h1>
<input onblur="${updateTaskname}" value="${this.state.taskname}"/>
${new Button({name:"Alert Task", onClick: displayTask})._mount(this)}
</div>
`
That is because DCL does not have a Virtual DOM that tracks changes in element states. Instead, the _getHTML
method creates a temporary element that:
- Wraps around each component's returned render html:
<dcl>
<div>
<h1>Task</h1>
<input onblur="updateTaskName()" value=""/>
<button onclick="displayTask()">Alert Task</button>
</div>
</dcl>
- Assigns a unique identifier to itself:
<dcl data-dcl-id="todo_l7xr2462wjgidez7kf">
<div>
<h1>Task</h1>
<input onblur="updateTaskName()" value=""/>
<button onclick="displayTask()">Alert Task</button>
</div>
</dcl>
- Passes it to the first child element:
<dcl data-dcl-id="todo_l7xr2462wjgidez7kf">
<div data-dcl-id="todo_l7xr2462wjgidez7kf">
<h1>Task</h1>
<input onblur="updateTaskName()" value=""/>
<button onclick="displayTask()">Alert Task</button>
</div>
</dcl>
- And removes itself:
<div data-dcl-id="todo_l7xr2462wjgidez7kf">
<h1>Task</h1>
<input onblur="updateTaskName()" value=""/>
<button onclick="displayTask()">Alert Task</button>
</div>
That way, the intended html structure of the page is maintained, and DCL is able to recognize which elements need to change upon rerender.
DCL
's _validateDCLX
method is called internally to ensure that only one top level element is returned for each component.
In this case, DCLX stands for DCL XML, DCL's markup syntax.
Now, the task component renders correctly, and is functional:
As possible imagined, this.setState
and this.createFunc
methods allow functions to be called on each element. Similar to React, setState
changes the component's state and rerenders it to reflect the change.
The Good
When setState
is triggered, the component searches the DOM for the element with that unique id, removes it, and rerenders the component and its children.
The Bad
Since DCL uses no Virtual DOM, each component must keep track of their nested components (children). That is why the Button
has the _mount(this)
function. _mount
accepts a parent component, and adds that child to the parent's list of components. That way, when a parent component has to be removed or rerendered, it can loop through its children and unmount them appropriately.
Although it works, the DCLX syntax for nested components is a bit...unappealing.
The Ugly
React and other frameworks have build steps that generate the appropriate JavaScript that allows functions and methods in each component to retain their proper scope. However, DCL does not have this, as each component returns the html as a string, which is then appended to the DOM using innerHTML
. As a result, functions defined this way cannot be returned as a string and executed correctly.
To address that, the setState
and createFunc
methods actually store the passed functions in a global scope controlled by DCL (stateStore
and funcStore
), and returns the string name of the method, so it is included in the innerHTML
and "works":
- The developer defines the function:
const updateTaskname = this.setState("taskname", (oldTaskname, evt) => {
return evt.target.value;
});
- It is internall added to DCL's
stateStore
:
setState(key, value) {
const funcID = `stateID_${this._dclId}_${_generateUID()}`;
DCL.stateStore[funcID] = (evt) => {
if(typeof value === "function") {
const prevState = _deepClone(this.state);
this.state[key] = value(prevState, evt);
} else {
this.state[key] = value;
}
this._rerender();
}
// ...other code
}
- It is also added to the component's references list (so they can delete it when unmounting):
setState(key, value) {
// ...other code
this._refs.stateFuncIDs.push(funcID);
}
- The string representation is finally returned:
setState(key, value) {
// ...other code
return `DCL.stateStore.${funcID}(event)`;
}
Thus, the innerHTML becomes the following:
<input onblur="DCL.stateStore.stateID_todo_l7xr2462wjgidez7kf_l7xr5olmp8tsb1cguf(event)" value=""/>
Whenever the input element is blurred, it will call the function since it can be found on the webpage.
The same process occurs with the createFunc
method: it is added to DCL's funcStore
, referenced by the component so it can be removed later, and returned as a string for the innerHTML.
This method is ugly because it pollutes the webpage's global scope. I do prevent memory leaks by deleting each method when its referenced component is unmounted, however.
Monitoring Global Changes
Internally, DCL uses a pub/sub method to broadcast changes. Each component can 'hook' onto changes using monitorContext
. Then, whenever that property is changed, the 'hooked' component rerenders:
class Elem1 extends DCL {
constructor(props) {
super(props);
this.num = DCL.getContext("number");
}
onMount() {
this.monitorContext("number", (value) => {
this.num = value;
});
}
render() {
return `
<div>
<h1>Elem 1</h1>
<p>Num: ${this.num}</p>
</div>
`
}
}
class Elem2 extends DCL {
render() {
const incrementNum = this.createFunc((evt) => {
const prevNumber = DCL.getContext("number") || 0;
DCL.setContext("number", prevNumber + 1);
});
return `
<div>
<h1>Elem 2</h1>
${new Button({name:"Increase Number", onClick: incrementNum})._mount(this)}
</div>
`
}
}
class Main extends DCL {
constructor(props) {
super(props);
DCL.setContext("number", 0);
}
render() {
return `
<div>
${new Elem1()._mount(this)}
${new Elem2()._mount(this)}
</div>
`
}
}
Whenever the button from Elem2
is clicked, DCL's number
property increases. Since Elem1
is 'hooked' to the number
property (from monitorContext
), a rerender is triggered each time the number property changes, so Elem1
successfully reflects the change each time.
Handling Page Routes
One of the intentions of my library was to treat pages as components and render a different component based on the url navigated to. For that, I needed DCL to handle routes as well. Thus, I created a Router
component that solves this:
class Router extends DCL {
constructor(props) {
super(props);
this.routes = props.routes || [];
this.defaultRoute = props.defaultRoute || routes[0];
this.view = null;
}
onMount() {
// redetermine route when the back button is triggered
window.addEventListener("popstate", this.determineRoute);
// navigate to new link without refreshing page if specific element is clicked
document.body.addEventListener("click", (evt) => {
if(evt.target.matches("[data-dcl-link]")) {
evt.preventDefault();
history.pushState(null, null, evt.target.href);
this.determineRoute();
}
});
this.determineRoute();
}
determineRoute() {
const routes = this.routes;
// test each route for potential match
const potentialMatches = routes.map(route => ({
route: route,
result: location.pathname.match(this._pathToRegex(route.path))
}));
let match = potentialMatches.find(potentialMatch => potentialMatch.result !== null);
if (!match) {
match = {
route: this.defaultRoute,
result: [location.pathname]
};
}
const props = match.route.props;
const params = this._getParams(match);
const query = this._getQuery(location.search);
DCL.params = params;
DCL.query = query;
// set view for determined route
this.view = new match.route.view({...props, params, query});
this._rerender();
}
render() {
if(!this.view) return "";
return `<div>${this.view.mount(this)}</div>`;
}
_pathToRegex = path => new RegExp("^" + path.replace(/\/?$/g, "").replace(/\//g, "\\/").replace(/:\w+/g, "([^/]+)") + "\/?$");
_getParams = match => {
const values = match.result.slice(1);
const keys = Array.from(match.route.path.matchAll(/:(\w+)/g)).map(result => result[1]);
return Object.fromEntries(keys.map((key, i) => {
return [key, decodeURI(values[i])];
}));
};
_getQuery = searchParams => {
const urlSearchParams = new URLSearchParams(searchParams);
const query = Object.fromEntries(urlSearchParams.entries());
return query;
};
}
The Router
is a custom standard component that hooks onto the forward and backward navigation of the page and swaps out the component based on the path. The router can be used with the following code:
class TodoPage extends DCL {
render() {
return `
<div>
<p>ID: ${this.props.params.id}</p>
</div>
`
}
}
class Main extends DCL {
render() {
const router = new Router({
routes: [
{ path: "/", view: HomePage, props: { num: 5 } },
{ path: "/todos", view: TodosPage },
{ path: "/todo/:id", view: TodoPage },
],
defaultRoute: { path: "/404", view: Page404 },
});
return `
<div>
<nav>
<a href="/" data-dcl-link>Home</a>
<a href="/todos" data-dcl-link>Todos</a>
<a href="/todo/4" data-dcl-link>4th Todo</a>
</nav>
${new Header().mount(this)}
<main>
${router.mount(this)}
</main>
${new Footer().mount(this)}
</div>
`
}
}
Disadvantages of DCL
Although DCL solved all of my needs, it was not without bugs that I was not able to solve:
Code Smell:
Different sections of the core class is polluted with spaghetti code, as I gradually built upon DCL alongside Projectree. Some code chunks may be completely useless, but I have not removed it due to continual development.
Breaking Input:
One major unsolvable issue is how inputs are handled. If an onchange
event was used with the <input>
element that causes it to rerender every time a value was entered, after each character was pressed, the component would rerender the input and focus would be lost.
Currently, DCL tries to solve this using a hack: It tries to keep track of each selected element per click, and holds an XPath reference to that element. If the element is a textarea
or input
element, it stores the selection range.
Additionally, an observeDOM
function is defined that waits for a change in the document using MutationObserver. When any component is rerendered, the DOM is considered changed, and DCL attempts to reselect the input element using the previously stored XPath
.
Sometimes this works successfully, but this assumes the input element remains in the same location in the webpage. Oftentimes, another element in the document is added or removed arbitrarily, and the XPath
causes some other element to be reselected (or an error occurs if no matching XPath
is found), which breaks the user experience, as they type into one field and suddenly are typing in another input field.
Other times, if there are too many input elements on the page, the observeDOM
tries to reselect an input field before the document rerenders every component, which also breaks user experience, since the input field suddenly loses focus.
I tried to create timeouts to wait a few milliseconds after rerendering before attempting to reselect elements, but this process also breaks for many elements. In the end, DCL currently has no way to determine when all elements have completely rerendered to attempt to reselect, which causes this issue.
DCL also accounts for asynchronous methods in its API, which makes detecting complete rerenders more difficult.
Wonky Paradigms:
DCL supports additional unmentioned options such as middleware
functions that are called before each reroute, as well as ignoreRoute
checks that can skip a route call if a condition fails (e.g. the user is not authorized). However, with the current flow of the application, sometimes the ignoreRoute
is called after the incomplete view
has rendered, causing a flicker on the end user's screen. I would have to rearrange some of DCL's routing logic to better control that flow, but it gets diffucult to control since each nested component is called using the _mount()
method.
SEO Limitations:
As DCL is a single page application (SPA) framework, it is client-side rendered (CSR). A user launches the page and are greeted with the standard HTML template that is then populated into an application when the JavaScript loads. For users, this is assumed as the loading process and is experienced for a few milliseconds to a few seconds, depending on connection speed or other factors. However, web crawlers that index pages usually do not wait around those extra seconds for the JavaScript to be loaded.
Currently, as DCL is a minimal develop-as-needed framework with no build steps, I had not considered supporting server-side rendering (SSR) which would be ideal for search engine optimizations.
Conclusion
DCL is a develop-as-needed library that I no longer have plans of working on, however, it was very helpful throughout the development of Projectree. From this experience, I have reasons both for and against creating your own frontend framework.
Why You Should Create Your Own Framework
From working on DCL, I learnt more about the internals of an SPA, and became aware of more browser APIs that I did not know before (such as history API), that can be used to make the developer experience easier. It is thanks to this project that I am able to better appreciate existing frameworks; although each seem complicated, they try in their own ways to make the developer's experience better, and provide as many tools as possible to create web apps.
I believe a developer should strive to make their own framework to understand what happens underneath, as it can also help them understand existing frameworks more.
Why You Should Not Create Your Own Framework
The code and examples in this article are a simplified view of what DCL is, however, it still cannot compare to solutions existing frameworks solve, and features they already provide. Additionally, many groups of senior developers and architects work on existing frameworks, and cater for many more edge cases than DCL does.
While developers should create their own frameworks for the learning experience, unless they are senior developers with industry level experience, it would be better to learn and use an existing framework.
Unless they are also like me and are too stubborn to learn a new framework; though eventually, I too will have to learn new frameworks.
Eventually, I'd like to recreate Projectree's frontend using React or SolidJS instead. Hopefully it is an easier process since I have first-hand experience. Hopefully, I've also convinced you to try creating your own frontend library too. You know...for the fun of it.
And also for the learning experience ๐
Thanks for staying tuned each week for articles this writeathon! You can click the links below to support me or check the unofficial source code yourself:
- DCL: Source code
- Projectree: projectree.net
- Twitter: @danidre
- Patreon: Danidre
Happy learning!