How to Build Our Own Framework

Alejandro Londoño - Jul 25 - - Dev Community

Hello everyone!

In this article I want to cover a bit of what it means to create a framework or library to develop the frontend, based on object-oriented programming and atomic design.

Topics

  • Concept
  • Architecture and technologies
  • Rendering
  • State
  • Life cycle
  • Global state
  • npm publication
  • Conclusions

Youtube Video

Concept

A framework is a set of tools, libraries and practices designed to help developers build applications more efficiently and consistently.

The idea is to create a small package that can provide functionalities such as rendering elements, state control and life cycle. Also, obviously, to package all the code and publish it on npm.

Architecture and technologies

Software architecture is the fundamental structure of a software system, which includes its components, the relationships between them and the environment in which they operate.

Before starting any project, it is important to begin by defining the technologies and what architecture will be followed.

For this occasion we will simply use JavaScript as the language to develop all the functionalities and Vitejs as the packaging tool.

As an architecture I decided to base myself a bit on atomic design and use the object-oriented programming paradigm. The idea is to create html elements as if they were classes.

Rendering

To start, I think that the most important thing a frontend framework can do is render html elements in a simpler way from JavaScript code.

We will use the DOM API to achieve this task, the framework will be called Classy JS and it will have vitejs as a development dependency.

The way to create html elements within JavaScript is done in the following way:

// create a HTML Element like p, h1, button, or input
let element = document.createElement("elementType");

//add to DOM
let parent = document.getElementById("parentId")
parent.appendChild(element)
Enter fullscreen mode Exit fullscreen mode

We're starting a new vite project and I'll be using only Pure JavaScript for this occasion:

npm create vite@latest
Enter fullscreen mode Exit fullscreen mode

We configure vite using the vite.config.js file and add the entry point and the name of our library. All the logic will be inside the src folder.

import { defineConfig } from "vite";

