What I Learned From Building a Framework

Watch the video on YouTube

There are 3 main reasons to write your own framework, even though, let’s face it, nobody will end up actually using it. First, Going through the process of building even a simple framework will consolidate your knowledge about basic concepts.

If you have a good grasp of the fundamentals, it’ll be fairly easy for you to jump from an Angular to a React project for instance. You’ll see throughout this article that while the developer experience might differ, all frameworks share a common set of core concepts and building blocks.

And, last but not least, the “magic” will go aw ay, and you’ll be more confident in your skills. In the past I often felt like the libraries I’m using are too complex, and that there is no way for me to understand the internals. You’ll be surprised that this is rarely the case.

So, let’s not waste any more time, and build a framework from scratch. There are a couple of essential features all modern frameworks are offering:

  1. First, and foremost, they are built on top of a powerful reactive system. In other words, the framework monitors the app state, and runs side effects(especially DOM updates) when the state is modified. Of course, in our case we’ll use Signals to achieve this, since Signals are the status quo in the industry at the moment.

  2. Second, frameworks have to be simple and lightweight. Despite user devices being more performant, and good internet access being a given these days, the trend in the industry is to simplify the dev process, remove unnecessary clutter and aim for the most efficient implementation possible.

  3. Finally, we need a clean, elegant way to handle rendering, since this is a hot topic in the space right now. The easiest way to do this is to go old school and defer all rendering to the server.

Let’s use a counter app as the starting point for our implementation. Imagine you are receiving this HTML from the server. The behavior should be pretty obvious - when the button is clicked, the state value will be increased, and, as a result, the DOM will be updated with the new value. Don’t worry, we’ll build on top of this example, and we’ll add various other features like conditional rendering, seamless server calls or more flexible ways to handle events, all in under 200 lines of code.

<main :state="{count: 0}">
	<h1 :html="count.val" @load="get:/count"></h1>

	<button
		@click="count.set(old => old + 1)"
		:show="count.val < 20">
		+1
	</button>

	<button
		name="count"
		:value="count.val"
		@click="post:/count">
		Save
	</button>
</main>

However, let’s not get ahead of ourselves. Back to our basic example, this counter showcases the two main features all frontend frameworks have to offer:

  1. A way to register event listeners and perform various logic;
@load="get:/count" - @click="post:/count"
  1. And a way to bind client state to DOM elements.
:state="{count: 0}" - :html="count.val" - :show="count.val < 20"

When you really think about it, this is all you need to build UIs. So our framework API will be just that - a collection of DOM attributes either starting with the @(at) sign to register event handlers or with the :(colon) sign to bind state to elements. Easy enough right?

In a new JS file, let’s define an immediately invoked function expression, to avoid polluting the global scope.

window.App = (() => {
  //TODO: Extract elements with special behavior
  //TODO: Map bindings and listeners to the elements
  //TODO: Implement signals & bindings
  //TODO: Implement listeners: JS code, async calls
  //TODO: Implement server calls and DOM update
})();

It’s always useful to break down complex work into smaller tasks. In our case: We first need to go through the DOM, and look for elements that have special behavior; Then, map bindings and event listeners to these special elements; Bindings will contain JS code that has access to Signals; Listeners can either run JS code to update client state, or HTTP calls to update server state. Finally, server calls should be smart enough to work with payloads and update the DOM based on the received responses.

Ok. In an init function we’ll define our special $state object. This is a proxy with a get trap we’ll use to silently register signal dependencies.

function init() {
  $state = new Proxy(
    {},
    {
      get(target, key) {
        if (typeof key === "symbol") return;
        const signal = target[key];
        if (silentRegisterCaller) {
          signal.deps.push(silentRegisterCaller);
        }
        return signal;
      },
    },
  );

  initElements(document);
}

Signals are powerful, yet extremely simple constructs. They keep track of an internal value and of a list of dependencies. Side effects (like DOM updates) are silently registered to the list of dependencies, and, when the value is updated, all dependencies are rerun. This is how we can easily achieve the reactivity I mentioned earlier.

