DEV Community

Carl Mungazi
Carl Mungazi

Posted on

Learning JavaScript by building a UI framework from scratch

In my previous post I explained how APIs from your favourite libraries and frameworks can be turned into programming lessons. Today I will develop that idea by taking it a step further. We will not read other people's code. Instead, we will write our own and build a very basic and rudimentary UI framework.

Building a framework is a good way to deepen your JavaScript and programming knowledge because it forces you to explore language features and software engineering principles. For example, all web frameworks try to solve the problem of keeping application data in sync with the UI. All the solutions to this problems can touch different areas such as routing, DOM manipulation, state management and asynchronous programming.

One of the more popular ways of solving this UI-state-sync problem is using a concept known as the virtual DOM (or vdom). Instead of manipulating the DOM directly in response to data changes, we can use JavaScript objects because they are computationally much cheaper to manipulate. The vdom approach can be broken down like so:

  1. When your application is first loaded, create a tree of JavaScript objects which describe your UI
  2. Turn these objects into DOM elements using DOM APIs such as document.createElement
  3. When you need to make a change to the DOM (either in response to user interaction, animations or network requests), create another tree of JavaScript objects describing your new UI
  4. Compare the old and new tree of JavaScript objects to see which DOM elements have been changed and how
  5. Make changes to the DOM only in places that have changed

One of the fundamental pieces of any vdom implementation is the function which creates the object. Essentially, this function must return an object containing the information needed to create a DOM element. For instance, in order to create this DOM structure:

<ul class="list">
    <li class="list-item" style="color: red;">Item 1</li>
    <li class="list-item" style="color: blue;">Item 2</li>
</ul>

You need to know the following information for each DOM element:

  • type of element
  • list of attributes
  • if it has any children (for each child, we also need to know the same information listed above)

This leads us to our first lesson: data structures. As Linus Torvalds said, "Bad programmers worry about the code. Good programmers worry about data structures and their relationships". So how can we represent the DOM structure above in code?

{
  type: 'ul',
  attrs: {
      'class': 'list'
  },
  children: [
    {
      type: 'li',
      attrs: {
        class: 'list-item',
        style: {
          color: 'red'
        }
      },
    },
    {
      type: 'li',
      attrs: {
        class: 'list-item',
        style: {
          color: 'blue'
        }
      },
    } 
  ]
}

We have an object with three properties and each property is either a string, object or array. How did we choose these data types?

  • All HTML elements can be represented by a string
  • HTML attributes have a key: value relationship which lends itself nicely to an object
  • HTML child nodes can come in a list format and creating them requires performing the same operation on each item in the list. Arrays are perfect for this

So now we know what our data structure looks like, we can move on to the function which creates this object. Judging by our output, the simplest thing to do would be to create a function with takes three arguments.

createElement (type, attrs, children) {
  return {
    type: type,
    attrs: attrs,
    children: children
  }
}

We have our function but what happens if when invoked, it does not receive all the arguments? Furthermore, does the creation of our object require every argument to be present?

This leads us to the next lesson: error handling, default parameters, destructuring and property shorthands.

Firstly, you cannot create a HTML element without specifying a type, so we need to guard against this. For errors, we can borrow Mithril's approach of throwing an error. Alternatively, we can define custom errors as described here.

createElement (type, attrs, children) {
  if (type == null || typeof type !== 'string') {
    throw Error('The element type must be a string');
  }

  return {
    type: type,
    attrs: attrs,
    children: children
  }
}

We will revisit this check type == null || typeof type !== 'string' later on but for now, let us focus on creating our object. Whilst we cannot create HTML elements without specifying a type, we can create HTML elements that have no children or attributes.

In JavaScript, if you call a function without providing any of the required arguments, those arguments are assigned the value undefined by default. So attrs and children will be undefined if not specified by the user. We do not want that because, as we will see later, the rest of our code expects those arguments to contain a value. To solve this, we will assign attrs and children default values:

createElement (type, attrs = {}, children = []) {
  if (type == null || typeof type !== 'string') {
    throw Error('The element type must be a string');
  }

  return {
    type: type
    attrs: attr,
    children: children
  }
}

As mentioned earlier, HTML elements can be created without any children or attributes, so instead of requiring three arguments in our function, we can require two:

