DEV Community

Carl Mungazi
Carl Mungazi

Posted on

Learn JavaScript by building a UI framework: Part 3 - Rendering & testing DOM elements

This article is the third in a series of deep dives into JavaScript. You can view previous articles here and here.

This series does not comprehensively cover every JavaScript feature. Instead, features are covered as they crop up in solutions to various problems. Also, every post is based on tutorials and open source libraries produced by other developers, so like you, I too am also learning new things with each article.


Last time out we wrote a testing library for our framework. In today's post, we will add more functionality to the framework which will turn this:

const aprender = require('../src/aprender');

const button = aprender.createElement('button', { children: ['Click Me!'] });
const component = aprender.createElement(
  'div', 
  {
    attrs: { id: 'app'},
    children: [
      'Hello, world!',
      button
    ]
  },
);

const app = aprender.render(component);

aprender.mount(app, document.getElementById('app'));
Enter fullscreen mode Exit fullscreen mode

into this:

We will also new tests:

let element;
let $root;
let app;

beforeAll(() => {
  element = createElement('div', {
    children: [
      createElement('h1', { children: ['Hello, World'] }),
      createElement('button', { children: ['Click Me!'] }),
    ]
  });

  createMockDom();

  $root = document.createElement("div");
  $root.setAttribute('id', 'root');
  document.body.appendChild($root);

  app = render(element);
});


check('it creates DOM elements', () => {
  assert.isDomElement( app );
});

check('it mounts DOM elements', () => {
  mount(app, document.getElementById('root'));

  assert.isMounted(app, $root);
});
Enter fullscreen mode Exit fullscreen mode

From JavaScript object to DOM element

Let us begin by reminding ourselves of the current file structure:

- aprender
  - src
    - createElement.js
  - tests
    - index.js
- examinar
  - node_modules
    - colors
  - package.json
  - package-lock.json
  - src
    - assertions
      - deep-equal.js
      - index.js
    - index.js
Enter fullscreen mode Exit fullscreen mode

Aprender is the name of our framework and it houses the createElement function and its associated test. Examinar is our testing framework and it has a node modules folder containing the colors package. In the assertions folder, the object equality utility function deep-equal sits in its own file. The index file contains the assertions isDeeplyEqual and throws. The first thing we will do is create a folder called demo in aprender so we can build the example application that prints Hello, World to the browser. In the demo folder we create an index.html and index.js file:

<html>
  <head>
    <title>Hello, World</title>
  </head>
  <body>
    <div id="app"></div>
    <script src="./index.js"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode
const aprender = require('../src/aprender');

const button = aprender.createElement('button', { children: ['Click Me!'] });
const component = aprender.createElement(
  'div', 
  {
    attrs: { id: 'app'},
    children: [
      'Hello, world!',
      button
    ]
  },
);

const app = aprender.render(component);

aprender.mount(app, document.getElementById('app'));
Enter fullscreen mode Exit fullscreen mode

The index.js file contains two new methods: render and mount. render turns the JavaScript object assigned to component into a DOM element whilst mount appends the newly created element to <div id="app"></div>. The code for render is:

function renderElement({ type, attrs, children }) {
  const $el = document.createElement(type);

  for (const [attribute, value] of Object.entries(attrs)) {
    $el.setAttribute(attribute, value);
  }
  for (const child of children) {
    $el.appendChild(render(child));
  }

  return $el;
};

function render(vNode) {
  if (typeof vNode === 'string') {
    return document.createTextNode(vNode);
  }

  return renderElement(vNode);
};
Enter fullscreen mode Exit fullscreen mode

Since render can be called recursively via renderElement, it needs to first check if it is dealing with a string. If it is, we create a text node and return it. Otherwise, we return the result of calling renderElement with our virtual dom object. renderElement then loops through the attrs object, sets any attributes it finds and then works on any children present in the children array. The mount function is more straightforward:

function mount($app, $root) {
  return $root.appendChild($app);
}
Enter fullscreen mode Exit fullscreen mode

If we were building React, for example, mount's equivalent would be ReactDOM.render. Like ReactDOM.render, mount takes the element we want to add to the DOM as the first argument and then appends it to the element specified as the second argument. Of course, React does a lot more than that but between createElement, render and mount, you have the basic workflow used by most UI frameworks to create DOM elements. The differences lie in all the things they do in between to manage events, keep track of state and perform updates.

Aprender is not a framework destined for production but if it were, we would need to ensure that it correctly creates and appends DOM elements. This testing will happen in node, which has no DOM, so what can we do? We could:

All of these are good options but for us, using jsdom or a headless browser is overkill. Stubbing DOM objects does not fully meet our requirements because we want to simulate the process of creating a virtual dom object, turning it into a DOM element and then adding it to the DOM tree. Fortunately for us, the framework Mithril.js has already tackled this problem. The maintainers have mocked the DOM in node as part of their testing suite. So we will model our own DOM on their implementation but adapt it to suit our needs. Those needs are expressed in the following tests:

group('aprender', () => {
  let element;
  let $root;
  let app;

  beforeAll(() => {
    element = createElement('div', {
      children: [
        createElement('h1', { children: ['Hello, World'] }),
        createElement('button', { children: ['Click Me!'] }),
      ]
    });

    createMockDom();

    $root = document.createElement("div");
    $root.setAttribute('id', 'root');
    document.body.appendChild($root);

    app = render(element);
  });

  check('it creates DOM elements', () => {
    assert.isDomElement(app);
  });

  check('it mounts DOM elements', () => {
    mount(app, document.getElementById('root'));

    assert.isMounted(app, $root);
  });
});
Enter fullscreen mode Exit fullscreen mode

