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 updated
Let’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
/ Element
The 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) {
= _.slice(arguments, 2).filter(_.truthy)
children
}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 666
Then it deals with children:
var count = 0
.each(this.children, function (child, i) {
_if (child instanceof Element) {
+= child.count
count else {
} = '' + child
children[i]
}++
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)
.appendChild(childEl)
el
})
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':
.style.cssText = value
nodebreak
case 'value':
var tagName = node.tagName || ''
= tagName.toLowerCase()
tagName if (
=== 'input' || tagName === 'textarea'
tagName
) {.value = value
nodeelse {
} // if it is not a input or textarea, use `setAttribute` to set
.setAttribute(key, value)
node
}break
default:
.setAttribute(key, value)
nodebreak
} }
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.
diff
This 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 = {
: REPLACE,
type: Element
node
}
type ReorderPatch {
: REORDER,
type: Move[]
moves
}
type Move {
: number,
index// type 0 is removing, type 1 is inserting
: 0 | 1,
type?: Element
item
}
type PropsPatch {
: PROPS,
type: Record<string, any>
props
}
type TextPatch {
: TEXT,
type: string
content
}
type Patch =
| ReplacePatch
| ReorderPatch
| PropsPatch
| TextPatch
The 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) {
.push({ type: patch.TEXT, content: newNode })
currentPatch
}// Nodes are the same, diff old node's props and children
else if (
} .tagName === newNode.tagName &&
oldNode.key === newNode.key
oldNode
) {// Diff props
var propsPatches = diffProps(oldNode, newNode)
if (propsPatches) {
.push({ type: patch.PROPS, props: propsPatches })
currentPatch
}// Diff children. If the node has a `ignore` property, do not diff children
if (!isIgnoreChildren(newNode)) {
diffChildren(
.children,
oldNode.children,
newNode,
index,
patches
currentPatch
)
}// Nodes are not the same, replace the old node with new node
else {
} .push({ type: patch.REPLACE, node: newNode })
currentPatch
}
if (currentPatch.length) {
= currentPatch
patches[index]
} }
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 = 0
In 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]
= (leftNode && leftNode.count)
currentNodeIndex ? currentNodeIndex + leftNode.count + 1
: currentNodeIndex + 1
dfsWalk(child, newChild, currentNodeIndex, patches)
= child
leftNode })
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.
patch
Patch 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]
.index++
walkerdfsWalk(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()
.parentNode.replaceChild(newNode, node)
nodebreak
case REORDER:
reorderChildren(node, currentPatch.moves)
break
case PROPS:
setProps(node, currentPatch.props)
break
case TEXT:
if (node.textContent) {
.textContent = currentPatch.content
nodeelse {
} // fuck ie
.nodeValue = currentPatch.content
node
}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!