createElement (type, opts) {
  if (type == null || typeof type !== 'string') {
    throw Error('The element type must be a string');
  }

  return {
    type: type
    attrs: opts.attr,
    children: opts.children
  }
}

We have lost the default parameters introduced earlier but we can bring them back with destructuring. Destructuring allows us to unpack object properties (or array values) and use them as distinct variables. We can combine this with shorthand properties to make our code less verbose.

createElement (type, { attrs = {}, children = [] }) {
  if (type == null || typeof type !== 'string') {
    throw Error('The element type must be a string');
  }

  return {
    type,
    attrs,
    children
  }
}

Our function can create virtual dom objects but we are not done yet. Earlier we skipped over this bit of code type == null || typeof type !== 'string'. We can now revisit it and learn something else: coercion.

There are four things to observe here:

  • the behaviour of the == loose equality operator
  • the behaviour of the || operator
  • the behaviour of typeof operator
  • the behaviour of !== operator

When I first learnt JavaScript I came across numerous articles advising against using the loose equality operator. This is because it produces surprising results such as:

1 == '1' // true
null == undefined // true

It is surprising because in the examples above, we are comparing values of four different primitive types: number, string, null and undefined. The checks above evaluate to true because == performs a coercion when comparing values of differing types. The rules that govern how this happens can be found here. For our specific case, we need to know the spec states that null == undefined will always return true. Also, !== works by performing the same checks performed by === and then negating the result. You can read the rules about that here.

Returning to our function, the first thing this type == null || typeof type !== 'string' is checking is if a null or undefined value has been passed. Should this be true, the || operator will return the result of typeof type !== 'string'. The order of how this happens is important. The || operator does not return a boolean value. It returns the value of one of the two expressions. It first performs a boolean test on type == null, which will either be true or false. If the test returns true, our error would be thrown.

However, if false is returned, || returns the value of the second expression, which in our case will either be true or false. If our check had been type == null || type and the first expression resulted in false, the second expression would return whatever value is in the variable type. The typeof operator returns a string indicating the type of the given value. We did not use it for our type == null check because typeof null returns object, which is an infamous bug in JavaScript.

With that newfound knowledge, we can take a harder look at createElement and ask ourselves the following questions:

  • How do we check that the second argument can be destructed?
  • How do we check that the second argument is an object?

Let us start by invoking our function with different argument types:

createElement (type, { attrs = {}, children = [] }) {
  if (type == null || typeof type !== 'string') {
    throw Error('The element type must be a string');
  }

  return {
    type,
    attrs,
    children
  }
}

createElement('div', []); // { type: "div", attrs: {…}, children: Array(0) }
createElement('div', function(){}); // { type: "div", attrs: {…}, children: Array(0) }
createElement('div', false); // { type: "div", attrs: {…}, children: Array(0) }
createElement('div', new Date()); // { type: "div", attrs: {…}, children: Array(0) }
createElement('div', 4); // { type: "div", attrs: {…}, children: Array(0) }

createElement('div', null); // Uncaught TypeError: Cannot destructure property `attrs` of 'undefined' or 'null'
createElement('div', undefined); // Uncaught TypeError: Cannot destructure property `attrs` of 'undefined' or 'null'

Now we modify the function:

createElement (type, opts) {
  if (type == null || typeof type !== 'string') {
    throw Error('The element type must be a string');
  }

  if (arguments[1] !== undefined && Object.prototype.toString.call(opts) !== '[object Object]') { 
    throw Error('The options argument must be an object'); 
  }

  const { attrs = {}, children = [] } = opts || {};

  return {
    type,
    attrs,
    children
  }
}

createElement('div', []); // Uncaught Error: The options argument must be an object
createElement('div', function(){}); // Uncaught Error: The options argument must be an object
createElement('div', false); // Uncaught Error: The options argument must be an object
createElement('div', new Date()); // Uncaught Error: The options argument must be an object
createElement('div', 4); // Uncaught Error: The options argument must be an object

createElement('div', null); // Uncaught Error: The options argument must be an object
createElement('div', undefined); // Uncaught Error: The options argument must be an object

Our first function was not fit for purpose because it accepted values of the wrong type. It also gave us a TypeError when invoked with null or undefined. We fixed this in our second function by introducing a new check and new lessons: error types, rest parameters and this.