beforeAll is a hook which takes a function as its argument and then calls it. The function we provided creates our virtual dom object and then initialises our mock DOM via createMockDom().

What is the DOM?

According to MDN Web Docs, "The Document Object Model (DOM) is the data representation of the objects that comprise the structure and content of a document on the web". Our new tests dictate that we need the createElement, appendChild, setAttribute, createTextNode and getElementById methods, so the skeleton of our mock DOM object looks like this:

const document = {
  createElement() {
    appendChild,
    setAttribute
  },
  createTextNode() {},
  getElementById() {}
}

function appendChild() {}
function setAttribute() {}
Enter fullscreen mode Exit fullscreen mode

We will begin by fleshing out the createElement method:

createElement(tag) {
  return {
    nodeType: 1,
    nodeName: tag.toUpperCase(),
    parentNode: null,
    childNodes: [],
    appendChild,
    setAttribute,
    attributes: {},
    $$dom: true
  }
}
Enter fullscreen mode Exit fullscreen mode

It returns an object which represents a DOM element. Real DOM elements contain more properties than listed above but we are only implementing the ones we need. The $$dom property is our own creation and we will look at why we need it when we cover the new test assertions isMounted and isDomElement. The next thing we will do is add the functionality for setting attributes:

function setAttribute(name, value) {
  this.attributes[name] = value;
}
Enter fullscreen mode Exit fullscreen mode

And appending children:

function appendChild(child) {
  let ancestor = this;

  if (ancestor === child) throw new Error("Child element cannot be equal to parent element");
  if (child.nodeType == null) throw new Error("The child is not a DOM element");

  const index = this.childNodes.indexOf(child);
  if (index > -1 ) this.childNodes.splice(index, 1);

  this.childNodes.push(child);
}
Enter fullscreen mode Exit fullscreen mode

The most interesting part of this method is that if the child element already exists in the childNodes array, we remove it and re-insert it at the end of the array. This behaviour mimics what happens in the browser if you append a child which already exists on the target element.

Next we move on to the createTextNode method:

createTextNode(text) {
  return {
    nodeType: 3,
    nodeName: "#text",
    parentNode: null,
    data: text
  }
}
Enter fullscreen mode Exit fullscreen mode

There is an interesting Stack Overflow answer explaining the differences between the data property we are using and the other properties which exist on text nodes and can also hold the text value.

After createTextNode, we come to the getElementById function. There is no definitive answer on how different browsers have implemented this particular method but from reading the HTML specification, we can see that traversing the DOM tree is a viable option. Our DOM tree will not be large enough to need traversing so we will opt for the simpler option of creating a new property called _elementIds on our document object and assigning it an empty object. We will populate this object in the setAttribute method every time an id is set:

function setAttribute(name, value) {
  this.attributes[name] = value;

  if (name === 'id') {
    if (document._elementIds[value]) {
      throw new Error(`${value} is already the id of an existing element`);
    }
    document._elementIds[value] = this;
  }
}
Enter fullscreen mode Exit fullscreen mode

When called, getElementById will return the element if its id exits in _elementIds.

getElementById(id) {
  if (document._elementIds[id]) {
    return document._elementIds[id]
  }

  return null;
}
Enter fullscreen mode Exit fullscreen mode

Testing the DOM

Our first new assertion isDomElement tries to answer the following question - how can you tell if something is a DOM element? The answer is not straightforward. We can attempt an answer by taking inspiration from how React adds a $$typeof property to React elements to help distinguish them from anything else not created by React. We will appropriate this by creating a property called $$dom on our document object and assigning it the value true. Then we write isDomElement:

isDomElement(element) {
  if (element.hasOwnProperty("$$dom") && element.$$dom) return true;

  throw new Error('The supplied element is not a DOM element')
}
Enter fullscreen mode Exit fullscreen mode

Our implementations of $$dom and _elementIds are not the best ways of adding what is effectively meta data to our document object. For one, we could have used Symbols to ensure those properties do not show up via iteration methods such as Object.keys or for..of. But even then, symbols can still be found through the Reflect.ownKeys or Object.getOwnPropertySymbols methods so that solution is not perfect. For getElementById we could traverse the DOM tree and find the element that way. Thankfully, our needs at this stage are relatively simple.

The second assertion, isMounted, tests the results of calling the mount method. As mentioned earlier, mount plays a similar role to ReactDOM.render, so all we need to do is check that our specified DOM element is the only child of the <div id="app"></div> element we created in the index.html file.

isMounted(element, parentElement) {
  if (parentElement.childNodes.length > 1) throw new Error('The root element has more than one child');

  if (parentElement.childNodes[0] === element) return true;

  throw new Error('The supplied element has not been mounted');
}
Enter fullscreen mode Exit fullscreen mode

All the new tests are predicated on the existence of a DOM like structure in the testing environment. This is handled by the createMockDom method, which first creates the root element that is supposed to exist on every non-empty HTML document. It then adds a head and body element as children. Strictly speaking, the html element should also exist in a childNodes array on the document object but we can skip this in our implementation. We then finish by adding our document object to the node global variable.

function createMockDom() {
  document.documentElement = document.createElement("html");
  document.documentElement.appendChild(document.createElement("head"));
  document.body = document.createElement("body");
  document.documentElement.appendChild(document.body);

  global.document = document;
}
Enter fullscreen mode Exit fullscreen mode

Summary

We now have a framework which creates and renders DOM elements. Or do we? Our tests pass but how can we view our application in the browser? We will cover that in the next article by building a module bundler and setting up our development environment. In the meantime, you can explore the code we have written so far here.

Resources

Top comments (0)