July 27, 2017

Mithril.js: What are vnodes?

In my first explainer of Mithril’s source code, I broke down how the hyperscript function worked. It’s main task is the creation of virtual DOM nodes (vnodes), the objects which represent DOM elements or parts of the DOM. So how are these objects created?

Vnode functions

This is the function which creates a vnode:

function Vnode(tag, key, attrs, children, text, dom) {
	return {tag: tag, key: key, attrs: attrs, children: children, text: text, dom: dom, domSize: undefined, state: undefined, _state: undefined, events: undefined, instance: undefined, skip: false}
}

This function is assisted in its work by these two helper functions:

Vnode.normalize = function(node) {
	if (Array.isArray(node)) 
		return Vnode("[", undefined, undefined, Vnode.normalizeChildren(node), undefined, undefined)
	if (node != null && typeof node !== "object") 
		return Vnode("#", undefined, undefined, node === false ? "" : node, undefined, undefined)
	return node
}
Vnode.normalizeChildren = function normalizeChildren(children) {
	for (var i = 0; i < children.length; i++) {
		children[i] = Vnode.normalize(children[i])
	}
	return children
}

The type of vnode we create is determined by the value of the tag property. The docs state that a vnode can either be:

1. Element vnode

Let’s go through what happens when we create each of these types. Suppose I write the following code: m('div', {class:"foo"}, "Foo"). The following vnode is created:

{
	attrs: Object,
	children: undefined,
	dom: undefined,
	domSize: undefined,
	events: undefined,
	instance: undefined,
	key: undefined,
	skip: false,
	state: undefined,
	tag: "div",
	text: "Foo",
	_state: undefined,
	__proto__: Object
}

Because I’ve given m() a string as my first argument, one of the things it does is perform this conditional check:

if (typeof selector === "string") {
	var cached = selectorCache[selector] || compileSelector(selector)
}

selectorCache holds an empty object, so when I use 'div' for the first time, selectorCache[selector] returns undefined. And since undefined is one of JavaScript’s six falsy values, cached is assigned the value of compileSelector(selector). Note: the logical operator || does not necessarily return a boolean. According to the JavaScript spec, “The value produced by a && or || operator is not necessarily of type Boolean. The value produced will always be the value of one of the two operand expressions.”. It is also worth noting that because compileSelector returns the following assignment selectorCache[selector] = {tag: tag, attrs: attrs}, selectorCache[selector] is no longer undefined. In this instance, it returns the following object:

{
	attrs: Object,
	tag: "div",
	__proto__: Object
}

When m() creates the vnode, the value of the tag property is assigned in the execSelector function and we end up with this vnode, which represents the div DOM element:

{
	attrs: {
		class: undefined,
		className: "foo",
		__proto__ :Object
	},
	children: undefined,
	dom: undefined,
	domSize: undefined,
	events: undefined,
	instance: undefined,
	key: undefined,
	skip: false,
	state: undefined,
	tag: "div",
	text: "Foo",
	_state: undefined,
	__proto__ :Object
}

2. Text vnode