class Signal {
  val;
  deps = [];

  constructor(val) {
    this.val = val;
    this.deps = [];
  }

  set(setter) {
    this.val = typeof setter === "function" ? setter(this.val) : setter;
    this.notify();
  }

  notify() {
    this.deps.forEach((it) => it());
  }
}

With the @state object initialized, it’s time now to traverse the DOM, and add special behavior to various elements. Remember, that interactivity is added to DOM elements having either colon or at based attributes.

function initElements(parent) {
  const $elements = [...parent.querySelectorAll("*")]
    .filter((el) => [...el.attributes])
    .find(({ name }) => name.startsWith(":") || name.startsWith("@"));

  $elements.filter((el) => el.hasAttribute(":state")).forEach(setupState);

  $elements.filter((el) => !el.hasAttribute(":state")).forEach(setup);
}

With the elements extracted, we’ll first go through the ones setting state, and then through the ones bound to signals or events.

The setupState function is pretty straight forward. Our users can define new signals using the “:state” binding, which accepts a JS object containing signal names and values. We’ll convert this string into an actual object, iterate over the keys, and then simply add the new signals in the $state object.

function setupState(el) {
  const state = JSON.parse(
    el.getAttribute(":state").replace(/(\w+):/g, '"$1":'),
  );
  Object.keys(state).forEach((key) => {
    if (!$state.hasOwnProperty(key)) {
      $state[key] = new Signal(state[key]);
    }
  });
}

The setup function might be a bit intimidating at first, but don’t worry - while this function is the core of our framework, it is simpler than you would expect.

function setup(el) {
	// Events
	[...el.attributes]
		.filter(({name}) => name.startsWith("@"))
		.map(({name}) => name.replace("@", ""))
		.forEach(ev => {...})

	// Bindings
	[...el.attributes]
		.filter(({name}) => name.startsWith(":"))
		.forEach(({name, value}) => {...})

	el.dataset.bound = "true"
}

First, we’ll iterate through the registered events. Remember that elements can have one or more event listeners attached to them, so we’ll go through all of them one by one. I’m also maintaining an event cache in order to avoid registering a listener multiple times. Our listeners will be registered only once on the document body, instead of multiple times directly on the DOM elements defining them. This small detail will also come in handy later, when we’ll swap parts of the HTML with server responses, and any newly added DOM element will benefit from the existing listeners. For convenience reasons I’m also maintaining a list of special events, like pressing enter or escape.

// Events
[...el.attributes]
  .filter(({ name }) => name.startsWith("@"))
  .map(({ name }) => name.replace("@", ""))
  .forEach((ev) => {
    if (ev === "load") {
      // TODO
    } else if (!events.has(ev)) {
      events.add(ev);
      if (KEYUP_EVENTS.indexOf(ev) === -1) {
        document.body.addEventListener(ev, listener);
      } else {
        document.body.addEventListener("keyup", (key) => {
          if (key.code.toLowerCase() === ev) listener(key);
        });
      }
    }
  });

I also want to define a special load event, which will be triggered when the element is first analyzed, and acts like an on init method. We’ll get back to this implementation in a second, once we sort out the other core component of our framework.

Next, when it comes to bindings, the attribute value is some JavaScript code. Let’s define an extractor function which uses the “with” keyword to correctly add the $state in the function scope, and then execute it. We need to run this code once, even though the app is not in use yet, so that our signal dependencies will silently register themselves. As a result, whenever the function accesses a state signal, the Proxy we looked at earlier will capture the get property call, and this is how the function reference will end up in the Signal’s dependency list.

