Simulacra.js

One-way data binding for web applications.

npm i simulacra --save #v1.0.16 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 and invoking change functions, which by default, assign plain text and form input values.

It emphasizes performance and terseness, and it has no dependencies. The approximate size of this library is ~4 KB (minified and gzipped).

Usage#

Simulacra.js uses plain 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 binds an object to the DOM. The first argument must be a singular object, and the second argument is a data structure that defines the bindings. The definition must be a single value or an array with at most three elements:

var simulacra = require('simulacra') // or `window.simulacra`
var fragment = document.getElementById('product').content

var node = simulacra(data, [ fragment, {
  name: '.name',
  details: [ '.details', {
    size: '.size',
    vendor: '.vendor'
  } ]
} ])

document.body.appendChild(node)

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")
  • data.details.size = [ 'Tall' ]

Change Function#

By default, the value will be assigned to the element's textContent property (or value or checked for inputs). A user-defined change function may be passed for arbitrary element manipulation, and its return value may affect the value used in the default behavior. The change function may be passed as the second position, it has the signature (element, value, previousValue, path):

To manipulate an element in a custom way, one may define a change function like so:

[ element || selector, function change (element, value) {
  return 'Hi ' + value + '!'
} ]

A change 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 change function:

Mount Function#

A mount function can be defined as the third position. Its signature is similar to the change 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 null.

[ element || selector, { ... }, function mount (element, value) {
  if (value !== null) {
    // Mounting an element, maybe attach event listeners here.
  }
  else {
    // Unmounting an element, may return `simulacra.retainElement`
    // to skip removal from the DOM.
  }
} ]

If the mount function returns simulacra.retainElement for an unmount, it will skip removing the element 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 simulacra = require('simulacra')

var data = { ... }, bindings = [ ... ]

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

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

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

Benchmarks#

Simulacra.js is pretty fast in the DBMonster benchmark. In initial rendering speed based on the benchmarks from Mithril.js, here's how it compares. Tests ran on a Linux desktop using Chromium.

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 change 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 element 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 requires:

It also requires these DOM API features:

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.