domvm

domvm (DOM ViewModel)domvm logo

A thin, fast, dependency-free vdom view layer (MIT Licensed)


Introduction

domvm is a flexible, pure-js view layer for building high performance web applications. Like jQuery, it’ll happily fit into any existing codebase without introducing new tooling or requiring major architectural changes.

To use domvm you should be comfortable with JavaScript and the DOM; the following code should be fairly self-explanatory:

var el = domvm.defineElement,
    cv = domvm.createView;

var HelloView = {
    render: function(vm, data) {
        return el("h1", {style: "color: red;"}, "Hello " + data.name);
    }
};

var data = {name: "Leon"};

var vm = cv(HelloView, data).mount(document.body);

Demo Playground

demo playground


Documentation


What domvm Is Not

As a view layer, domvm does not include some things you would find in a larger framework. This gives you the freedom to choose libs you already know or prefer for common tasks. domvm provides a small, common surface for integration of routers, streams and immutable libs. Some minimalist libs that work well:

Many /demos are examples of how to use these libs in your apps.


Builds

domvm comes in several builds of increasing size and features. The nano build is a good starting point and is sufficient for most cases.


Changelog

Changes between versions are documented in Releases.


Tests


Installation

Browser

<script src="dist/nano/domvm.nano.iife.min.js"></script>

Node

var domvm = require("domvm");   // the "full" build

DEVMODE

If you’re new to domvm, the dev build is recommended for development & learning to avoid common mistakes; watch the console for warnings and advice.

There are a couple config options:

Due to the runtime nature of DEVMODE heuristics, some warnings may be false positives (where the observed behavior is intentional). If you feel an error message can be improved, open an issue!

While not DEVMODE-specific, you may find it useful to toggle always-sychronous redraw during testing and benchmarks:

domvm.cfg({
    syncRedraw: true
});

Templates

Most of your domvm code will consist of templates for creating virtual-dom trees, which in turn are used to render and redraw the DOM. domvm exposes several factory functions to get this done. Commonly this is called hyperscript.

For convenience, we’ll alias each factory function with a short variable:

var el = domvm.defineElement,
    tx = domvm.defineText,
    cm = domvm.defineComment,
    sv = domvm.defineSvgElement,
    vw = domvm.defineView,
    iv = domvm.injectView,
    ie = domvm.injectElement,
    cv = domvm.createView;

Using defineText is not required since domvm will convert all numbers and strings into defineText vnodes automatically.

Below is a dense reference of most template semantics.

el("p", "Hello")                                            // plain tags
el("textarea[rows=10]#foo.bar.baz", "Hello")                // attr, id & class shorthands
el(".kitty", "Hello")                                       // "div" can be omitted from tags

el("input",  {type: "checkbox",    checked: true})          // boolean attrs
el("input",  {type: "checkbox", ".checked": true})          // set property instead of attr

el("button", {onclick: myFn}, "Hello")                      // event handlers
el("button", {onclick: [myFn, arg1, arg2]}, "Hello")        // parameterized

el("p",      {style: "font-size: 10pt;"}, "Hello")          // style can be a string
el("p",      {style: {fontSize: "10pt"}}, "Hello")          // or an object (camelCase only)
el("div",    {style: {width: 35}},        "Hello")          // "px" will be added when needed

el("h1", [                                                  // attrs object is optional
    el("em", "Important!"),
    "foo", 123,                                             // plain values
    ie(myElement),                                          // inject existing DOM nodes
    el("br"),                                               // void tags without content
    "", [], null, undefined, false,                         // these will be auto-removed
    NaN, true, {}, Infinity,                                // these will be coerced to strings
    [                                                       // nested arrays will get flattened
        el(".foo", {class: "bar"}, [                        // short & attr class get merged: .foo.bar
            "Baz",
            el("hr"),
        ])
    ],
])

el("#ui", [
    vw(NavBarView, navbar),                                 // sub-view w/data
    vw(PanelView, panel, "panelA"),                         // sub-view w/data & key
    iv(someOtherVM, newData),                               // injected external ViewModel
])

