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:

an element

a fragment

text

trusted HTML

a component

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 }

In this case, attrs holds an array of our list elements, so we skip the if statement and move on to the else if

holds an array of our list elements, so we skip the if statement and move on to the else if attrs is NOT an object and does NOT hold the null value but it IS an array so… attrs is assigned an empty object start is assigned the number 1 (when it was initialised it was given the number 2)

is NOT an object and does NOT hold the value but it IS an array so…

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 ++ ]) }

arguments.length is 2 and start + 1 = 2 so… children is assigned an array of two objects and another array. How is this possible, I hear you ask? Is the second argument not an array of m() calls? That’s what I thought but if you console.log the arguments variable or if you use your browser to debugger to place breakpoints in the m() function, you will find that Mithril first deals with the those m() calls (turns them into vnode objects). So by the time it comes to dealing with the initial m('ul', [...]) call, it has a nice array to deal with instead of function calls children now holds an array so this nested if statement returns false . In plain English, it is asking: “Is children NOT an array?”

is and + = so…

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:

Vnode.normalize(node) applies two conditional checks. If the node (the name given to the argument it expects) fails those checks, it is returned in the same state it was entered. The first check is a simple Array.isArray(node) check. The second check node != null && typeof node !== "object" returns false because the first expression returns true but the second expression returns false . Remember: the && operator needs both expressions to be true for it to return true. The first two elements in children are returned as they are because but our third element, the array, is turned into a fragment vnode by this: return Vnode("[", undefined, undefined, Vnode.normalizeChildren(node), undefined, undefined) . So at the end of this, normalized now holds an array of three vnodes - two of which are element vnodes and the third a fragment vnode

applies two conditional checks. If the (the name given to the argument it expects) fails those checks, it is returned in the same state it was entered.

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