Learning JavaScript by building a UI framework from scratch
Carl Mungazi
Posted on May 1, 2019
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:
- When your application is first loaded, create a tree of JavaScript objects which describe your UI
- Turn these objects into DOM elements using DOM APIs such as
document.createElement
- 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
- Compare the old and new tree of JavaScript objects to see which DOM elements have been changed and how
- 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.
Posted on May 1, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.