// special _* props

el("p", {_key: "myParag"}, "Some text")                     // keyed nodes
el("p", {_data: {foo: 123}}, "Some text")                   // per-node data (faster than attr)

el("p", {_ref: "myParag"}, "Some text")                     // named refs (vm.refs.myParag)
el("p", {_ref: "pets.james"}, "Some text")                  // namespaced (vm.refs.pets.james)

el("p", {_hooks: {willRemove: ...}}, "Some text")           // lifecycle hooks

el("div", {_flags: ...}, "Some text")                       // optimization flags

Spread children

micro+ builds additionally provide two factories for defining child elements using a ...children spread rather than an explicit array.

var el = domvm.defineElementSpread,
    sv = domvm.defineSvgElementSpread;

el("ul",
    el("li", 1),
    el("li", 2),
    el("li", 3)
);

JSX

While not all of domvm’s features can be accommodated by JSX syntax, it’s possible to cover a fairly large subset via a defineElementSpread pragma. Please refer to demos and examples in the JSX wiki.


Views

What React calls “components”, domvm calls “views”. A view definition can be a plain object or a named closure (for isolated working scope, internal view state or helper functions). The closure must return a template-generating render function or an object containing the same:

var el = domvm.defineElement;

function MyView(vm) {                                       // named view closure
    return function() {                                         // render()
        return el("div", "Hello World!");                           // template
    };
}

function YourView(vm) {
    return {
        render: function() {
            return el("div", "Hello World!");
        }
    };
}

var SomeView = {
    init: function(vm) {
        // ...
    },
    render: function() {
        return el("div", "Hello World!");
    }
};

Views can accept external data to render (à la React’s props):

function MyView(vm) {
    return function(vm, data) {
        return el("div", "Hello " + data.firstName + "!");
    };
}

vm is this views’s ViewModel; it’s the created instance of MyView and serves the same purpose as this within an ES6 React component. The vm provides the control surface/API to this view and can expose a user-defined API for external view manipulation.

Rendering a view to the DOM is called mounting. To mount a top-level view, we create it from a view definition:

var data = {
    firstName: "Leon"
};

var vm = cv(MyView, data);

vm.mount(document.body);            // appends into target

By default, .mount(container) will append the view into the container. Alternatively, to use an existing placeholder element:

var placeholder = document.getElementById("widget");

vm.mount(placeholder, true);        // empties & assimilates placeholder

When your data changes, you can request to redraw the view, optionally passing a boolean sync flag to force a synchronous redraw.

vm.redraw(sync);

If you need to replace a view’s data (as with immutable structures), you should use vm.update, which will also redraw.

vm.update(newData, sync);

Views can be nested either declaratively or by injecting an already-initialized view:

var el = domvm.defineElement,
    vw = domvm.defineView,
    iv = domvm.injectView;

function ViewA(vm) {
    return function(vm, dataA) {
        return el("div", [
            el("strong", dataA.test),
            vw(ViewB, dataA.dataB),               // implicit/declarative view
            iv(data.viewC),                       // injected explicit view
        ]);
    };
}

function ViewB(vm) {
    return function(vm, dataB) {
        return el("em", dataB.test2);
    };
}

function ViewC(vm) {
    return function(vm, dataC) {
        return el("em", dataC.test3);
    };
}

var dataC = {
    test3: 789,
};

var dataA = {
    test: 123,
    dataB: {
        test2: 456,
    },
    viewC: cv(ViewC, dataC),
};

var vmA = cv(ViewA, dataA).mount(document.body);

Notes:

Options

cv and vw have four arguments: (view, data, key, opts). The fourth opts arg can be used to pass in any additional data into the view constructor/init without having to cram it into data. Several reserved options are handled automatically by domvm that correspond to existing vm.cfg({...}) options (documented in other sections):

This can simplify sub-view internals when externally-defined opts are passed in, avoiding some boilerplate inside views, eg. vm.cfg({hooks: opts.hooks}).

ES6/ES2015 Classes

