simple-virtual-dom is a library which, a bit like đź’Ż hundred VDom, is intended to serve as a learning resource to understand how the virtual DOM works. It is, as far as I can tell, implemented in a pretty similar way to other virtual DOM examples which are out there.
The package exports three functions which work together to implement
a virtual DOM: el, patch, and
diff. el has a call signature which is
compatible with the standard inferface for 👩‍🔬
JSX (so it can be used to create a tree of virtual DOM objects),
which can be rendered to the DOM by calling a .render()
method. diff takes two virtual DOM trees (old and new) and
returns a set of patches which can be applied to the former to turn it
into the latter, and patch actually applies those
patches.
Using this looks like:
var svd = require('simple-virtual-dom')
var el = svd.el
var diff = svd.diff
var patch = svd.patch
// 1. use `el(tagName, [propeties], children)` to create a virtual dom tree
var tree = el('div', {'id': 'container'}, [
el('h1', {style: 'color: blue'}, ['simple virtal dom']),
el('p', ['Hello, virtual-dom']),
el('ul', [el('li')])
])
// 2. generate a real dom from virtual dom. `root` is a `div` element
var root = tree.render()
// 3. generate another different virtual dom tree
var newTree = el('div', {'id': 'container'}, [
el('h1', {style: 'color: red'}, ['simple virtal dom']),
el('p', ['Hello, virtual-dom']),
el('ul', [el('li'), el('li')])
])
// 4. diff two virtual dom trees and get patches
var patches = diff(tree, newTree)
// 5. apply patches to real dom
patch(root, patches)
// now the `root` dom is updatedLet’s start with the el function for creating new
elements and look at how that is rendered to the DOM, and then move on
to diffing and patching.
el / ElementThe el function which is exported is an alias for the
Element object constructor, defined here:
/**
* Virtual-dom Element.
* @param {String} tagName
* @param {Object} props - Element's properties,
* - using object to store key-value pair
* @param {Array<Element|String>} - This element's children elements.
* - Can be Element instance or just a piece plain text.
*/
function Element (tagName, props, children) {
if (!(this instanceof Element)) {
if (!_.isArray(children) && children != null) {
children = _.slice(arguments, 2).filter(_.truthy)
}
return new Element(tagName, props, children)
}This function can be called either with new or without,
and it works fairly similarly to the equivalent function in đź’Ż hundred VDom, except that it is
an object constructor rather than a plain function which returns an
object created with an object literal.
But anyhow, it does things you’d expect, like setting some values on
this based on the args (note the fun easter egg here):
this.tagName = tagName
this.props = props || {}
this.children = children || []
this.key = props
? props.key
: void 666Then it deals with children:
var count = 0
_.each(this.children, function (child, i) {
if (child instanceof Element) {
count += child.count
} else {
children[i] = '' + child
}
count++
})
this.count = count
}If the children are instances of Element (aka virtual
DOM nodes in this context - probably would have been better to call this
function VElement or something like that) then it adds the
child’s count property to its own count
property, and otherwise it coerces the value to a string, and in either
case it increments count. Since this is basically happening recursivey
up and down the tree the root node will have a .count
property reflecting the total number of child nodes.
Then the final thing is a render method which creates a
new HTML element, renders all the children into it, and then returns
that element so the caller can attach it to the document body.
/**
* Render the hold element tree.
*/
Element.prototype.render = function () {
var el = document.createElement(this.tagName)
var props = this.props
for (var propName in props) {
var propValue = props[propName]
_.setAttr(el, propName, propValue)
}
_.each(this.children, function (child) {
var childEl = (child instanceof Element)
? child.render()
: document.createTextNode(child)
el.appendChild(childEl)
})
return el
}One more interesting bit here is the call to _.setAttr
which is a little utility func defined nearby which does a bit more than
Element.setAttribute:
_.setAttr = function setAttr (node, key, value) {
switch (key) {
case 'style':
node.style.cssText = value
break
case 'value':
var tagName = node.tagName || ''
tagName = tagName.toLowerCase()
if (
tagName === 'input' || tagName === 'textarea'
) {
node.value = value
} else {
// if it is not a input or textarea, use `setAttribute` to set
node.setAttribute(key, value)
}
break
default:
node.setAttribute(key, value)
break
}
}Ok so basically the way this works is you call Element
with normal 👩‍🔬 JSX factory function
arguments and you get back a tree of these Element objects.
Then on the root one you can call .render to render it to
DOM nodes, which you can then attach to the document.
diffThis function does a tree traversal, comparing the old and new
virtual DOM trees and, when they differ, produces patch objects which
will be interpreted by the patch function to bring the
state of the DOM in-line with the new virtual DOM tree.
I believe that the types for the patch objects could be represented in TypeScript like this:
const REPLACE = 0
const REORDER = 1
const PROPS = 2
const TEXT = 3
type ReplacePatch = {
type: REPLACE,
node: Element
}
type ReorderPatch {
type: REORDER,
moves: Move[]
}
type Move {
index: number,
// type 0 is removing, type 1 is inserting
type: 0 | 1,
item?: Element
}
type PropsPatch {
type: PROPS,
props: Record<string, any>
}
type TextPatch {
type: TEXT,
content: string
}
type Patch =
| ReplacePatch
| ReorderPatch
| PropsPatch
| TextPatchThe entry point for producing these is this function:
function diff (oldTree, newTree) {
var index = 0
var patches = {}
dfsWalk(oldTree, newTree, index, patches)
return patches
}The dfsWalk function is where most of the work happens.
You can read through it to get an idea:
function dfsWalk (oldNode, newNode, index, patches) {
var currentPatch = []
// Node is removed.
if (newNode === null) {
// Real DOM node will be removed when perform reordering, so has no needs to do anything in here
// TextNode content replacing
} else if (_.isString(oldNode) && _.isString(newNode)) {
if (newNode !== oldNode) {
currentPatch.push({ type: patch.TEXT, content: newNode })
}
// Nodes are the same, diff old node's props and children
} else if (
oldNode.tagName === newNode.tagName &&
oldNode.key === newNode.key
) {
// Diff props
var propsPatches = diffProps(oldNode, newNode)
if (propsPatches) {
currentPatch.push({ type: patch.PROPS, props: propsPatches })
}
// Diff children. If the node has a `ignore` property, do not diff children
if (!isIgnoreChildren(newNode)) {
diffChildren(
oldNode.children,
newNode.children,
index,
patches,
currentPatch
)
}
// Nodes are not the same, replace the old node with new node
} else {
currentPatch.push({ type: patch.REPLACE, node: newNode })
}
if (currentPatch.length) {
patches[index] = currentPatch
}
}One interesting bit to pay attention to here is the
index argument. Initially when dfsWalk is
called from diff this is set to 0:
var index = 0In dfsWalk a series of patches are assembled in
currentPatch and then that array of patches is stuffed into
the patches record (which basically has type
Record<number, Patch[]>) under the index passed
in.
The index is also passed down to
diffChildren, the function responsible for diffing the
children of an element and for recursively calling
dfsWalk with those children:
var leftNode = null
var currentNodeIndex = index
_.each(oldChildren, function (child, i) {
var newChild = newChildren[i]
currentNodeIndex = (leftNode && leftNode.count)
? currentNodeIndex + leftNode.count + 1
: currentNodeIndex + 1
dfsWalk(child, newChild, currentNodeIndex, patches)
leftNode = child
})Basically what’s going on here is that as we do a depth-first
traversal left-to-right traversal of the tree we end up incrementing
this index argument for each component we encounter. The
patches for a given component are then stored in patches
under the index that corresponds to their order in that left-to-right,
depth-first traversal. This means that later on when we’re patching we
know which elements our patches correspond to without having to keep a
reference around to the elements themselves.
patchPatch is sort of the final piece of the puzzle. Here’s the entry function:
function patch (node, patches) {
var walker = {index: 0}
dfsWalk(node, walker, patches)
}Note that node here is an HTMLElement,
returned by Element.render().
Here we follow a similar strategy as we did in the diff
function with incrementing the index, but here instead we store a
variable in an object which we can mutate as we traverse the tree.
This calls out to a walker function dfsWalk which
basically just traverses the tree and calls an applyPatches
function:
function dfsWalk (node, walker, patches) {
var currentPatches = patches[walker.index]
var len = node.childNodes
? node.childNodes.length
: 0
for (var i = 0; i < len; i++) {
var child = node.childNodes[i]
walker.index++
dfsWalk(child, walker, patches)
}
if (currentPatches) {
applyPatches(node, currentPatches)
}
}Note that we get currentPatches, i.e. the patches which
apply to the current node, by grabbing
patches[walker.index].
The applyPatches function then basically switches on the
patch type and calls out to some functions which do the work:
function applyPatches (node, currentPatches) {
_.each(currentPatches, function (currentPatch) {
switch (currentPatch.type) {
case REPLACE:
var newNode = (typeof currentPatch.node === 'string')
? document.createTextNode(currentPatch.node)
: currentPatch.node.render()
node.parentNode.replaceChild(newNode, node)
break
case REORDER:
reorderChildren(node, currentPatch.moves)
break
case PROPS:
setProps(node, currentPatch.props)
break
case TEXT:
if (node.textContent) {
node.textContent = currentPatch.content
} else {
// fuck ie
node.nodeValue = currentPatch.content
}
break
default:
throw new Error('Unknown patch type ' + currentPatch.type)
}
})
}You can read the file for the rest - that’s basically how the whole thing works!