A very fast virtual dom library
blockdom
is a fast virtual dom library. Its main selling point is
that it does not work at the granularity of a single html element, but instead
it works with blocks
: html elements, with arbitrary content.
So, instead of doing something like h('div', {}, [...some children])
, we can
work in blockdom with a larger unit of dom. For example:
// create block types
const block = createBlock(`<div class="some-class"><p>hello</p><blockdom-child-0/></div>`);
const subBlock = createBlock(`<span>some value: <blockdom-text-0/></span>`);
// create a blockdom virtual tree
const tree = block([], [subBlock(["blockdom"])]);
// mount the tree
mount(tree, document.body);
// result:
// <div class="some-class"><p>hello</p><span>some value: blockdom</span></div>
As a result, blockdom
can use the internal cloneNode(true)
method to quickly
create dom elements in one call instead of many, and the diff process is much
faster, since it has to process less virtual nodes. Here is a benchmark run,
comparing the performance of a handcrafted vanilla js implementation against
blockdom
, solid
(incredibly fast fine-grained reactive framework) and ivi
(the fastest
virtual dom implementation).
Note: yes, I had to run the benchmark a few times to beat solid
. This is totally
cheating. But still impressive, if you ask me :)
blockdom
can update the dom, manage event handlers, support fragments (multi-root elements).
It is however not a fully featured framework. Its goal is being a compilation target
for templates in a higher level framework. For some frameworks, it is useful to
have a rendering process in two separate phases: the render phase (creating the virtual
dom representation) and the commit phase (applying it to the DOM).
Here is a more interesting example. It is a dynamic list of counters, featuring handlers, lists and dynamic content:
const counterBlock = createBlock(`
<div class="counter">
<button block-handler-1="click">Increment</button>
<span>Value: <block-text-0/></span>
</div>`);
const mainBlock = createBlock(`
<div>
<div><button block-handler-0="click">Add a counter</button></div>
<div><block-child-0/></div>
</div>`);
const state = [{ id: 0, value: 3 }];
function addCounter() {
state.push({ value: 0, id: state.length });
update();
}
function incrementCounter(id) {
const counter = state.find((c) => c.id === id);
counter.value++;
update();
}
function render(state) {
const counters = state.map((c) => {
const handler = [incrementCounter, c.id];
return withKey(counterBlock([c.value, handler]), c.id);
});
return mainBlock([addCounter], [list(counters)]);
}
let tree = render(state);
mount(tree, document.body);
function update() {
patch(tree, render(state));
}
Notice that block types are first created, with special attributes or tags such as
<block-text-0 />
or block-handler-1="click"
. What happens is that blockdom
then process the block template, find all these special tags/attributes and generate
fast functions that will create and/or update these values. The number corresponds
to the index of the data given when the block is constructed.
The examples
folder contains the complete code for this example.
blockdom
api is quite small: 6 function to create vnodes, 3 functions to manipulate
vdom trees and one configuration object.
blockdom
provide three functions:
-
mount(vnode, target)
is called initially to mount a vnode tree inside a target (which should be an html element). This will create the relevant DOM and store the proper references inside the vnodes. -
patch(vnode1, vnode2)
is used to update a (already mounted) vnode tree with a new vnode tree. This method will patch the dom, and update the internal references invnode
.vnode2
is left unchanged, and can be discarded. Also, note that ifvnode1
andvnode2
are the same reference, then the patching process will be entirely skipped. This is the way we can implement memoization. -
remove(vnode)
is a method that will remove a (already mounted) vnode tree.
First, let us talk about the various vnode types:
Name | Purpose |
---|---|
block |
a representation of an html element (with children/attributes) |
multi |
a representation of a static list of vnodes (possibly undefined or of different types) |
list |
a dynamic list of vnodes (which have all the same type) |
text |
a simple vnode representing a text node |
toggler |
a container node that allows switching dynamically between different type of subnodes |
html |
represent an arbitrary html content |
The most important vnode type is a block. Since each block is actually unique, we need to first generate a block builder function:
const block = createBlock(`<div>hello blockdom</div>`);
The createBlock
function takes a string and return a function that builds the
corresponding block:
const tree = block(); // now tree is a vnode that can be mounted/patched
So, in a sense, createBlock
is a kind of factory. It creates a function that
will generate the final vnode. The function createBlock
takes two optional
arguments: data
(list of values) and children
(list of vnodes).
The values given in data
are used to set/update dynamic content (text, attributes,
handlers or refs). The vnodes in children
correspond to sub blocks.
Text content is simply added by using a special tag block-text-{index}
:
const block = createBlock(`<div><p><block-text-0/></p><p><block-text-1/></p></div>`);
Notice the suffix 0
and 1
: all content nodes/attributes have to be indexed,
starting at 0. Then, we can provide the corresponding values in the data
array,
given in argument:
const tree = block(["hello", "blockdom"]);
This tree now represents <div><p>hello</p><p>blockdom</p></div>
Block attributes are defined with an attribute: block-attribute-{index}
:
const block = createBlock(`<div block-attribute-0="hello"></div>`);
const tree = block(["world"]); // correspond to <div hello="world"></div>
Note that attribute here is given a broad meaning: class and styles are considered
attributes (but they will use specialized code to properly manage them), and also
some special tags have properties (for example, the checked
property on an input).
These properties are also properly handled, even though they are defined as
attribute.
Event handlers can be added with the block-handler-{index}
attribute:
const block = createBlock(`<div block-handler-0="click"></div>`);
const tree = block([someFunction]);
By default, blockdom
support two variations: the given data can be a function
(in that case, it will be called with the event as argument), or it can be a pair
[fn, value]
, in which case, the function fn
will be called with value, event
as arguments.
Note that this behaviour can be customized (see the section about configuration).
Also, blockdom
has a synthetic event system: this means that it does not
really attach an event handler for each handler in each block. It just binds a
simple global event handler on document.body
for each event type, and will
properly call the corresponding handlers when necessary.
Finally, blocks can define a reference with the block-ref={index}
attribute.
In this case, the provided data should be a function:
const block = createBlock(`<div><p block-ref=0="click">hey</p></div>`);
const tree = block([someFunction]);
The function someFunction
will be called with the htmlelement <p>
when it is
created, and then later with null
when it is removed from the dom.
The multi block is useful when we deal with a fixed number of vnodes. For example, a template with multiple consecutive elements. Also, some or all of its vnodes can be undefined. This is useful when there is some condition for a child to be present. If a child is undefined, the multi vnode will replace it by an empty text node.
const block1 = createBlock(`<div>1</div>`);
const block2 = createBlock(`<div>2</div>`);
const tree = multi([block1(), block2()]); // represents `<div>1</div><div>2</div>`
const otherTree = multi([block1(), undefined]); // represents `<div>1</div>`
Each children can be a mix of any type.
A list
vnode represents a dynamic collection of vnodes, all of them with the
same type. Each of these nodes need to have a key to properly reconcile them.
Here is an example:
const data = [
{ id: 1, text: "apple" },
{ id: 2, text: "pear" },
];
const block = createBlock(`<p><block-text-0/></p>`);
const items = data.map((item) => withKey(block([item.text]), item.id));
const tree = list(items); // represents <p>apple</p><p>pear</p>
Note the use of the withKey
helper.
Most text are inserted inside a block with block-text-{index}
. However, in
some cases, it is useful to be able to manipulate directly just a simple text
node:
// represents 3 text nodes: blackyellowred
const tree = multi([text("black"), text("yellow"), text("red")]);
As mentioned above, blockdom
need each vnode in a patch operation to be of the
same exact type. However, it is not always known before hand what the concrete
type of the vnode will be. For example, if we implement sub templates (partials)
in a template language. The call site does not know what the result of an
arbitrary template render will be. In that case, we need the toggler
vnode
to dispatch between different type of vnodes:
const block = createBlock("<p>hey</p>");
const tree1 = toggler("key1", text("foo")); // represent a text node with foo
const tree2 = toggler("key2", block()); // represent <p>hey</p>
The toggler
function takes a key
as first argument, and a vnode as second.
When it is patched, it compares the values of the keys: if they are the same,
it will simply patch the child vnode. If they are different, it will remove the
previous one and mount the new vnode in its place.
This should be used with caution: this vnode type is used to insert arbitrary html into the DOM:
const tree = html("<div>hey</div>");
This should be avoided most of the time. However, it happens that we need to
display some (hopefully safe/sanitized) html coming from the database. In that
case, the html
vnode type is here to perform the job.
Here is a list of every configuration options in blockdom
:
-
shouldNormalizeDom (boolean, default=true)
If true,blockdom
will normalize the DOM generated by blocks. This means removing text nodes that only contains spaces.config.shouldNormalizeDom = true;
-
mainEventHandler (function taking (data, event))
. Each event generated by handlers will go through that method. By default,blockdom
uses the following code:config.mainEventHandler = (data, event) => { if (typeof data === "function") { data(ev); } else if (Array.isArray(data)) { data[0](data[1], ev); } };
This means that the data given to the block can be either a function or a pair
[function, argument]
. Overriding this may be helpful if the code usingblockdom
has different needs (for example, checking if a component is still alive).
blockdom
is designed to be used as a lower layer in a "real" framework. Some
frameworks (for example, Owl, which is the one I am working on) will need a way
to add some kind of component system. To do that, it seems like the best way is
to add new types of vnodes.
Here is the interface of a blockdom vnode:
export interface VNode<T = any> {
mount(parent: HTMLElement, afterNode: Node | null): void;
moveBefore(other: T | null, afterNode: Node | null): void;
patch(other: T, withBeforeRemove: boolean): void;
beforeRemove(): void;
remove(): void;
firstNode(): Node | undefined;
}
So, to add a new vnode type, we simply need to define an object or a class with
these methods, and it will work with the mount/patch/remove
methods.
Notice the beforeRemove
method: it is a method used to let the framework know
that a vnode will be removed. It is called before the node is removed.
blockdom
is very fast, I believe. If you read this section, you may be interested
in understanding why. Well, to be honest, I am not really sure. I spent hours
running benchmarks, and even now, I am not really sure about what exactly makes
some code fast or not.
Here is what I can tell, though:
-
working at a block level instead of a single html element is a huge speedup, obviously. This is the main selling point of this library.
-
browsers are pretty good at inlining functions, so it's mostly pointless to try to manually complicate code by inlining small functions.
-
synthetic events is a small speed increase (around 1% on the main benchmark).
-
I could not find any noticeable difference by using smaller objects/shorter key names
-
however, for some reason, the implementation got a pretty big speedup once I started using classes. I am not certain why, but I guess that browsers are pretty good at optimizing class construction. It feels like it is faster than creating directly an object: I tried implementing vnodes with objects such as
return { mount: mountFunction, patch: patchFunction, moveBefore: moveBeforeFunction, ..., data: ..., children: ... }
and it was noticeably slower. I assume that it is because each object takes more memory, since they need to keep a pointer to each vnode function.
An alternative was grouping all such objects in a sub key:
return { impl: implementationObject // contains mount/patch/moveBefore/... data: ..., children: ... }
but it was also slower (probably because the code had to perform a lookup everytime).
Another alternative using
Object.create(implementationObject)
failed. So, the big takeway from this is that maybe, using classes is good for performance in some hot paths. -
one of the first implementation tried to build fast code by creating a new customized function with
new Function
, for each block. It was really fast, but actually not really noticeably faster than simply trying to setup a fast create/update path and using closures to compile a block. This has also the advantage of not usingnew Function
(which is disallowed in some environments), and is cheaper, memory wise. -
another interesting point: I believe some of the speed of this vdom comes from the fact that it has a pretty big constraint: a vdom tree is supposed to be patched with a vdom tree of the same shape. This comes naturally if we compile a template into a function (the template has always the same structure). This constraint means that the underlying code does not have to check the type or the keys in most cases. It knows that it is patched with a block of the same type.
-
Also: there is probably some room to make it faster. First, I am not smart enough to understand everything that was done in
ivi
, so there may be some nice ideas that I did not apply. Also, the reconciliation algorithm is probably not the most performant, since I just adapted a quite naive version. Another possible improvement is removing the need to keep a reference to the parent node. If it can be done, this should reduce memory use. If you have any other ideas, I am very interested in hearing about them!
In this section, you will find answers to some questions you may have about this project.
-
Is this virtual dom used in an actual project? Not yet ready, but it is used in the current work on Owl version 2. The Owl framework 1.x (github.com/odoo/owl) is based on a fork of snabbdom, and as such, does not support fragment. The version 2 is not ready yet, but will be based on
blockdom
. -
This is not a virtual dom, is it? Yes it is. Well, it depends what you mean by a virtual dom. It is not a representation of the dom tree element by element, but it still is a complete representation of what the dom is looking like. So, yes,
blockdom
is a virtual dom. -
Why would you need a virtual dom, in the first place? It depends on your needs. Clearly, some frameworks can do very well by using other strategies. However, some other frameworks (such as React and owl with their concurrent mode) need the ability to split the rendering process in two phases, so we can choose to commit a rendering (or not if for some reason it is no longer useful). In that case, I do not see how to proceed without a virtual dom.
-
This sucks. blockdom is useless/slow because of X/Y. Great, please tell me more. I genuinely want to improve this, and helpful criticism is always welcome.
blockdom
is inspired by many frameworks: snabbdom, then solid, ivi, stage0 and
1more. The people behind these projects are incredible.