What happens if I write the following code: m('div', {class:"foo"}, "Foo", "Baz", "Wom", "Bat)? This will still create a vnode for the div DOM element but instead of the children property being undefined, it will be an array of four objects, one for each string I have given as the last four arguments:

{
	attrs: {
		class: undefined,
		className: "foo",
		__proto__ :Object
	},
	children: Array(4),
	dom: undefined,
	domSize: undefined,
	events: undefined,
	instance: undefined,
	key: undefined,
	skip: false,
	state: undefined,
	tag: "div",
	text: "Foo",
	_state: undefined,
	__proto__ :Object
}

The Vnode.normalize function does this through this return statement: return Vnode("#", undefined, undefined, node === false ? "" : node, undefined, undefined). The # string signifies that the vnode being created is a text vnode.

3. Trusted HTML vnode

So far we’ve touched upon element and text vnodes. Next, we’ll look at vnodes for trusted HTML. Mithril escapes all values by default to prevent cross-site scripting attacks, so the following code: m('div', "<h1>Here's some <em>HTML</em></h1>") will be rendered as a div, with the text <h1>Here's some <em>HTML</em></h1>. The vnode looks like:

{
	attrs: undefined,
	children: undefined,
	dom: undefined,
	domSize: undefined,
	events: undefined,
	instance: undefined,
	key: undefined,
	skip: false,
	state: undefined,
	tag: "div",
	text: "<h1>Here's some <em>HTML</em></h1>",
	_state: undefined,
	__proto__ :Object
}

But if we write m('div', m.trust("<h1>Here's some <em>HTML</em></h1>")), we get what we have asked for and the vnode looks like:

{
	attrs: undefined,
	children: Array(1), // the object in this array is below
	dom: undefined,
	domSize: undefined,
	events: undefined,
	instance: undefined,
	key: undefined,
	skip: false,
	state: undefined,
	tag: "div",
	text: undefined,
	_state: undefined,
	__proto__ :Object
}

// Our string, which has created a h1 element, is now a child of the div element
{
	attrs: undefined,
	children: "<h1>Here's some <em>HTML</em></h1>",
	dom: undefined,
	domSize: undefined,
	events: undefined,
	instance: undefined,
	key: undefined,
	skip: false,
	state: undefined,
	tag: "<",
	text: undefined,
	_state: undefined,
	__proto__ :Object
}

The difference between this vnode and the earlier one is that the tag property has a different value. Also, the string has been turned into a vnode and is now a child of the div element. In the source, this is the m.trust declaration:

function(html) {
	if (html == null) 
        html = ""
	return Vnode("<", undefined, undefined, html, undefined, undefined)
}

Why are there no error checks to make sure this function receives the string argument it deserves? That’s taken care off by the rendering process. So if the desired argument is missing, you will definitely hear about it.

4. Component vnode

The fourth type of vnode Mithril creates is a component vnode. A Mithril component is simply a JavaScript object that has a method called view.

var Comp = {
    view: function() {
        return m('div', 'I am a Component')
    }
}
m.render(document.body, m(Comp));

So far, we’ve been passing strings as the first argument to m(). But because we have now given it an object, it will return this Vnode(selector, attrs.key, attrs, normalized) function call instead of execSelector(cached, attrs, normalized), and we end up with this vnode:

{
	attrs: Object, // empty
	children: Array(0),
	dom: undefined,
	domSize: undefined,
	events: undefined,
	instance: undefined,
	key: undefined,
	skip: false,
	state: undefined,
	tag: {
		view: function(),
	        __proto__ :Object
    	},
	text: undefined,
	_state: undefined,
	__proto__ :Object
}

5. Fragment vnode

Lastly, and definitely not least, we have fragment vnodes. Here is what the docs say about them: Represents a list of DOM elements whose parent DOM element may also contain other elements that are not in the fragment. When using the m() helper function, fragment vnodes can only be created by nesting arrays into the children parameter of m(). m(“[”) does not create a valid vnode. The docs for the m.fragment call (which also creates fragment vnodes but then also gives them an attributes object) has the following example of how you could construct fragment vnodes:

var groupVisible = true

m("ul", [
    m("li", "child 1"),
    m("li", "child 2"),
    groupVisible ? [
        // a fragment containing two elements
        m("li", "child 3"),
        m("li", "child 4"),
    ] : null
])

Here we have an array of elements which contains a nested array containing further elements. What kind of vnode object does this create?

{
    attrs: undefined
    children: Array(3), // this contains our first two list elements and our nested array
    dom: undefined,
    domSize: undefined,
    events: undefined,
    instance: undefined,
    key: undefined,
    skip: false,
    state: undefined,
    tag: "ul",
    text: undefined,
    _state: undefined,
    __proto__ :Object
}

In the children array, the vnodes for the list elements look as you would expect. The nested array vnode object looks like:

{
    attrs: undefined
    children: Array(2), // this contains our last two list elements
    dom: undefined,
    domSize: undefined,
    events: undefined,
    instance: undefined,
    key: undefined,
    skip: false,
    state: undefined,
    tag: "[",
    text: undefined,
    _state: undefined,
    __proto__ :Object
}

So how does m() create this data structure?

1. The fun begins with this conditional check:

if (attrs == null) {
    attrs = {}
} else if (typeof attrs !== "object" || attrs.tag != null || Array.isArray(attrs)) {
    attrs = {}
    start = 1
}

2. Then we move on to another conditional check:

if (arguments.length === start + 1) {
    children = arguments[start]
    if (!Array.isArray(children)) children = [children]
} else {
    children = []
    while (start < arguments.length) children.push(arguments[start++])
}

3. The next step is this: var normalized = Vnode.normalizeChildren(children). From our earlier deep dive into m(), we know that all this function does is apply the Vnode.normalize function on every element in our array. So this is what happens to each element:

4. The final step is: return execSelector(cached, attrs, normalized) and this returns the object we started with earlier.

So, in summary, when using Mithril (and I supposed this also extends to other virtual dom frameworks), vnodes are among the most important data structures because they represent the DOM elements painted on screen. Before I dug into the source, I used to think of them in somewhat mystical terms, but once I realised they were simply objects which happened to be doing a lot of cool stuff, I started approaching them with less trepidation. Also, this deep dive focused exclusively on the type of vnodes Mithril creates, so when I revisit this topic I will look at the other properties available on vnodes.

Reflections

  1. A deeper understanding of DOM creation would help me appreciate more how virtual dom works. Links for further study: this and this

  2. I always assumed the || check returned a boolean. However, it can actually be used to set default values. Kyle Simpson’s book You Don’t Know JavaScript has a LOT more detail on this in these two chapters

  3. Investigating fragment vnodes lead me to the discovery of the DocumentFragment object. You can use this object to append elements to the DOM without causing reflows or repainting. Read this blog post for more or check out this

  4. I need to study further the subtleties between != and !== checks. They keep cropping up and I need to get a better handle on them

  5. Stepping through functions calls in the Chrome debugger can make your head hurt! But it’s a really useful tool for checking the values and state of your program. Definitely a tool to add to the source code reading tool box

  6. Digging into the creation of fragment vnodes was by far the most time-consuming part of this exercise. And even though I feel my understanding is still fuzzy, that is a good thing because it means the ideas will only get clearer the more code reading I do

  7. An extension of my study on vnodes could be creating my own virtual dom library as described in this article