Class views are not supported because domvm avoids use of this in its public APIs. To keep all functions pure, each is invoked with a vm argument. Not only does this compress better, but also avoids much ambiguity. Everything that can be done with classes can be done better with domvm’s plain object views, ES6 modules, Object.assign() and/or Object.create(). See #194 & #147 for more details.

TODO: create Wiki page showing ES6 class equivalents:


Parents & Roots

You can access any view’s parent view via vm.parent() and the great granddaddy of any view hierarchy via vm.root() shortcut. So, logically, to redraw the entire UI tree from any subview, invoke vm.root().redraw(). For traversing the vtree, there’s also vm.body() which gets the next level of descendant views (not necessarily direct children). vnode.body and vnode.parent complete the picture.


Sub-views vs Sub-templates

A core benefit of template composition is code reusability (DRY, component architecture). In domvm composition can be realized using either sub-views or sub-templates, often interchangeably. Sub-templates should generally be preferred over sub-views for the purposes of code reuse, keeping in mind that like sub-views, normal vnodes:

Sub-views carry a bit of performance overhead and should be used when the following are needed:

As an example, the distinction can be discussed in terms of the calendar demo. Its implementation is a single monolithic view with internal sub-template generating functions. Some may prefer to split up the months into a sub-view called MonthView, which would bring the total view count to 13. Others may be tempted to split each day into a DayView, but this would be a mistake as it would create 504 + 12 + 1 views, each incuring a slight performance hit for no reason. On the other hand, if you have a full-page month view with 31 days and multiple interactive events in the day cells, then 31 sub-views are well-justified.

The general advice is, restrict your views to complex, building-block-level, stateful components and use sub-template generators for readability and DRY purposes; a button should not be a view.


Event Listeners

Basic listeners are bound directly and are defined by plain functions. Like vanilla DOM, they receive only the event as an argument. If you need high performance such as mousemove, drag, scroll or other events, use basic listeners.

function filter(e) {
    // ...
}

el("input", {oninput: filter});

Parameterized listeners are defined using arrays and executed by a single, document-level, capturing proxy handler. They:

function cellClick(foo, bar, e, node, vm, data) {}

el("td", {onclick: [cellClick, "foo", "bar"]}, "moo");

View-level and global onevent callbacks:

// global
domvm.cfg({
    onevent: function(e, node, vm, data, args) {
        // ...
    }
});

// vm-level
vm.cfg({
    onevent: function(e, node, vm, data, args) {
        // ...
    }
});

Autoredraw

Is calling vm.redraw() everywhere a nuisance to you?

There’s an easy way to implement autoredraw yourself via a global or vm-level onevent which fires after all parameterized event listeners. The onevent demo demonstrates a basic full app autoredraw:

domvm.cfg({
    onevent: function(e, node, vm, data, args) {
        vm.root().redraw();
    }
});

You can get as creative as you want, including adding your own semantics to prevent redraw on a case-by-case basis by setting and checking for e.redraw = false. Or maybe having a Promise piggyback on e.redraw = new Promise(...) that will resolve upon deep data being fetched. You can maybe implement filtering by event type so that a flood of mousemove events, doesnt result in a redraw flood. Etc..


Streams

Another way to implement view reactivity and autoredraw is by using streams. By providing streams to your templates rather than values, views will autoredraw whenever streams change. domvm does not provide its own stream implementation but instead exposes a simple adapter to plug in your favorite stream lib.

domvm’s templates support streams in the following contexts:

A stream adapter for flyd looks like this:

domvm.cfg({
    stream: {
        val: function(v, accum) {
            if (flyd.isStream(v)) {
                accum.push(v);
                return v();
            }
            else
                return v;
        },
        on: function(accum, vm) {
            let calls = 0;

            const s = flyd.combine(function() {
                if (++calls == 2) {
                    vm.redraw();
                    s.end(true);
                }
            }, accum);

            return s;
        },
        off: function(s) {
            s.end(true);
        }
    }
});

An extensive demo can be found in the streams playground.

Notes:


Refs & Data

Like React, it’s possible to access the live DOM from event listeners, etc via refs. In addition, domvm’s refs can be namespaced:

