Simulacra.js

Reactive data binding for web applications.

npm i simulacra --save #v1.4.3 Download Fork on GitHub

Synopsis#

Simulacra.js handles DOM interactions in reaction 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 ~5 KB (minified and gzipped).

Usage#

Simulacra.js exports only a single function that is used to bind an object to the DOM, that is its entire API surface area. Here is an example of Simulacra.js in action:

Live demo, try it out:

  • data.name = "Caramel Latte"
  • data.details.size.push("Trenta")
  • data.details.size = [ 'Tall' ]

Simulacra.js uses plain HTML for templating, and it does not require any meta-information in the template. This makes it straightforward to start with a static HTML page and add interactive parts. 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 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 CSS selector string, DOM Node, change function or definition object (parent binding only), 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.

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 determines the new textContent, value, or checked attribute if it is not applied on a definition object. The change function may be passed as the second or third 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:

[ selector, function (element, value, previousValue) {
  // Attach listeners before inserting a DOM Node.
  if (previousValue === null)
    element.addEventListener('click', function () {
      alert('clicked')
    })

  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:

Helper Functions#

Here is an example of using the built-in helper functions to control animations and events:

Simulacra.js includes some built-in helper functions for common use cases, such as event listening and animations. To use them, one can define a change function like so:

var simulacra = require('simulacra')
var chain = simulacra.chain
var setDefault = simulacra.setDefault
var bindEvents = simulacra.bindEvents
var animate = simulacra.animate

var change = chain(
  // Use default behavior for mapping values to the DOM.
  setDefault,

  // Accepts a hash keyed by event names, using this has the advantage of
  // automatically removing event listeners, even if the element is still
  // in the DOM. The optional second argument is `useCapture`.
  bindEvents({
    // The first argument is the DOM event, second is the path to the data.
    click: function (event, path) {
      event.target.classList.toggle('alternate')
    }
  }),

  // Accepts class names on insert, mutate, and remove, and a time in ms for
  // how long to retain an element after removal.
  animate('fade-in', 'bounce', 'fade-out', 1500))

Note that setDefault should generally be set first if the default behavior is desired.

Data Binding#

The idea is that once the bindings have been set up, one does not call Simulacra.js again. For example, assigning data.name = 'Simulacra' by default will set the text of the element to that value and append it to the DOM if it doesn't exist, and data.name = null will remove all elements corresponding to that field. Assigning data.name = ['John', 'Doe'] will create missing elements and assign the text of both elements, and append them if necessary.

The bindings work recursively on objects, which provides a simple way to build complex user interfaces. For example, assigning data.details = { size: [1, 2, 3], vendor: 'X' } will create the element for details and the child elements corresponding to its fields (size, vendor, etc), and remove the previous element if it existed. The new object also has bindings, so data.details.size.push(4) will create a new element corresponding to that value.

All values that are bound to non-parent elements are arrays internally, which will be mapped to elements. For example, a list of things may be modelled as an array of objects: [ {...}, {...}, {...} ]. The arrays which are bound also have instance-specific methods for efficient DOM manipulation, i.e. array.splice(2, 0, { ... }) will insert a new element at index 2 without touching the other elements.

What Simulacra.js does is capture the intent of the state change, so it is important to use the correct semantics. Using data.details = { ... } is different from Object.assign(data.details, { ... }), the former will assume that the entire object changed and remove and append a new element, while the latter will re-use the same element and check the differences in the key values. For arrays, it is almost always more efficient to use the proper array mutator methods (push, splice, pop, etc). This is also important for implementing animations, since it determines whether elements are created, updated, or removed.

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 replaces bound elements from the template with empty text nodes (markers) for memoizing their positions. 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 makes use of these JavaScript features:

It also makes use of 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.