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 attrs
is NOT an object and does NOT hold thenull
value but it IS an array so…attrs
is assigned an empty objectstart
is assigned the number 1 (when it was initialised it was given the number 2)
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
is2
andstart
+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 ofm()
calls? That’s what I thought but if youconsole.log
thearguments
variable or if you use your browser to debugger to place breakpoints in them()
function, you will find that Mithril first deals with the thosem()
calls (turns them into vnode objects). So by the time it comes to dealing with the initialm('ul', [...])
call, it has a nice array to deal with instead of function callschildren
now holds an array so this nested if statement returnsfalse
. In plain English, it is asking: “Ischildren
NOT an array?”
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 thenode
(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"
returnsfalse
because the first expression returnstrue
but the second expression returnsfalse
. Remember: the&&
operator needs both expressions to be true for it to return true. The first two elements inchildren
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 areelement vnodes
and the third afragment vnode
- The first check is a simple
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
-
A deeper understanding of DOM creation would help me appreciate more how virtual dom works. Links for further study: this and this
-
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 -
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
-
I need to study further the subtleties between
!=
and!==
checks. They keep cropping up and I need to get a better handle on them -
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
-
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
-
An extension of my study on vnodes could be creating my own virtual dom library as described in this article