How I Created My Own Frontend Framework

How I Created My Own Frontend Framework

And Whether or Not You Should Do it Too

ยท

13 min read

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 and document.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 as taskname) is empty.
  • Then, we try to render 3 elements to represent this component: an h1 element representing the task label, an input element representing the task itself, and a Button component that can display the task's name to the user.

If we try to run this, however, we get the following error:

only_1_elem_error.JPG

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:

  1. 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>
  1. 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>
  1. 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>
  1. 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:

working_task_component.gif

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":

  1. The developer defines the function:
const updateTaskname = this.setState("taskname", (oldTaskname, evt) => {
    return evt.target.value;
});
  1. 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
}
  1. 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);
}
  1. 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.

elem2_elem1_monitorContext.gif

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>
        `
    }
}

Todo_with_params.gif

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:

Happy learning!

ย