Simulacra.js

One-way data binding for web applications.

npm i simulacra --save #v0.16.1 Download Fork on GitHub

Synopsis#

Simulacra.js makes the DOM react to changes in data. When data changes, it maps those changes to the DOM by adding and removing elements after invoking mutator functions, which by default, assign plain text and form input values.

Fundamentally, it is a low-cost abstraction over the DOM that optimizes calls to Node.insertBefore and Node.removeChild. Its performance is comparable to hand-written DOM manipulation code, see the benchmarks.

Usage#

Simulacra.js uses plain old HTML for templating, and it does not require meta-information in the template at all. Here's a sample template:

<template id="product">
  <h1 class="name"></h1>
  <div class="details">
    <div><span class="size"></span></div>
    <h4 class="vendor"></h4>
  </div>
</template>

Using the <template> tag is optional but optimal since its contents are not rendered by default, but any DOM element will suffice. The shape of the data is important since it has a straightforward mapping to the DOM, and arrays are iterated over to output multiple DOM elements. Here's some sample data:

var data = {
  name: 'Pumpkin Spice Latte',
  details: {
    size: [ 'Tall', 'Grande', 'Venti' ],
    vendor: 'Starbucks'
  }
}

Simulacra.js exports only a single function, which can either define bindings to the DOM, or apply bindings to an object (this is also exposed as simulacra.defineBinding and simulacra.bindObject). If the first argument is an object, it will try to bind the second argument onto the object. If the first argument is either a DOM Node or a CSS selector string, it will return a definition object that is used by Simulacra.js internally, and the second argument then defines either a nested definition or a mutator function. This can be combined in a single expression:

var $ = require('simulacra') // or `window.simulacra`

var fragment = document.getElementById('product').content

var content = $(data, $(fragment, {
  name: $('.name'),
  details: $('.details', {
    size: $('.size'),
    vendor: $('.vendor')
  })
}))

document.body.appendChild(content)

The DOM will update if any of the bound keys are assigned a different value, or if any Array.prototype methods on the value are invoked. Arrays and single values may be used interchangeably, the only difference is that Simulacra.js will iterate over array values.

Here is the result of the example above:

Live demo, try it out:

  • data.name = "Caramel Latte"
  • data.details.size.push("Trenta")

Mutator Function#

By default, the value will be assigned to the element's textContent property (or value or checked for inputs), a user-defined mutator function may be used for arbitrary element manipulation. The mutator function may be passed as the second argument to Simulacra.js, it has the signature (node, value, previousValue, path):

To manipulate a node in a custom way, one may define a mutator function like so:

$(node || selector, function mutator (node, value) {
  node.textContent = 'Hi ' + value + '!'
})

A mutator function can be determined to be an insert, mutate, or remove operation based on whether the value or previous value is null:

There are some special cases for the mutator function:

Mount Function#

A mount function can be defined on a bound object, as the third argument. Its signature is very similar the mutator function, except that it does not provide previousValue. Instead, it can be determined if there was a mount or unmount based on whether value is an object or not.

$(node || selector, { ... }, function mount (node, value) {
  if (value !== null) {
    // Mounting a node, maybe attach event listeners here.
  }
})

If the mount function returns false for an unmount, it will skip removing the node from the DOM. This is useful for implementing animations.

State Management#

Since Simulacra.js is intended to be deterministic, the bound object can be cloned at any point in time and bound again to reset to that state. For example, using the clone module:

var clone = require('clone')
var $ = require('simulacra')

var data = { ... }, bindings = $( ... )

var node = $(data, bindings)
var initialData = clone(data)

// Do some mutations, and then reset to initial state.
node = $(initialData, bindings)

This is just one way to implement time travel, but not the most efficient.

Benchmarks#

Simulacra.js is comparable to directly calling Node.appendChild in terms of DOM rendering. Based on the benchmarks from Mithril.js, here's how it compares. Tests ran on a Linux desktop using Chromium. Only loading, scripting, rendering, and aggregate times are shown.

NameLoadingScriptingRenderingAggregate
appendChild10 ms3 ms13 ms38 ms
Simulacra.js9 ms9 ms13 ms39 ms
React.js23 ms76 ms13 ms129 ms
Mithril.js16 ms77 ms23 ms165 ms
Backbone20 ms106 ms23 ms191 ms
jQuery20 ms119 ms24 ms211 ms
Angular.js17 ms159 ms24 ms295 ms

To run the benchmarks, you will have to clone the repository and build it by running npm run build. The benchmarks are located here.

How it Works#

On initialization, Simulacra.js removes bound elements from the document and replaces them with an empty text node (marker) for memoizing its position. Based on a value in the bound data object, it clones template elements and applies the mutator function on the cloned elements, and appends them near the marker or adjacent nodes.

When a bound key is assigned, it gets internally casted into an array if it is not an array already, and the values of the array are compared with previous values. Based on whether a value at an index has changed, Simulacra.js will remove, insert, or mutate a DOM Node corresponding to the value. This is faster and simpler than diffing changes between DOM trees.

Caveats#

Under the Hood#

This library is written in ES5 syntactically, and makes use of:

No shims are included. At the bare minimum, it works in IE9+ with a WeakMap polyfill, but otherwise it should work in IE11+.

Server-Side Rendering#

Simulacra.js works in Node.js (it's isomorphic!), with one thing to keep in mind: it should be called within the context of the window global, however this may be optional in some implementations. This is most easily done by using Function.prototype.bind, although Function.prototype.call is more performant. In the following example, Domino is used as the DOM implementation.

const domino = require('domino')
const simulacra = require('simulacra')

const window = domino.createWindow('<h1></h1>')
const $ = simulacra.bind(window)
const data = { message: 'Hello world!' }
const binding = $('body', {
  message: $('h1')
})

console.log($(data, binding).innerHTML)

This will print the string <h1>Hello world!</h1> to stdout.

License#

This software is licensed under the MIT license.