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'));
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);
});
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
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>
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'));
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);
};
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);
}
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:
- Use
jsdom
. Jest and Enzyme, two popular testing tools, use it - Stub DOM objects as shown here
- Use a headless browser
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);
});
});
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() {}
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
}
}
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;
}
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);
}
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
}
}
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;
}
}
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;
}
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')
}
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');
}
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;
}
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.
Top comments (0)