function View(vm) {
    function sayPet(e) {
        var vnode = vm.refs.pets.fluffy;
        alert(fluffy.el.value);
    }

    return function() {
        return el("form", [
            el("button", {onclick: sayPet}, "Say Pet!"),
            el("input", {_ref: "pets.fluffy"}),
        ]);
    };
}

VNodes can hold arbitrary data, which obviates the need for slow data-* attributes and keeps your DOM clean:

function View(vm) {
    function clickMe(e, node) {
        console.log(node.data.myVal);
    }

    return function() {
        return el("form", [
            el("button", {onclick: [clickMe], _data: {myVal: 123}}, "Click!"),
        ]);
    };
}

Notes:

vm.state & vm.api are userspace-reserved and initialized to null. You may use them to expose view state or view methods as you see fit without fear of collisions with internal domvm properties & methods (present or future).


Keys & DOM Recycling

Like React [and any dom-reusing lib worth its salt], domvm sometimes needs keys to assure you of deterministic DOM recycling - ensuring similar sibling DOM elements are not reused in unpredictable ways during mutation. In contrast to other libs, keys in domvm are more flexible and often already implicit.


Hello World++

Try it: https://domvm.github.io/domvm/demos/playground/#stepper1

var el = domvm.defineElement;                       // element VNode creator

function StepperView(vm, stepper) {                 // view closure (called once during init)
    function add(num) {
        stepper.value += num;
        vm.redraw();
    }

    function set(e) {
        stepper.value = +e.target.value;
    }

    return function() {                             // template renderer (called on each redraw)
        return el("#stepper", [
            el("button", {onclick: [add, -1]}, "-"),
            el("input[type=number]", {value: stepper.value, oninput: set}),
            el("button", {onclick: [add, +1]}, "+"),
        ]);
    };
}

var stepper = {                                     // some external model/data/state
    value: 1
};

var vm = cv(StepperView, stepper);    // create ViewModel, passing model

vm.mount(document.body);                            // mount into document

The above example is simple and decoupled. It provides a UI to modify our stepper object which itself needs no awareness of any visual representation. But what if we want to modify the stepper using an API and still have the UI reflect these changes. For this we need to add some coupling. One way to accomplish this is to beef up our stepper with an API and give it awareness of its view(s) which it will redraw. The end result is a lightly-coupled domain model that:

  1. Holds state, as needed.
  2. Exposes an API that can be used programmatically and is UI-consistent.
  3. Exposes view(s) which utilize the API and can be composed within other views.

It is this fully capable, view-augmented domain model that domvm’s author considers a truely reusable “component”.

Try it: https://domvm.github.io/domvm/demos/playground/#stepper2

var el = domvm.defineElement;

function Stepper() {
    this.value = 1;

    this.add = function(num) {
        this.value += num;
        this.view.redraw();
    };

    this.set = function(num) {
        this.value = +num;
        this.view.redraw();
    };

    this.view = cv(StepperView, this);
}

function StepperView(vm, stepper) {
    function add(val) {
        stepper.add(val);
    }

    function set(e) {
        stepper.set(e.target.value);
    }

    return function() {
        return el("#stepper", [
            el("button", {onclick: [add, -1]}, "-"),
            el("input[type=number]", {value: stepper.value, oninput: set}),
            el("button", {onclick: [add, +1]}, "+"),
        ]);
    };
}

var stepper = new Stepper();

stepper.view.mount(document.body);

// now let's use the stepper's API to increment
var i = 0;
var it = setInterval(function() {
    stepper.add(1);

    if (i++ == 20)
        clearInterval(it);
}, 250);

Emit System

Emit is similar to DOM events, but works explicitly within the vdom tree and is user-triggerd. Calling vm.emit(evName, ...args) on a view will trigger an event that bubbles up through the view hierarchy. When an emit listener is matched, it is invoked and the bubbling stops. Like parameterized events, the vm and data args reflect the originating view of the event.

// listen
vm.cfg({
    onemit: {
        myEvent: function(arg1, arg2, vm, data) {
            // ... do stuff
        }
    }
});

