How RetroV works
Back to the RetroV main page.
RetroV is as small and simple as I can make it.
My first attempt at it was just 74 lines long and all it did was render (no diffing). It’s surprising how useful the rendering alone can be!
Since then, the code has grown to 300 lines, but the interface has remained identical.
There’s a single method:
RV.render(dom_container, v);
Inside the library’s closure, this has been renamed to user_render()
, which
is the first thing you’ll encounter in the source. (There’s also an internal
render()
which is called recursively.)
The v
parameter is the tree of virtual node data (nested arrays with
the occasional properties object). It will be rendered into
the DOM element dom_container
.
Just so we have something concrete to look at, here’s the example from the README:
RV.render( document.body, ['div', ['h1', 'Hello'], ['p', 'Hello world, this is ', ['em', 'RetroV'], '!', ] ] );
Which you can see running here.
make_obj() makes "vnodes"
The first thing that happens when you call RV.render()
is that the v
data structure gets converted into an internal object format.
I re-wrote RetroV no less than four times in the span of a week. The first two times, it used the user-supplied array structure directly.
This was fine for simple examples like the Hello World above. But it got really nasty for anything more complicated!
The hardest part was dealing with arrays of elements. RetroV lets you define sibling elements in a very natural way when they’re children of a common parent, like so:
['ul', ['li', 'A'], ['li', 'B'], ['li', 'C'], ]
But a very common pattern in generating pages is to use something like
a map
of an array to make siblings, like so:
letters = ['A','B','C']; ['ul', letters.map(function(l){ return ['li', l]; }) ]
Which results in one extra array layer:
['ul', [ ['li', 'A'], ['li', 'B'], ['li', 'C'], ] ]
Obviously I want to flatten the extra array layer, but accomplishing that while also trying to "diff" changes between the new and old "v" trees and keeping track of where we are in the real DOM to apply changes was a total nightmare.
So make_obj()
was born. It turns the array tree into a tree of uniform "vnode"
objects. And yes, it ended up looking remarkably similar to the shape used by
many other VDOM libraries…not intentional, but also not very surprising.
{t:'ul', c:[ {t:'li', c:{t:'"', text: 'A'}}, {t:'li', c:{t:'"', text: 'B'}}, {t:'li', c:{t:'"', text: 'C'}}, ]}
What makes the objects uniform is that they all have a t
(type) property.
It’s certainly more verbose, and I hated having to "walk the tree" twice (once
in make_obj()
and once again in render()
), but having non-element types
like text nodes, null
, false
, and raw HTML strings in a uniform structure
let me compare them with a simple old.t === new.t
instead of a bunch of
awkward tests.
And the array flattening during this process was essential for keeping the complexity down in the rest of the library.
old_v and new_v
After make_obj()
turns the supplied v
structure into the internal
object format, the new tree is
called new_v
.
Now the user_render()
function checks for a previously
stored tree in the container_dom
. If it exists, it’s called old_v
.
Then new_v
becomes old_v
for the next round and so on.
As long as you keep rendering to the same container, all changes will be performed
by finding differences between old_v
and new_v
.
Once the user_render()
function is done with the new/old housekeeping, it
calls the internal render()
function for the real work.
Internal render()
Figuring out what to do with any given combination of vnode objects was mostly a process of trial and error. The order of each logical test is important!
This comment at the top of render()
summarizes the table of
tested combinations in the order in which they’re performed:
old | new | Action -----+-----+----------------------------------------- A | | Remove old * |false| Boolean 'false' means "no change" * | [ | Array, render each child as sibling | A | Append to container_dom B | A | Replace old with new A | A | update() old with new props and children
It might be a bit cryptic, but A
means "one type of element" and
B
means "another type of element". *
means "any type".
When RetroV sees a different element type, it replaces the old element with the new one. I briefly explored inserting rather than replacing because sometimes that would be more efficient if the rest of the siblings remained unchanged..but keeping track of that and the current DOM position quickly proved to be a complexity nightmare and totally not worth the effort.
(By the way, for this reason, I recommend using null
as a placeholder
for an element which may exist in the future as opposed to simply
omitting it entirely. Replacing the null
placeholder with the element
will be much more efficient than re-rendering everything that comes
after when the element positions change. I show an example of
this in the demo/tutorial.)
The create()
method creates real DOM nodes for all types and recursively
handles any children in element types. It also makes placeholders for things
like empty arrays and null
values.
The blanks in the table above are literally undefined
nodes and they are
always the result of either rendering into a brand new empty container or
different quantities of sibling nodes from the old or new lists. In those
cases, I’ll be appending or removing items.
When we encounter an array (identified with type "["
), it
means we have a collection of sibling elements that are not
children of another vnode (that’s handled in element create/update).
It kind of pains me to have this logic at all.
Handling arrays of elements in a different way from handling child siblings
feels redundant because they’re both dealing with lists of vnodes.
But I do need to be able to directly render a collection of sibling elements to a DOM container like so:
<div id="container"></div> <script> RV.render(document.getElementById('container'), [ ['header', ...], ['article', ...], ['footer', ...], ] );
(Notice how these will ultimately have a common parent when they’re attached to the DOM, but that parent is not part of the tree itself.)
Anyway, render()
is just a bunch of if
statements dealing with these
possibilities.
The final possibility in the list is
updating a node when old and new are the same type.
Here’s the last line of render()
:
update(child, old_v, new_v);
update() compares individual elements
Conceptually, updating an element is very simple: each property is compared between old and new and if it’s changed, we changed it on the real DOM element.
Of course, it’s a little more complicated. For example, styles
is a
collection with sub-properties. And form elements like text fields have their
own internal state in the DOM that can be changed by the user (which is the
point of those elements), so we can’t rely on the old node to know if
certain properties have changed.
In practice, I have chosen to just always change the value
property in form
elements if one is set in the new vnode and that’s worked out so far. We’ll see
if I run into any trouble with it.
Also, we need to handle updating any child elements, which means recursing
back into render()
. Ah, trees…
Add forward, remove backwards
There is a function called resolve_siblings()
which is used to update array collections and element children.
You’ll noticed something funny about it:
If the new list of siblings is the same size or larger than the old
list, I call render()
on them in a loop like you’d expect.
But when the old list is bigger, I loop through them backward!
The reason for this is that if you delete an element from the DOM, all of the index numbers for the children which come after that element are now wrong and your code will explode.
Example: Let’s say we’ve removed two items from a list. Here’s what our old and new sibling lists look like:
old = A,B,C,D new = A,B
When processing that list, here’s what a VDOM library sounds like when it forgets to perform removals in reverse:
-
"I’m on Sibling 0: New A and Old A are the same, continue with Sibling 1."
-
"I’m on Sibling 1: New B and Old B are the same, continue with Sibling 2."
-
"I’m on Sibling 2: New is undefined, so remove DOM Element 2!"
-
Delete DOM Element 2
-
DOM Element 3 is now 2!
-
"Now I’ll continue with Sibling 3."
-
"I’m on Sibling 3: New is undefined, so remove DOM Element 3!"
-
Delete DOM Element 3
-
ERROR! There is no DOM Element 3!
-
"Oh no! Now I will vomit upon the console and crash."
Summary
There are a ton of other little details, but that’s already more than you really need to know to get a good feel for it.
I would say that the hardest thing about writing one of these libraries is that the input structures can have so many annoyingly heterogeneous values. Sibling elements can be just an array to be returned or they can be children of a parent element. Each of those types have to be handled a little bit differently.
There are regular elements, text nodes, and even comment nodes. Those all need to be created differently.
Elements have certain properties that require special handling (I mentioned styles
earlier.)
On top of the intrinsic browser DOM complexities, RetroV allows special values (empty arrays, nulls, false, HTML source strings) and gives them special meaning. Oh, and we mustn’t forget about undefined values when you have two lists of different lengths!
But there is a really simple idea buried under all those exceptions and edge cases: compare two trees and create new items or update existing items.
These bullet points cover most of it:
-
We call
RV.render()
with a DOM container andv
structure to render. -
Turn the
v
into an internal object format to make it easier to deal with. -
If we’ve rendered to this container before, get the old
v
structure to compare. -
Save the new
v
as the oldv
for next time. -
Start comparing new and old:
-
Remove from the DOM if there are less new elements.
-
Add to the DOM if there are more new elements.
-
Replace the DOM element if the old and new types are different.
-
Update the DOM element if the old and new types are the same.
-
-
When we Add, Replace, or Update an element that has children, loop over them and Add, Replace, or Update those as needed.
As with all recursive problem solving, you start with the base case and then "do the rest".
Finally, trying to actually trace it all at once is too much for my grug brain (grugbrain.dev), but each individual part is tractably small.
See also Why use a VDOM?