export default defineConfig({
  build: {
    lib: {
      entry: "src/index.js",
      name: "classyjs",
      fileName: (format) => `classyjs.${format}.js`,
    },
    rollupOptions: {
      external: [],
      output: {
        globals: {},
      },
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Following atomic design, each HTML element would be an atom by definition. Each atom will have the ability to render itself and its children, and will have its own state.

The createAtom function will be responsible for creating the HTML element we want, assigning it a unique key and starting with its own state, which we will pass as a parameter, as well as the type of element we want to create.

export default function createAtom(atomType, initialState) {
  let atom = document.createElement(atomType);
  let key = parseInt(Math.random() * 10000000000);
  let state = initialState;

  atom.setAttribute("data-key", key);

  const setAtomValues = (newState) => {
    for (let [stateName, stateValue] of Object.entries(newState)) {
      for (let [name, value] of Object.entries(stateValue)) {
        if (stateName === "children") {
          if (typeof value === "object") {
            value.render(atom);
          } else if (typeof value === "number" || typeof value === "string") {
            atom.innerText = value;
          } else {
            atom.appendChild(value);
          }
        } else if (stateName === "props") {
          atom[name] = value;
        } else if (stateName === "attrs") {
          atom.setAttribute(name, value);
        } else if (stateName === "events") {
          atom.addEventListener(name, value);
        }
      }
    }
  };

  setAtomValues(state);

  const updateState = (newState) => {
    setAtomValues(newState);
    return atom;
  };

  return {
    render: atom,
    key,
    updateState,
  };
}
Enter fullscreen mode Exit fullscreen mode

This function will also return the element, its unique key, and a method which is responsible for assigning the values of properties, children, attributes and events and we will usually use it to update the state of the atom.

State and Lifecycle

As a next step, we will abstract all the functionality of the createAtom function into an Atom class which will also help us provide the functionality to be able to listen to the lifecycle of the atom, such as when it is mounted, unmounted or updated.

import createAtom from "./createAtom";

export default class Atom {
  constructor(atomType, initialState, lifeCycle = {}) {
    this.state = initialState;
    this.lifeCycle = lifeCycle;
    this.atom = createAtom(atomType, this.state);
    this.parent;
  }
  render(parent) {
    this.parent = parent;
    this.parent.appendChild(this.atom.render);
    if (this.lifeCycle?.mount) {
      return this.lifeCycle.mount();
    }
    return;
  }
  update(newState, callback) {
    let state = { ...this.state, ...newState };
    if (typeof newState.children !== "object") {
      state = {
        ...this.state,
        ...newState,
        children: {
          child: newState.children,
        },
      };
    }
    this.state = state;
    const newAtom = this.atom.updateState(this.state);
    const oldAtom = this.parent.querySelector(`[data-key="${this.atom.key}"]`);
    if (oldAtom && oldAtom.parentNode) {
      oldAtom.parentNode.replaceChild(newAtom, oldAtom);
      if (typeof callback === "function") {
        callback(this.state);
      }
      if (this.lifeCycle?.mount) {
        return this.lifeCycle.mount();
      }
      return;
    }
    this.parent.appendChild(newAtom);
    if (typeof callback === "function") {
      callback(this.state);
    }
    if (this.lifeCycle?.mount) {
      return this.lifeCycle.mount;
    }
  }
  remove() {
    const atom = this.parent.querySelector(`[data-key="${this.atom.key}"]`);
    atom.remove();
    if (this.lifeCycle?.unmount) {
      return this.lifeCycle.unmount();
    }
    return;
  }
}
Enter fullscreen mode Exit fullscreen mode

This class accepts three parameters: the atom type, the initial state, and the lifecycle.

The atom type is the type of HTML element we want to create, such as an Input, a Div, or a Button.

The initial state is an object with 4 properties: children, attributes, properties, and events. Each of these properties belongs to the atom's state and can be updated at any time.

const initialState = {
    children: {
        text: "Hello"
    },
    props: {
        style: "color:red;"
    },
    events: {
        click: () => alert("Message")
    },
    attrs: {
        class: "my-class"
    }
}
Enter fullscreen mode Exit fullscreen mode

The lifecycle is also an object with 2 properties: mount and unmount. And they are a pair of callbacks that are executed when the element is mounted or unmounted.

const lifeCycle = {
    mount: () => console.log("atom did mount"),
    unmount: () => console.log("atom did unmount")
}
Enter fullscreen mode Exit fullscreen mode

The Atom class also has 3 methods: render, update and remove. As their names indicate, they are responsible for rendering, updating and removing the element or atom from the DOM.

The update method receives two parameters, the new state and a callback which will simply be executed every time the element is updated.

let count = 0

const atom = new Atom({
    children: {count}
})

count++

atom.update({
    children: {count}
}, () => console.log("atom updated"))
Enter fullscreen mode Exit fullscreen mode

With all of the above we can now create atoms for each type of HTML element we need. As an example, the ul element, which is a list, would represent a molecule in our framework, and the li element would be an atom.

import Atom from "../atoms/Atom";

// molecule
class List extends Atom {
  constructor(ListType, initialState, lifeCycle) {
    super(
      { ul: "ul", ol: "ol" }[ListType],
      {
        ...initialState,
        children: Object.assign({}, initialState.children),
      },
      lifeCycle,
    );
  }
}

// atom
class ListItem extends Atom {
  constructor(initialState, lifeCycle) {
    super("li", initialState, lifeCycle);
  }
}
Enter fullscreen mode Exit fullscreen mode

To use our atoms we would do the following.

const listItem = new ListItem({
    children: { text: "I am a li" }
})

const list = new List({
    children: [ listItem ]
}, {
    mount: () => console.log("List did mount")
})

list.render(document.getElementById("parentId"))
Enter fullscreen mode Exit fullscreen mode

Global State

To manage a global state, we will create a function called createContext, which will store the state that we pass to it in the browser's sessionStorage, so we can access it from any page in the application. This function accepts two parameters: The name of the context and the state. And it returns the state and a method to update it.

export default function createContext(name, initialState) {
  const key = `context-${name}`;
  let state;

  if (sessionStorage.getItem(key)) {
    state = JSON.parse(sessionStorage.getItem(key));
    console.log("state", state);
  } else {
    state = initialState;
    sessionStorage.setItem(key, JSON.stringify(state));
  }

  const setState = (callback) => {
    const newState = callback(state);
    state = newState;
    sessionStorage.setItem(key, JSON.stringify(newState));
    return newState;
  };

  return {
    initialState: state,
    setState,
  };
}
Enter fullscreen mode Exit fullscreen mode

Publish on npm

First we must run the npm run build command, so that the library is packaged, but first we must configure the package.json file:

{
  "name": "@slydragonn/classyjs",
  "version": "1.1.3",
  "publishConfig": {
    "access": "public"
  },
  "description": "Framework Frontend based on OOP and Atomic design",
  "main": "dist/classyjs.umd.js",
  "module": "dist/classyjs.es.js",
  "files": [
    "dist"
  ],
  "scripts": {
    "build": "vite build"
  },
  "keywords": [],
  "author": "slydragonn",
  "license": "MIT",
  "devDependencies": {
    "vite": "^5.3.3"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/slydragonn/classyjs.git"
  }
}
Enter fullscreen mode Exit fullscreen mode

npm will take the dist folder to share it as a package like react. To publish it we must create an npm account if we don't have one, log in with the npm login command and to publish our project with the npm publish command and now we would see it on the npm page.

Image description

If you want to take a look at this project, just head over to the repository where all the details on how to use it are.

Repo: https://github.com/slydragonn/classyjs

Conclusions

In conclusion, a framework is an excellent tool that not only helps us save development time but also helps us program with good practice and create complex things with much less code.

Creating a framework is not an easy task, in this article alone we have covered ten percent of what a framework really is, but I think it is an interesting project to do and if you like dealing with many concepts it will certainly be a fun task to do.

I hope you liked it, see you later!

. . . . . . .
Terabox Video Player