// trigger
vm.emit("myEvent", arg1, arg2);

There is also a global emit listener which fires for all emit events.

domvm.cfg({
    onemit: {
        myEvent: function(arg1, arg2, vm, data) {
            // ... do stuff
        }
    }
});

Lifecycle Hooks

Demo: lifecycle-hooks different hooks animate in/out with different colors.

Node-level

Usage: el("div", {_key: "...", _hooks: {...}}, "Hello")

While not required, it is strongly advised that your hook-handling vnodes are uniquely keyed as shown above, to ensure deterministic DOM recycling and hook invocation.

View-level

Usage: vm.cfg({hooks: {willMount: ...}}) or return {render: ..., hooks: {willMount: ...}}

Notes:


Third-Party Integration

Several facilities exist to interoperate with third-party libraries.

Non-interference

First, domvm will not touch attrs that are not specified or managed in your templates. In addition, elements not created by domvm will be ignored by the reconciler, as long as their ancestors continue to remain in the DOM. However, the position of any inserted third-party DOM element amongst its siblings cannot be guaranteed.

will/didInsert Hooks

You can use normal DOM methods to insert elements into elements managed by domvm by using will/didInsert hooks. See the Embed Tweets demo.

injectElement

domvm.injectElement(elem) allows you to insert any already-created third-party element into a template, deterministically manage its position and fire lifecycle hooks.

innerHTML

You can set the innerHTML of an element created by domvm using a normal .-prefixed property attribute:

el("div", {".innerHTML": "<p>Foo</p>"});

However, it’s strongly recommended for security reasons to use domvm.injectElement() after parsing the html string via the browser’s native DOMParser API.


Extending ViewModel & VNode

If needed, you may extend some of domvm’s internal class prototypes in your app to add helper methods, etc. The following are available:


createContext

This demo in the playground shows how to implement VNode.prototype.pull() - a close analog to React’s createContext - a feature designed to alleviate prop drilling without resorting to globals.


Isomorphism & SSR

Like React’s renderToString, domvm can generate html and then hydrate it on the client. In server & full builds, vm.html() can generate html. In client & full builds, vm.attach(target) should be used to hydrate the rendered DOM.

var el = domvm.defineElement;

function View() {
    function sayHi(e) {
        alert("Hi!");
    }

    return function(vm, data) {
        return el("body", {onclick: sayHi}, "Hello " + data.name);
    }
}

var data = {name: "Leon"};

// return this generated <body>Hello Leon</body> from the server
var html = cv(View, data).html();

// then hydrate on the client to bind event handlers, etc.
var vm = cv(View, data).attach(document.body);

Notes:


Optimizations

Before you continue…

Still here? You must be a glutton for punishment, hell-bent on rendering enormous grids or tabular data ;) Very well, then…

Isolated Redraw

Let’s start with the obvious. Do you need to redraw everything or just a sub-view? vm.redraw() lets you to redraw only specific views.

Flatten Nested Arrays

While domvm will flatten nested arrays in your templates, you may get a small boost by doing it yourself via Array.concat() before returning your templates from render().

Old VTree Reuse

If a view is static or is known to not have changed since the last redraw, render() can return the existing old vnode to short-circuit the vtree regeneration, diffing and dom reconciliation.

function View(vm) {
    return function(vm) {
        if (noChanges)
            return vm.node;
        else
            return el("div", "Hello World");
    };
}

The mechanism for determining if changes may exist is up to you, including caching old data within the closure and doing diffing on each redraw. Speaking of diffing…

View Change Assessment

Similar to React’s shouldComponentUpdate(), vm.cfg({diff:...}) is able to short-circuit redraw calls. It provides a caching layer that does shallow comparison before every render() call and may return an array or object to shallow-compare for changes.

function View(vm) {
    vm.cfg({
        diff: function(vm, data) {
            return [data.foo.bar, data.baz];
        }
    });

    return function(vm, data) {
        return el("div", {class: data.baz}, "Hello World, " + data.foo.bar);
    };
}

