@qnc/vdom
v1.0.1
Published
ultra-minimal vdom
Readme
qnc_vdom
Goals
- to support easy, declarative DOM creation and patching
- to be small/simple enough that it's sensible to use us for even the smallest DOM creation/patching tasks
- to guarantee a stable API so that users can safely add us a PEER dependency
- to support the maniuplation of DOM elements inside another wnidow (eg. iframe)
- never move document.activeElement; move other elements around it
Non-goals
- automatic re-rendering (aka "mounting")
- routing
- jsx
- provide multiple ways of doing the same thing
Performance
Our initial performance testing (performance_tests/browser_performance_test.html) suggests that we are in between mithril.js and React for speed (in terms of initial render and updates).
Importan Types
VirtualElement
VirtualElements are lightweight js objects describing an Element.
You'll create VirtualElements using one of our factory functions (h for HTMLElements and s for SVGElements).
VirtuaElements are actually a generic type. They "know" which type of Element they create. E.g. a VirtualElement<HTMLButtonElement> creates a <button></button> element. To simplifiy this readme, we'll often ignore the fact that VirtualElements are generic.
VirtualElement is an opaque type. You are not meant to interact with any of its properties. You are only meant to generate them and then pass them to our functions.
VirtualNode
A VirtualNode corresponds to a single Node.
Conceptually:
export type VirtualNode = number | string | Node | VirtualElement;Note that our actual definition for VirtualNode is slightly more complex because VirtualElement is a generic type, whereas VirtualNode is concrete.
Note that real DOM Nodes are valid VirtualNodes. See the Node Injection section for details.
VirtualFragment
VirtualFragment can represent any possible mix of text nodes and Elements. It is any mix of strings, numbers (treated as strings), pre-defined Nodes, VirtualElements, and arrays thereof. Conceptually, it is:
export type VirtualFragment = null | false | VirtualNode | VirtualFragment[];Note that both null and false values are ignored.
Node Injection
Notice that Node is a valid type of VirtualNode / VirtualFragment. This means you can "inject" pre-existing Nodes (which have been created by whatever means you want) into our managed DOM trees. Such nodes get injected without any modification to their attributes/properties/children.
Node Injection makes it trivial to use us alongside other tools/frameworks/libraries.
We believe this feature is unique amongst vdom libraries.
Pre-rendering / Server-side rendering
Calls to patch will happily reuse/patch "pre-rendered" elements, so long as those elements are compatible with the VirtualElement that is meant to replace them (and those elements were not "injected" via a previous patch/create_element/create_fragment operation). Eg:
document.body.innerHTML = '<div></div>' // or the <body> could have been defined by initial html
patch(document.body, {}, h('div', {}, 'hello'))In this snippet, the patch call will reuse the existing div, and append a text node to it.
Life Cycle Callbacks
The only lifecycle callback we support so far is on_create. This one is trivial to add, with minimal code changes. It's also the only lifecycle callback you'll need 99% of the time.
If you find you need another life cycle method (eg. after the element is connected to the document, or after the element is removed), please let us know and explain your use case. We may consider adding more lifecycle callbacks later.
on_create
This callback is called when an element is first created for a VirtualElement, after its attributes and properties have been set, but before its children have been added and before it is added to its parent element.
Element Reuse / Keys
When you patch an element, we try to reuse and patch its existing children, when possible.
During a patch operation, the existing child elements and the virtual elements all have "reuse keys". These keys are generated from a combination of:
- the "element base type" (html/svg)
- the element tag name
- the
keyfrom the original VirtualElement
#1 and #2 are required, because there is no DOM API which allows you to change the type of an Element. If you have a div on the first render and then you want a p on the second render, it's impossible to reuse the div child. We have to remove the div and create the p.
#3 allows you to:
- force elements to be reordered
- force the removal of one element and creation of a similar element (eg. when input values, scroll position, focus state, etc. should be reset)
Note that unlike other vdom libraries, we allow:
- a mix of keyed and unkeyed VirtualElements within the same parent
- multiple children of the same parent to use the same key
TODO: detailed samples for users who aren't familiar with vdom keys already.
TODO: sample demonstrating how "injected nodes" are not reused
API
API Overview
h: A factory function for creating VirtualElements representing HTMLElements. You'll use this a lot.s: A factory function for creating VirtualElements representing SVGElements. You'll only need this if you're managing inline SVG elements.patch: Update a given element in-place to match a specified virtual DOM tree. This is your main "render" / "update" / "redraw" function.create_element: Create a new element from a VirtualElement. Useful when you want to perform further actions on the result and then place it in the DOM manually.create_fragment: Create a DocumentFragment from VirtualFragment.get_prefix_nodes/get_suffix_nodes: Helper functions for use with Partial Element Updating
h
Factory function for creating VirtualElements representing HTML elements.
Signature:
// signature 1: for built-in HTMLElements, the resulting VirtualElement knows exactly which sub-type of HTMLElement is created (eg. *VirtualElement<HTMLInputElement>*)
function h<K extends keyof HTMLElementTagNameMap>(
tag: K,
options: { key?, attrs?, props?, on_create? },
...children: VirtualFragment[]
): VirtualElement<HTMLElementTagNameMap[K]>
// signature 2: for unknown/custom elements, the result is the more loosely typed *VirtualElement<HTMLElement>*
function h(
tag: string,
options: { key?, attrs?, props?, on_create? },
...children: VirtualFragment[]
): VirtualElement<Element>Parameters:
tag: The HTML tag name (e.g.'div','button','input').options: Configuration object (all properties optional):key: A string to help guide the reuse of elements during patch operations. See the Element Reuse / Keys section.attrs: Record of HTML attributes to set. Values can benullto remove an attribute.props: Record of DOM properties to set (e.g.{ checked: true, value: 'text' }).on_create: Callback invoked when the element is first created, after attributes/props are set but before children are added.
...children: Zero or more VirtualFragment children. Can be nested arrays, strings, numbers, Nodes, or VirtualElements.
Example:
const button = h('button',
{
attrs: { id: 'submit-btn', class: 'primary' },
props: { disabled: false },
on_create: (button: HTMLButtonElement) => console.log('Button created!'),
key: 'submit'
},
'Click me'
);
const input = h('input',
{
attrs: { type: 'text', placeholder: 'Enter name', value: 'John Doe' },
props: { oninput: function(this: HTMLInputElement, event: InputEvent) {console.log(this.value)} },
},
);
const list = h('ul', {}, [
h('li', {}, 'Item 1'),
h('li', {}, 'Item 2'),
]);s
Signature:
s(
tag: string,
options: { key?, attrs?, props?, on_create? },
...children: VirtualFragment[]
): VirtualElement<SVGElement>This is identical to h, except that it creates VirtualElement<SVGElement> instances (ie for in-line svg content; eg svg, path, etc.).
Example:
const svg = s('svg',
{ attrs: { viewBox: '0 0 100 100', width: '200', height: '200' } },
s('rect', {
attrs: { x: '10', y: '10', width: '80', height: '80', fill: 'green' }
})
);Why separate from h?
- svg elements cannot be created via
document.createElement, so VirtualElement needs to differentiate between SVGElement and HTMLElement - both HTML and SVG have overlapping tag names (e.g.
a,script,style), so we can't determine solely by tag name - we could have a single function and use an optional
type: 'html' | 'svg'attribute in the options object, but that makes svg element creation more verbose
patch
Update an existing DOM element in-place to match the specified attributes, properties, and children.
This is your primary "render" or "update" function. You can call patch multiple times on the same element as your app state changes.
Signature:
patch(
element: Element,
options: { attrs?, props? },
...desired_children: VirtualFragment[]
): voidParameters:
element: The DOM element to update (must already exist in the DOM).options: Configuration object (all properties optional):attrs: Record of attributes to set. Passnullto remove an attribute. Any existing attributes not mentioned remain unchanged.props: Record of properties to set. Any existing properties not mentioned remain unchanged.
...desired_children: Zero or more VirtualFragments. Existing children will be reused where possible, with new children created and unwanted children removed.
Example:
const container = document.getElementById('app')!;
// Initial render
patch(
container,
{
attrs: {class: 'intro'},
},
h('h1', {}, 'Hello'),
h('p', {}, 'Welcome!')
);create_element
Create a new DOM element from a VirtualElement.
Useful when you want to want to manipulate the created element further before you manually insert it into the document, or when you want to pass the created element to other libraries/functions.
Signature:
create_element<E extends Element>(virtual_element: VirtualElement<E>): EExample:
const virtual_element = h('div',
{ attrs: { id: 'my-div', class: 'container' } },
h('p', {}, 'Hello world')
);
const element = create_element(virtual_element);
console.log(element instanceof HTMLDivElement); // true
console.log(element.id); // 'my-div'
// Now you can perform further actions before inserting it into the DOM
element.style.color = 'red';
document.body.appendChild(element);create_fragment
Create a DocumentFragment from VirtualFragment content.
We don't foresee many use cases for this, but it was trivial to add.
Signature:
create_fragment(...content: VirtualFragment[]): DocumentFragmentExample:
const fragment = create_fragment(
h('li', {}, 'First'),
h('li', {}, 'Second'),
h('li', {}, 'Third')
);
const list = document.getElementById('my-list')!;
list.appendChild(fragment); // All three <li> elements are insertedget_prefix_nodes
Returns an array of all of a container's child nodes up to and including a last_prefix_node. If last_prefix_node is not found in container, then returns all of container's child nodes.
See the Partial Element Updating recipe for the use case and sample code.
Signature:
get_prefix_nodes(container: Node, last_prefix_node: Node): Node[]get_suffix_nodes
Returns an array of all of a container's child nodes from first_suffix_node through its last child. If first_suffix_node is not found in container, then returns an empty array.
See the Partial Element Updating recipe for the use case and sample code.
Signature:
get_suffix_nodes(container: Node, first_suffix_node: Node): Node[]Recipes
Partial Element Updating
Suppose you have a managed "app" inside the body element, and your app consists of multiple elements (and you don't want to add a wrapper element around them). Further, suppose you have some content inside the body element before your app (ie "prefix nodes"), and some content after your app (ie "suffix nodes").
You can still patch the body element by doing:
patch(
document.body,
{},
get_fixed_prefix_nodes(),
render_my_app(),
get_fixed_suffix_nodes(),
)How you implement get_fixed_prefix_nodes and get_fixed_suffix_nodes is up to you. If you have a reference to to the last prefix node (and/or the first suffix node), then you can make use of our get_prefix_nodes (and/or get_suffix_nodes) helper function:
patch(
document.body,
{},
get_prefix_nodes(document.body, last_prefix_node),
render_my_app(),
get_suffix_nodes(document.body, first_suffix_node),
)