When we invoked the function with null or undefined as the second argument, we saw this message: Uncaught TypeError: Cannot destructure property 'attrs' of 'undefined' or 'null'. A TypeError is an object which represents an error caused by a value not being the expected type. It is one of the more common error types along with ReferenceError and SyntaxError. This is why we reverted to using an object as our argument because there is no way of guarding against null and undefined values when destructuring function arguments.

Let us take a closer look at the check in our second iteration:

if (arguments[1] !== undefined && Object.prototype.toString.call(opts) !== '[object Object]') { 
  throw Error('The options argument must be an object'); 
}

The first question to ask is: why are we using the arguments object when rest parameters are a thing? Rest parameters were introduced in ES6 as a cleaner way of allowing developers to represent an indefinite number of arguments as an array. Had we used them, we could have written something like this:

createElement (type, ...args) {
  if (type == null || typeof type !== 'string') {
    throw Error('The element type must be a string');
  }

  if (args[0] !== undefined && Object.prototype.toString.call(args[0]) !== '[object Object]') { 
    throw Error('The options argument must be an object'); 
  }
}

This code is useful if our function had many arguments but because we are only expecting two, the former approach works better. The more exciting thing about our second function is the expression Object.prototype.toString.call(opts) !== '[object Object]'. That expression is one of the answers to the question: In JavaScript, how do you check if something is an object? The obvious solution to try first is typeof opts === "object" but as we discussed earlier, it is not a reliable check because of the JavaScript bug that returns true using typeof with null values.

Our chosen solution worked in the ES5 world by taking advantage of the internal [[Class]] property which existed on built-in objects. According to the ES5 spec, this was a string value indicating a specification defined classification of objects. It was accessible using the toString method. The spec explains toString's behaviour in-depth but essentially, it returned a string with the format [object [[Class]]] where [[Class]] was the name of the built-in object.

Most built-ins overwrite toString so we have to also use the call method. This method calls a function with a specific this binding. This is important because whenever a function is invoked, it is invoked within a specific context. JavaScript guru Kyle Simpson has outlined the four rules which determine the order of precedence for this. The second rule is that when a function is called with call, apply or bind, the this binding points at the object specified in the first argument of call, apply or bind. So Object.prototype.toString.call(opts) executes toString with the this binding pointing at whatever value is in opts.

In ES6 the [[Class]] property was removed so whilst the solution still works, its behaviour is slightly different. The spec advises against this solution, so we could seek inspiration from Lodash's way of handling this, for example. However, we will keep it because the risk of it producing erroneous results are very low.

We have created what on the surface appears to be a small and basic function but as we have experienced, the process is anything but small or basic. We can move on to the next stage but that leads to the question, what should that stage be? Our function could do with some tests but that would require creating a development workflow. Is it too early for that? If we add tests, which testing library are we going to use? Is it not better to create a working solution before doing any of this other stuff? These are the kind of tensions developers grapple with daily and we will explore those tensions (and the answers) in the next tutorial.

Oldest comments (8)

Collapse
 
solaomi profile image
Bisola Omisore

Great Article @carlmungazi , looking forward to reading the rest.

Collapse
 
carlmungazi profile image
Carl Mungazi

Thanks! Haven't time to do the final piece (the virtual dom algorithm) but hoping to get it out in the next couple of months or so.

Collapse
 
atongsf profile image
AtongSF

Excellent! While I wonder now that the [[class]] property was removed in ES6, how does Object.prototype.toString.call work.

Collapse
 
carlmungazi profile image
Carl Mungazi

Interesting you mention that. I remember reading the source for one of Lodash's methods and they've stopped using Object.prototype.toString.call in favour of something else. I can't remember what exactly but it was in relation to the [[class]] property.

Collapse
 
alexandrzavalii profile image
Alex Zavalii

Very interesting articles! Really enjoyed reading them.

Collapse
 
carlmungazi profile image
Carl Mungazi

Glad you have found them interesting! They've certainly been fun to write!

Collapse
 
mukeshjoshi profile image
Mukesh Joshi

Really interesting, Thanks for sharing.

Collapse
 
carlmungazi profile image
Carl Mungazi

Thanks. Glad you liked it!