diff may also return a plain value that’s the result of your own DIY comparison, but is most useful for static views where no complex diff is required at all and a simple === will suffice.

With a plain-object view, it looks like this:

var StaticView = {
    diff: function(vm, data) {
        return 0;
    },
    render: function(vm, data) {},
};

Notes:

If you intend to do a simple diff of an object by its identity, then it’s preferable to return it wrapped in an array to avoid domvm also diffing all of its enumerable keys when oldObj !== newObj. This is a micro-optimization and will not affect the resulting behavior. Also, see Issue #148.

VNode Patching

VNodes can be patched on an individual basis, and this can be done without having to patch the children, too. This makes mutating attributes, classes and styles much faster when the children have no changes.

var vDiv = el("div", {class: "foo", style: "color: red;"}, [
    el("div", "Mooo")
]);

vDiv.patch({class: "bar", style: "color: blue;"});

DOM patching can also be done via a full vnode rebuild:

function makeDiv(klass) {
    return el("div", {class: klass, style: "color: red;"}, [
        el("div", "Mooo")
    ]);
}

var vDiv = makeDiv("foo");

vDiv.patch(makeDiv("bar"));

vnode.patch(vnode|attrs, doRepaint) can be called with a doRepaint = true arg to force a DOM update. This is typically useful in cases when a CSS transition must start from a new state and should not be batched with any followup patch() calls. You can see this used in the lifecycle-hooks demo.

Fixed Structures

Let’s say you have a bench like dbmonster in this repo. It’s a huge grid that has a fixed structure. No elements are ever inserted, removed or reordered. In fact, the only mutations that ever happen are textContent of the cells and patching of attrs like class, and style.

There’s a lot of work that domvm’s DOM reconciler can avoid doing here, but you have to tell it that the structure of the DOM will not change. This is accomplished with a domvm.FIXED_BODY vnode flag on all nodes whose body will never change in shallow structure.

var Table = {
    render: function() {
        return el("table", {_flags: domvm.FIXED_BODY}, [
            el("tr", {_flags: domvm.FIXED_BODY}, [
                el("td", {_flags: domvm.FIXED_BODY}, "Hello"),
                el("td", {_flags: domvm.FIXED_BODY}, "World"),
            ])
        ]);
    }
};

This is rather tedious, so there’s an easier way to get it done. The fourth argument to defineElement() is flags, so we create an additional element factory and use it normally:

function fel(tag, arg1, arg2) {
    return domvm.defineElement(tag, arg1, arg2, domvm.FIXED_BODY);
}

var Table = {
    render: function() {
        return fel("table", [
            fel("tr", [
                fel("td", "Hello"),
                fel("td", "World"),
            ])
        ]);
    }
};

Fully-Keyed Lists

In domvm, the term “list”, implies that child elements are shallow-homogenous (the same views or elements with the same DOM tags). domvm does not require that child arrays are fully-keyed, but if they are, you can slightly simplify domvm’s job of matching up the old vtree by only testing keys. This is done by setting the domvm.KEYED_LIST vnode flag on the parent.

Lazy Lists

Lazy lists allow for old vtree reuse in the absence of changes at the vnode level without having to refactor into more expensive views that return existing vnodes. This mostly saves on memory allocations. Lazy lists may be created for both, keyed and non-keyed lists. To these lists, you will need:

While a bit involved, the resulting code is quite terse and not as daunting as it sounds: https://domvm.github.io/domvm/demos/playground/#lazy-list

Special Attrs

VNodes use attrs objects to also pass special properties: _key, _ref, _hooks, _data, _flags. If you only need to pass one of these special options to a vnode and have not actual attributes to set, you can avoid allocating attrs objects by assigning them directly to the created vnodes.

Instead of creating attrs objects just to set a key:

el("ul", [
    el("li", {_key: "foo"}, "hello"),
    el("li", {_key: "bar"}, "world"),
])

Create a helper to set the key directly:

function keyed(key, vnode) {
    vnode.key = key;
    return vnode;
}

el("ul", [
    keyed("foo", el("li", "hello")),
    keyed("bar", el("li", "world")),
])