@thi.ng/hdom
This project is part of the
@thi.ng/umbrella monorepo.
About
Lightweight UI component tree definition syntax, DOM creation and
differential updates using only vanilla JS data structures (arrays,
iterators, closures, attribute objects or objects with life cycle
functions, closures). By default targets the browser's native DOM, but
supports other arbitrary target implementations in a branch-local
manner, e.g. to define scene graphs for a canvas
element
as part of the normal UI tree.
Benefits:
- Use the full expressiveness of ES6 / TypeScript to define user interfaces
- No enforced opinion about state handling, very flexible
- Clean, functional component composition & reuse
- No source pre-processing, transpiling or string interpolation
- Less verbose than HTML / JSX, resulting in smaller file sizes
- Supports arbitrary elements (incl. SVG), attributes and events in
uniform, S-expression based syntax
- Supports branch-local custom update behaviors & arbitrary (e.g.
non-DOM) target data structures to which tree diffs are applied to
- Suitable for server-side rendering and then "hydrating" listeners and
components with life cycle methods on the client side
- Can use JSON for static components (or component templates)
- Optional user context injection (an arbitrary object/value passed to
all component functions embedded in the tree)
- Default implementation supports CSS conversion from JS objects for
style
attribs (also see:
@thi.ng/hiccup-css) - Auto-expansion of embedded values / types which implement the
IToHiccup
or
IDeref
interfaces (e.g. atoms, cursors, derived views, streams etc.) - Fast (see benchmark example below)
- Only ~5.5KB gzipped
Minimal example #1: Local state, RAF update
Live demo |
standalone example
import * as hdom from "@thi.ng/hdom";
const greeter = (_, name) => ["h1.title", "hello ", name];
const counter = (i = 0) => {
return () => ["button", { onclick: () => (i++) }, `clicks: ${i}`];
};
const app = () => {
return ["div#app", [greeter, "world"], counter(), counter(100)];
};
hdom.start(app(), { root: document.body });
hdom.renderOnce(app(), { root: document.body });
Alternatively, use the same component for browser or server side HTML
serialization (Note: does not emit attributes w/ functions as values,
e.g. a button's onclick
attrib).
import { serialize } from "@thi.ng/hiccup";
console.log(serialize(app()));
Minimal example #2: Reactive, push-based state & update
This example uses
@thi.ng/rstream
for reactive state values and the
@thi.ng/transducers-hdom
support library to perform push-based DOM updates (instead of regular
diffing via RAF).
Live demo |
standalone example
import { fromInterval, stream, sync } from "@thi.ng/rstream/stream";
import { updateDOM } from "@thi.ng/rstream/transducers-hdom";
import * as tx from "@thi.ng/rstream/transducers";
const app = ({ ticks, clicks }) =>
["div",
`${ticks} ticks & `,
["a",
{ href: "#", onclick: () => clickStream.next(0)},
`${clicks} clicks`]
];
const clickStream = stream().transform(tx.scan(tx.count(-1)));
clickStream.next(0);
sync({
src: {
ticks: fromInterval(1000),
clicks: clickStream,
},
}).transform(
tx.map(app),
updateDOM({ root: document.body })
);
Minimal example #3: Immutable app state & interceptors
This example uses
@thi.ng/interceptors
for state & event handling and to skip DOM updates completely if not
needed.
Live demo |
Source code
Live demo |
Source code (extended version)
import { Atom } from "@thi.ng/atom";
import { start } from "@thi.ng/hdom";
import { choices } from "@thi.ng/transducers";
import * as icep from "@thi.ng/interceptors";
const colors = choices(["cyan", "yellow", "magenta", "chartreuse"]);
const state = new Atom({});
const bus = new icep.EventBus(state, {
"init": () => ({
[icep.FX_STATE]: { clicks: 0, color: "grey" }
}),
"inc-counter": [
icep.valueUpdater("clicks", (x: number) => x + 1),
icep.dispatchNow(["randomize-color"])
],
"randomize-color": icep.valueUpdater(
"color", () => colors.next().value
)
});
start(
({ bus, state }) => bus.processQueue() ?
["button",
{
style: {
padding: "1rem",
background: state.value.color
},
onclick: () => bus.dispatch(["inc-counter"])
},
`clicks: ${state.value.clicks}`] :
null,
{ ctx: { state, bus } }
);
bus.dispatch(["init"]);
Minimal example #4: Canvas scene tree / branch-local behavior
This example uses the
@thi.ng/hdom-canvas
component to support the inclusion of (virtual / non-DOM targets) shape
elements as part of the normal HTML component tree. A description of the
actual mechanism can be found further below and in the hdom-canvas
readme. In short, all canvas child elements will be translated into
canvas API draw calls.
Related examples:
import { start } from "@thi.ng/hdom";
import { canvas } from "@thi.ng/hdom-canvas";
start(() =>
["div",
["h1", "Hello hdom"],
[canvas, { width: 300, height: 300 },
["g", { stroke: "none", translate: [50, 50] },
["circle", { fill: "red" },
[0, 0], 25 + 25 * Math.sin(Date.now() * 0.001)],
["text", { fill: "#fff", align: "center", baseline: "middle" },
[0, 0], "Hello"]
]
]
]
);
How it works
The hdom data flow
The usual hdom update process is as follows: First the user app creates
an up-to-date UI component tree, which is then passed to hdom, will be
normalized (expanded into a canonical format) and then used to
recursively compute the minimal edit set of the difference to the
previous DOM tree.
Important:
- hdom uses a RAF render loop only by default, but is in absolutely no
way tied to this (see
@thi.ng/transducers-hdom
for a possible alternative)
- hdom uses the browser DOM only by default, but supports custom target
implementations, which can modify other target data structures. These
custom implementations can be triggered on branch-local basis in the
tree
- hdom NEVER tracks the real DOM, only its own trees (previous & current)
- hdom can be used without diffing, i.e. for compact, one-off DOM
creation (see
renderOnce()
)
The syntax is inspired by Clojure's
Hiccup and
Reagent projects, which themselves
were influenced by prior art by Phil
Wadler
at Edinburgh University, who pioneered this approach in Lisp back in
1999. hdom offers several additional features to these established
approaches.
hdom components are usually nested, vanilla ES6 data structures without
any custom syntax, organized in an S-expression-like manner. This makes
them very amenable to being constructed inline, composed, transformed or
instrumented using the many ES6 language options available.
Nested arrays
No matter what the initial supported input format was, all components /
elements will eventually be transformed into a tree of nested arrays.
See normalizeTree()
further down for details.
The first element of each array is used as tag and if the 2nd
element is a plain object, it will be used to define arbitrary
attributes and event listeners for that element. All further elements
are considered children of the current element.
Emmet-style tags with ID and/or classes are supported.
["section#foo.bar.baz",
["h3", { class: "title" }, "Hello world!"]]
Equivalent HTML:
<section id="foo" class="bar baz">
<h3 class="title">Hello World!</h3>
</section>
Attribute objects
Attributes objects are optional, but if present always given as the 2nd
element in an element array and are used to define arbitrary attributes,
CSS properties and event listeners. The latter always have to be
prefixed with on
and their values always must be functions
(naturally). CSS props are assigned to the style
attribute, but given
as JS object.
["a", {
href: "#",
onclick: (e) => (e.preventDefault(), alert("hi")),
style: {
background: "#000",
color: "#fff",
padding: "1rem",
margin: "0.25rem"
}
}, "Say Hi"]
<a href="#" style="background:#000;color:#fff;padding:1rem;margin:0.25rem;">Say Hi</a>
With the exception of event listeners (which are always functions),
others attribute values can be functions too and if so will be called
with the entire attributes object as sole argument and their return
value used as actual attribute value. Same goes for CSS property
function values (which receive the entire style
object). In both
cases, this supports the creation of derived values based on other
attribs:
const btAttribs = {
onclick: (e)=> alert(e.target.id),
class: (attr) => `bt bt-${attr.id}`,
href: (attr) => `#${attr.id}`,
};
["div",
["a#foo", btAttribs, "Foo"],
["button#bar", btAttribs, "Bar"]]
<div>
<a id="foo" class="bt bt-foo" href="#foo">Foo</a>
<button id="bar" class="bt bt-bar" href="#bar">Bar</button>
</div>
Pure functions and/or closures
["ul#users", ["alice", "bob", "charlie"].map((x) => ["li", x])]
const unorderedList = (_, attribs, ...items) =>
["ul", attribs, ...items.map((x)=> ["li", x])];
[unorderedList, { id: "users"}, "alice", "bob", "charlie"]
<ul id="users">
<li>alice</li>
<li>bob</li>
<li>charlie</li>
</ul>
Functions used in the "tag" (head) position of an element array are
treated as delayed execution mechanism and will only be called and
recursively expanded during tree normalization with the remaining array
elements passed as arguments. These component functions also receive an
arbitrary user context object (not used for these
examples here) as additional first argument.
const iconButton = (_, icon, onclick, label) =>
["a.bt", { onclick }, ["i", {class: `fas fa-${icon}`}], label];
const alignButton = (_, type) =>
[iconButton, `align-${type}`, () => alert(type), type];
["div",
{ style: { padding: "1rem" } },
[alignButton, "left"],
[alignButton, "center"],
[alignButton, "right"]]
<div style="padding:1rem;">
<a class="bt"><i class="fas fa-align-left"></i>left</a>
<a class="bt"><i class="fas fa-align-center"></i>center</a>
<a class="bt"><i class="fas fa-align-right"></i>right</a>
</div>
Functions in other positions of an element array are also supported but
only receive the optional user context object as attribute.
const now = () => new Date().toLocaleString();
["footer", "Current date: ", now]
<footer>Current date: 9/22/2018, 1:46:41 PM</footer>
Iterators
ES6 iterables are supported out of the box and their use is encouraged
to avoid the unnecessary allocation of temporary objects caused by
chained application of Array.map()
to transform raw state values into
components. However, since iterators can only be consumed once, please
see this issue
comment
for potential pitfalls.
The
@thi.ng/transducers
package provides 130+ functions to create, compose and work with
iterator based pipelines. These are very powerful & handy for component
construction as well!
import { map, range } from "@thi.ng/transducers";
["ul", map((i) => ["li", i + 1], range(3))]
<ul>
<li>1</li>
<li>2</li>
<li>3</li>
</ul>
Interface support
Any type implementing one of the
IToHiccup
or
IDeref
or interfaces will be auto-expanded during tree normalization.
This currently includes the following types from other packages in this
repo, but also any user defined custom types:
class Foo {
constructor(val) {
this.value = val;
}
deref() {
return ["div.deref", this.value];
}
}
class Bar {
constructor(val) {
this.value = val;
}
toHiccup(ctx) {
return ["div.hiccup", ctx && ctx.foo, this.value];
}
}
serialize(
["div", new Foo(23), new Bar(42)],
{
foo: { class: "bg-lightest-blue navy pa2 ma0" }
}
);
<div>
<div class="deref">23</div>
<div class="bg-lightest-blue navy pa2 ma0 hiccup">42</div>
</div>
Component objects with life cycle methods
Most components can be succinctly expressed via the options discussed so
far, though for some use cases we need to get a handle on the actual
underlying DOM element and can only fully initialize the component once
it's been mounted etc. For those cases components can be specified as
classes or plain objects implementing the following interface:
interface ILifecycle {
init?(el: Element, ctx: any, ...args: any[]);
render(ctx: any, ...args: any[]): any;
release?(ctx: any, ...args: any[]);
}
When the component is first used the order of execution is: render
->
init
. The release
method is only called when the component has been
removed / replaced (basically, if it's not present in the new tree
anymore). The release
implementation should NOT manually call
release()
on any children, since that's already been handled by hdom's
diffTree()
.
Any remaining arguments are sourced from the component call site as
this simple example demonstrates:
const canvas = () => {
return {
init: (el, ctx, { width, height }, msg, color = "red") => {
const c = el.getContext("2d");
c.fillStyle = color;
c.fillRect(0, 0, width, height);
c.fillStyle = "white";
c.textAlign = "center";
c.fillText(msg, width / 2, height / 2);
},
render: (ctx, attribs) => ["canvas", attribs],
};
};
start(
[canvas(), { width: 100, height: 100 }, "Hello world"],
);
const app = () => {
const c1 = canvas();
const c2 = canvas();
return () =>
["div",
[c1, { width: 100, height: 100 }, "Hello world"],
[c2, { width: 100, height: 100 }, "Goodbye world", "blue"]
];
};
start(app());
Event & state handling options
Since this package is purely dealing with the translation of component
trees, any form of state / event handling or routing required by a full
app is out of scope. These features are provided by the following
packages and can be used in a mix & match manner. Since hdom components
are just plain functions/arrays, any solution can be used in
general.
Reusable components
A currently small (but growing) number of reusable components are
provided by these packages:
Status
Stable, in active development. The project has been used for several
projects in production since early 2016.
Example projects
Most of the approx. 30
examples
included in this repo are using this package in one way or another.
Please check them out to learn more. Each is heavily commented, incl.
some best practice notes.
Non-exhaustive list:
Realtime crypto candle chart
Source |
Live version
Git commit log table
Source |
Live version
XML/HTML/SVG to Hiccup converter
Source |
Live version
Interactive SVG grid generator
Source |
Live version
Interactive additive waveform visualization
Source |
Live version
Dataflow graph SVG components
This is a preview of the WIP
@thi.ng/estuary
package:
Source
| Live version
Mouse gesture analysis
Source
| Live version
Canvas based radial dial input widget
Source |
Live version
SPA with router and event bus
Based on the create-hdom-app
project scaffolding...
Source
| Live version
XML/HTML to Hiccup syntax converter
Source
| Live version
Installation
yarn add @thi.ng/hdom
You can use the
create-hdom-app project
generator to create one of several pre-configured app skeletons using
features from @thi.ng/atom, @thi.ng/hdom, @thi.ng/interceptors &
@thi.ng/router. Presets using @thi.ng/rstream for reactive state
handling will be added in the near future.
yarn create hdom-app my-app
cd my-app
yarn install
yarn start
Dependencies
API & Usage
Even though the overall approach should be obvious from the various
examples in this document, it's still recommended to also study the
@thi.ng/hiccup
reference to learn more about other possible syntax options to define
components. Both projects started in early 2016 and have somewhat
evolved independently, however should be considered complementary.
start()
Params:
tree: any
opts?: Partial<HDOMOpts>
impl?: HDOMImplementation
Main user function. For most use cases, this function should be the only
one required in user code. It takes an hiccup tree (array, function or
component object w/ life cycle methods) and an optional object of DOM
update
options
(also see section below), as well as an optional HDOMImplementation
.
If the latter is not given, the DEFAULT_IMPL
will be used, which
targets the browser DOM. Unless you want to create your own custom
implementation, this should never be changed.
Starts RAF update loop, in each iteration first normalizing given tree,
then computing diff to previous frame's tree and applying any changes to
the real DOM. The ctx
option can be used for passing arbitrary config
data or state down into the hiccup component tree. Any embedded
component function in the tree will receive this context object as first
argument, as will life cycle methods in component objects. See context
description further below.
Selective updates
No updates will be applied if the current hiccup tree normalizes to
undefined
or null
, e.g. a root component function returning no
value. This way a given root component function can do some state
handling of its own and implement fail-fast checks and determine that no
DOM updates are necessary, saving effort re-creating a new hiccup tree
and request skipping DOM updates via this convention. In this case, the
previous DOM tree is kept around until the root function returns a valid
tree again, which then is diffed and applied against the previous tree
kept, as usual. Any number of frames may be skipped this way. This
pattern is often used when working with the @thi.ng/interceptors
EventBus
.
Important: Unless the hydrate
option is enabled, the parent
element given is assumed to have NO children at the time when start()
is called. Since hdom does NOT track the real DOM, the resulting changes
will result in potentially undefined behavior if the parent element
wasn't empty. Likewise, if hydrate
is enabled, it is assumed that an
equivalent DOM (minus listeners) already exists (i.e. generated via SSR)
when start()
is called. Any other discrepancies between the
pre-existing DOM and the hdom trees will cause undefined behavior.
start
returns a function, which when called, immediately cancels the
update loop.
renderOnce()
One-off hdom tree conversion & target / DOM application. Takes same args
as start()
, but performs no diffing and only creates or hydrates
target (DOM) once. The given tree is first normalized and no further
action will be taken, if the normalized result is null
or undefined
.
HDOMOpts config options
Config options object passed to hdom's start()
, renderOnce()
or
@thi.ng/transducers-hdom's
updateDOM()
:
root
: Root element or ID (default: "app")ctx
: Arbitrary user context object, passed to all component
functions embedded in the tree (see below)keys
: If true (default), each elements will receive an
auto-generated key
attribute (unless one already exists).span
: If true (default), all text content will be wrapped in
<span>
elements. Spans will never be created inside <button>
,
<option>
, <textarea>
or <text>
elements.hydrate
: If true (default false), the first frame will only be used
to inject event listeners, using the hydrateDOM()
function.
Important: Enabling this option assumes that an equivalent DOM
(minus event listeners) already exists (e.g. generated via SSR /
hiccup's serialize()
) when hdom's start()
function is called. Any
other discrepancies between the pre-existing DOM and the hdom trees
will cause undefined behavior.
HDOMImplementation interface
The following functions are the core parts of the HDOMImplementation
interface, the abstraction layer used by hdom to support different
targets.
normalizeTree()
Normalizes given hdom tree, expands Emmet-style tags, embedded
iterables, component functions, component objects with life cycle
methods and injects key
attributes for diffTree()
to later identify
changes in nesting order. During normalization any embedded component
functions are called with the given (optional) user ctx
object as
first argument. For further details of the default implementation,
please see normalizeTree()
in normalize.ts
.
Implementations MUST check for the presence of the __impl
control
attribute on each branch. If given, the current implementation MUST
delegate to the normalizeTree()
method of the specified implementation
and not descent into that branch further itself.
Furthermore, if (and only if) an element has the __normalize
control
attrib set to false
, the normalization of that element's children MUST
be skipped. Calling this function is a prerequisite before passing a
component tree to diffTree()
. Recursively expands given hiccup
component tree into its canonical form:
["tag", { attribs }, ...body]
- resolves Emmet-style tags (e.g. from
div#id.foo.bar
) - adds missing attribute objects (and
key
attribs) - merges Emmet-style classes with additional
class
attrib values (if
given), e.g. ["div.foo", { class: "bar" }]
=> ["div", { class: "bar foo" }]
- evaluates embedded functions and replaces them with their result
- calls the
render
life cycle method on component objects and uses
result - consumes iterables and normalizes their individual values
- calls
deref()
on elements implementing the IDeref
interface and
uses returned results - calls
toHiccup()
on elements implementing the IToHiccup
interface
and uses returned results - calls
.toString()
on any other non-component value and by default
wraps it in ["span", x]
. The only exceptions to this are: button
,
option
, textarea
and SVG text
elements, for which spans are
never created.
Additionally, unless the keys
option is explicitly set to false, an
unique key
attribute is created for each node in the tree. This
attribute is used by diffTree
to determine if a changed node can be
patched or will need to be moved, replaced or removed.
In terms of life cycle methods: render
should ALWAYS return an array
or another function, else the component's init
or release
fns will
NOT be able to be called. E.g. If the return value of render
evaluates
as a string or number, it should be wrapped as ["span", "foo"]
or an
equivalent wrapper node. If no init
or release
methods are used,
this requirement is relaxed.
See normalizeElement
(normalize.ts) for further details about the
canonical element form.
diffTree()
Takes an HDOMOpts
options object, an HDOMImplementation
and two
normalized hiccup trees, prev
and curr
. Recursively computes diff
between both trees and applies any necessary changes to reflect curr
tree, based on the differences to prev
, in target (browser DOM when
using the DEFAULT_IMPL
implementation).
All target modification operations are delegated to the given
implementation. diffTree()
merely manages which elements or attributes
need to be created, updated or removed and this NEVER involves any form
of tracking of the actual underlying target data structure (e.g. the
real browser DOM). hdom in general and diffTree()
specifically are
stateless. The only state available is that of the two trees given (prev
/ curr).
Implementations MUST check for the presence of the __impl
control
attribute on each branch. If given, the current implementation MUST
delegate to the diffTree()
method of the specified implementation and
not descent into that branch further itself.
Furthermore, if (and only if) an element has the __diff
control
attribute set to false
, then:
- Computing the difference between old & new branch MUST be skipped
- The implementation MUST recursively call any
release
life cycle
methods present anywhere in the current prev
tree (branch). The
recursive release process itself is implemented by the exported
releaseDeep()
function in diff.ts
. Custom implementations are
encouraged to reuse this, since that function also takes care of
handling the __release
attrib: if the attrib is present and set to
false, releaseDeep()
will not descend into the branch any further. - Call the current implementation's
replaceChild()
method to replace
the old element / branch with the new one.
createTree()
Realizes the given hdom tree in the target below the parent
node, e.g.
in the case of the browser DOM, creates all required DOM elements
encoded by the given hdom tree. If parent
is null the result tree
won't be attached to any parent. If insert
is given, the new elements
will be inserted at given child index.
For any components with init
life cycle methods, the implementation
MUST call init
with the created element, the user provided context
(obtained from opts
) and any other args. createTree()
returns the
created root element(s) - usually only a single one, but can be an array
of elements, if the provided tree is an iterable of multiple roots. The
default implementation creates text nodes for non-component values.
Returns parent
if tree is null
or undefined
.
Implementations MUST check for the presence of the __impl
control
attribute on each branch. If given, the current implementation MUST
delegate to the createTree()
method of the specified implementation
and not descent into that branch further itself.
hydrateTree()
Takes a target root element and normalized hdom tree, then walks tree
and initializes any event listeners and components with life cycle
init
methods. Assumes that an equivalent "DOM" (minus listeners)
already exists when this function is called. Any other discrepancies
between the pre-existing DOM and the hdom tree might cause undefined
behavior.
Implementations MUST check for the presence of the __impl
control
attribute on each branch. If given, the current implementation MUST
delegate to the hydrateTree()
method of the specified implementation
and not descent into that branch further itself.
User context
hdom offers support for an arbitrary "context" object passed to
start()
, which will be automatically injected as argument to all
embedded component functions anywhere in the tree. This avoids having to
manually pass down configuration data into each child component and so
can simplify many use cases, e.g. event dispatch, style / theme
information, global state etc.
import { start } from "@thi.ng/hdom";
import { Event, EventBus } from "@thi.ng/interceptors";
type AppContext = {
bus: EventBus,
ui: { link: string, list: string }
};
type LinkSpec = [Event, any];
const ctx: AppContext = {
bus: new EventBus(),
ui: {
link: "fw7 blue link dim pointer",
list: "list center tc"
}
};
const eventLink = (ctx: AppContext, evt: Event, ...body: any[]) =>
["a",
{
class: ctx.ui.link,
onclick: () => ctx.bus.dispatch(evt),
},
...body];
const linkList = (ctx: AppContext, ...links: LinkSpec[]) =>
["ul", { class: ctx.ui.list },
links.map((l) => ["li", [eventLink, ...l]])];
const app = [
linkList,
[["handle-login"], "Login"],
[["external-link", "http://thi.ng"], "thi.ng"],
];
start(app, { ctx });
Behavior control attributes
The following special attributes can be added to elements to control the
branch-local behavior of the hdom implementation:
__impl
If present, the element and all of its children will be processed by the
given implementation of the HDOMImplementation
interface. Currently,
@thi.ng/hdom-canvas
is the only example of a component using this feature.
__diff
If true (default), the element will be fully processed by diffTree()
.
If false, no diff will be computed and the replaceChild()
operation
will be called in the currently active hdom target implementation.
__normalize
If false
, the current element's children will not be normalized. Use
this when you're sure that all children are already in canonical format
(incl. key
attributes). See normalizeTree()
for details.
__release
If false
, hdom will not attempt to call release()
lifecycle methods
on this element or any of its children.
__serialize
@thi.ng/hiccup
only. If false
, this element and its children will be omitted from the
serialized output.
Benchmark
A stress test benchmark is here:
/examples/benchmark
Live version
Based on user feedback collected via
Twitter,
performance should be more than acceptable for even quite demanding UIs.
In the 192 / 256 cells configurations this stress test causes approx.
600 / 800 DOM every single frame, very unlikely for a typical web app.
In Chrome 68 on a MBP2016 this still runs at a stable 60fps (192 cells)
/ 35fps (256 cells). Both FPS readings are based the 50 frame
SMA.
Authors
License
© 2016 - 2018 Karsten Schmidt // Apache Software License 2.0