// Bindings
[...el.attributes]
  .filter(({ name }) => name.startsWith(":"))
  .forEach(({ name, value }) => {
    const extractor = func(value);
    silentRegisterCaller = function () {
      const attr = name.replace(":", "");
      if (attr === "show") {
        el.style.display = extractor($state, {}) ? "block" : "none";
      } else if (attr === "html") {
        el.innerHTML = extractor($state, {});
      } else {
        el.setAttribute(attr, extractor($state, {}));
      }
    };
    silentRegisterCaller();
    silentRegisterCaller = null;
  });

We support two special bindings: The “show” binding which can trigger the visibility of an element, and the “html” binding which allows us to set up the element’s inner html. In any other case, we’ll simply update the actual element attribute.

Next, let’s take a look at the listener function, which is executed when one of the registered events is triggered. Since these listeners are registered on the document body they will be triggered quite a lot, but we only have to worry about our special elements. Then, we are converting the DOM special attributes into a config object for convenience reasons.

async function listener(ev) {
  if (!isElement(ev)) return;

  const cfg = extract(ev);
  cfg.forEach((it) => {
    let exec = true;
    if (it.before) {
      exec = func(it.before)($state, { $ev: ev });
    }
    if (!exec) return;

    if (it.code) {
      func(it.code)($state, { $ev: ev });
    } else {
      call(ev.target, it);
    }
  });
}

This is a good time to take a step back and discuss a useful HTMX inspired feature I really want in my framework. Until now, we only saw event listeners that contain JS code. These listeners will perform state updates ON the client, but I want to seamlessly perform updates on the server as well. On top of everything else, once the server call is finished, we should be able to easily update parts of our DOM with the HTML response received from the server.

So let’s make sure that our event handlers can accept both plain JavaScript code and server endpoints to which we can trigger fetch calls.

In this context, I am using the extract function to better understand what is the overall configuration of an event. Is it a server call, or simply JS code that needs to be executed? Does the user want to execute validations before or after the actual code execution? What should we do with the server response?

function extract({type, target, key}) {
	return [target.getAttribute(`@${type}`), target.getAttribute(`@${(key ?? "").toLowerCase()}`)]
		.filter(it => !!it).map(value => {
			if (value.indexOf(":/") > -1) {
				const [method, url] = value.split(":");
				const cfg = {
					method,
					url,
					before: target.getAttribute("before")
				}
				const to = [...target.attributes].find(it => it.name.startsWith("to"));
				if (to) cfg.to = {
					swap: to.name.indexOf(":") > -1 ? to.name.split(":")[1] : "replace",
					target: to.value
				}
				return cfg;
			} else {
				return {code: value...};
			}
		})
}

With all this information nicely wrapped up in an object, I am then executing validations if they are required, and then either run the JS code or perform the server call.

In the call function we’ll first check if there is any payload associated with the server call. Back to our example, when the “Save to server” button is clicked, we’ll add the name value combo as parameters in the request body.

With the headers and body in place, the fetch call is triggered, and, when the HTML response is received, we’ll update the DOM accordingly if needed.

Finally, note that once the DOM is updated, we’ll have to traverse the newly added elements and make sure that any new registered events or bindings are being correctly configured.

async function call(el, { url, method, to }) {
  const payload = getPayload(el);
  const init = { method };
  if (Object.keys(payload).length > 0) {
    init.headers = { "Content-Type": "application/json" };
    init.body = JSON.stringify(payload);
  }
  const resp = await fetch(url, init);
  if (to) {
    const html = await resp.text();
    if (to === "el") {
      el.innerHTML = html;
    } else {
      [...document.querySelectorAll(to.target)].forEach((parent) => {
        if (to.swap === "prepend" || to.swap === "append") {
          parent.insertAdjacentHTML(
            to.swap === "append" ? "beforeend" : "afterbegin",
            html,
          );
        } else {
          parent.innerHTML = html;
        }
        initElements(parent);
      });
    }
  }
}

Using this call method, we can now go back to our special load event, extract the request details and perform a server call with the response replacing the actual element body.

You’ll find this code linked in the description and I’ll be happy to hear your ideas about how this can be improved, or, even better, simplified.

Until next time, thank you for reading!