Reconciliation is a fundamental part of any virtual DOM implementation and as far as I can tell is where most of the complexity and the potential pitfalls and bugs lie. At its most simple, it’s the problem of syncing the real DOM with the virtual DOM, and in particular taking care of updates. Therefore, it’s helpful to look at how it is handled in various projects, as well as to get a handle on the general nature of the problem.
Given a tree of virtual DOM nodes it is fairly straightforward to produce a corresponding DOM tree, especially if we’re dealing with the “simple case” of virtual DOM where there are no 🔧 VDom components. We can start with the root node, create a DOM node which corresponds to it, and then recur down the tree through the children, adding new children to the DOM nodes we’ve already created to match our virtual DOM tree structure. This is the easy case and is basically what we do on the first render.
Immediately after that (i.e. at the second render) we’re confronted with a different problem: how do we figure out what we need to update, and how do we keep our updates performant? We’ll be passed a new virtual DOM tree (we don’t worry now about where that comes from or how its calculated) and we need to make changes to the DOM to reflect that new tree so that the user sees the right view.
The simplest possible answer would be to re-render the whole tree, creating an entirely new DOM tree based purely on the new virtual DOM tree which we could then just swap in for the current DOM tree. This would work and possibly be fine for very low interactivity applications with small numbers of DOM nodes, but even though computers are fast this would run into some obvious performance issues very quickly.
So instead what we have to do is figure out a performant way to update the existing DOM tree in-place in order to create a DOM state that matches the new virtual DOM tree.
To put it a bit more formally, let V be the set of all virtual DOM trees (aka the virtual DOM treespace) and let v_{cur}, v_{next} \in V be our old and new virtual DOM trees (current and next). Then let D be the set of all actual (or concrete, I sort of prefer that terminology) DOM trees, and let d_{cur} \in D be the current state of a DOM tree which we assume accurately reflects v_{cur}. There is then some tree d_{next} \in D which is a (currently hypothetical) DOM tree which matches up with v_{next}, or, put another way, if we have some function render: V \to D which maps from the virtual DOM treespace V to the concrete DOM treespace D then we assume render(v_{cur}) = d_{cur} and render(v_{next}) = d_{next}. The problem we’re confronted with then is, more or less, that we assume render to be expensive, and therefore simply swapping out render(v_{next}) for our current tree is impractical.
So then we’ve got to figure out, based on comparing v_{cur} and v_{next}, how to carry out some sequence of transformations S on d_{cur} which will transform it into d_{next}. This is the core of the problem because, to restate, we could always just use render(v_{next}) but we’d like to avoid that because we assume that, in practice, it will get expensive. It turns out that computing the edit script S is something which can be done exhaustively: there are published algorithms which will find a script which minimizes the edit distance between two trees1. In a perfect world we’d just use one of those and be off to the races! But unfortunately we won’t get away with things that easily. It turns out that until recently the published algorithms for finding the minimal edit script between two trees ran in cubic time (O(n^3))2, which means that for a reasonably large but not huge number of nodes in a tree (say 1000) we’d be looking at performing something like a billion comparison operations in order to find the minimal edit script. Not acceptable!
Thus in the real world we need to use some sort of heuristic in order to transform d_{cur} into d_{next}.3 Different virtual DOM implementations use different heuristics for this, I’m going to review them here, describe the current heuristic used in Stencil, and try to produce some useful comparison / summary of tradeoffs between them.
The React devs have published a few technical articles details of how React works, including one about reconciliation.
According to their documentation, React’s diffing algorithm works like this, operating on a pair of trees (the old tree of vdom nodes and the new tree):
newTree
be the new vdom tree and
oldTree
be the old onenewRootVNode
be the root node of
newTree
and let oldRootVNode
be the root node
of oldTree
newRootVNode.type
equal
oldRootVNode.type
? Type here is basically the element type
set in 👩‍🔬 JSX (i.e. the first argument to
whatever function processes the JSX) so it could be an HTML tag name
like div
, span
, etc, or it could be a
user-defined component like ProfilePicture
,
Comment
, whatever
oldRootVNode
and
replace it with a new DOM tree created based on
newRootVNode
newRootVNode.type
a DOM element or a custom
element?rootDOMNode
be the DOM node created for
oldRootVNode
rootDOMNode
for any which are
different between newRootVNode
and
oldRootVNode
rootDOMNode
as the DOM node for
newRootVNode
newRootVNode
and
oldRootVNode
. let i
be the index from 0 to
newRootVNode.children.length
:
i
-th children have a key
prop set on them?VNode
with the same key and, if found, recur diffing those two childreni
-th childrennewRenderOutput
be the output of the
render()
function (or just call the function in the case of
a function-based component) and let oldRenderOutput
be the
previous render output. Note that both of these things are trees of
VNode
objectsnewRenderOutput
and
oldRenderOutput
An important point here, and something which was apparent to me in
reviewing and documenting our updateChildren
method in
Stencil, is that any reconciliation algorithm is basically attempting to
identifying when and where DOM elements can be preserved across
re-renders. As long as the cost of doing these comparisons is less than
the cost of just recreating the whole DOM tree it saves computation, but
it really is always a “best effort” sort of thing - there are cases
where it is ambiguous or too expensive to figure out which elements
should be preserved, and in those cases the thing to do really is to
just re-render.
For instance, on thing of note is that React makes no attempt to re-use any DOM nodes within a tree if the root node needs to be thrown out. This seems wasteful but is probably fine almost all the time, and I would imagine that if you need children to be preserved the thing to do would be to refactor so that their parents have the same tags.
Just consider how complicated it would get to figure out when nodes buried deep in a tree could get hoisted out and re-used…
Preact’s virtual DOM API is a bit simpler than some others, notably
it is quite a bit simpler than React. Instead of having to do a
createRoot
, call some sort of function to render into that
root, and all that, Preact instead just exposes a single
render
function to the user. This can be called once by the
user.
// To be able to support calling `render()` multiple times on the same
// DOM node, we need to obtain a reference to the previous tree. We do
// this by assigning a new `_children` property to DOM nodes which points
// to the last rendered tree. By default this property is not present, which
// means that we are mounting a new tree for the first time.
let oldVNode = isHydrating
? null
: (replaceNode && replaceNode._children) || parentDom._children;
looks like they diff the new vnodes with the old ones and produce a queue of edits that need to be made to the DOM, which are then committed in one step
diff(
,
parentDom// Determine the new vnode tree and store it on the DOM element on
// our custom `_children` property.
,
vnode|| EMPTY_OBJ,
oldVNode ,
EMPTY_OBJ.ownerSVGElement !== undefined,
parentDom!isHydrating && replaceNode
? [replaceNode]
: oldVNode
? null
: parentDom.firstChild
? slice.call(parentDom.childNodes)
: null,
,
commitQueue!isHydrating && replaceNode
? replaceNode
: oldVNode
? oldVNode._dom
: parentDom.firstChild,
isHydrating;
)
// Flush all queued effects
commitRoot(commitQueue, vnode);
}
Somehow though once you call render
once if you have a
state update or anything like that then Preact seems to auto-update the
component, I think basically preact is like a reactive virtual DOM
hybrid of sorts.
render
function basically
sets up listeners which will re-render in the case that a prop changes,
that a value changes, etc?See https://arxiv.org/pdf/2209.07524.pdf for a recent result sharing a new algorithm for doing this - in addition to the result, the paper has some helpful background about the nature of the problem.↩︎
In some of their documentation the React devs cite a paper (https://grfia.dlsi.ua.es/ml/algorithms/references/editsurvey_bille.pdf) which discusses this in some detail.↩︎
I did not know about this algorithmic constraint when
first reading the Stencil code, but after looking into it a bit I’ve
concluded that Stencil’s usage of heuristics for things like detecting
when children match up is well-justified. However, we should try to make
sure that our heuristics always result in good output! The ref
issue showcases how we do have a shortcoming there.↩︎