Synopsis#
Simulacra.js returns a DOM Node that updates when an object changes. Its API is a single function, and it does not introduce any new syntax or a template language. It recursively adds metaprogramming features to vanilla data structures to work.
It is a fairly low cost abstraction, though it may not be quite as fast as hand-optimized code. The approximate size of this library is ~5 KB (minified and gzipped).
Usage#
With Simulacra.js, any changes on bound objects reflect immediately in the DOM, no manual intervention required. Here is an example of Simulacra.js in action:
Simulacra.js uses plain HTML for templating, and it does not introduce its own template language. 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, any DOM element will suffice. The shape of the state 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 state:
var state = {
name: 'Pumpkin Spice Latte',
details: {
size: [ 'Tall', 'Grande', 'Venti' ],
vendor: 'Coffee Co.'
}
}
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, change function or definition object (parent binding only), or an array with at most three elements:
- Index 0: a CSS selector string.
- Index 1: either a definition object, or a change function.
- Index 2: if index 1 is a definition object, this may be a change function.
var bindObject = require('simulacra')
var template = document.getElementById('product')
var node = bindObject(state, [ template, {
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
):
element
: the local DOM element.value
: the value assigned to the key of the bound object.previousValue
: the previous value assigned to the key of the bound object.path
: an object containing info on where the change occurred.
To manipulate an element in a custom way, one may define a change function like so:
[ selector, function (element, value, previousValue) {
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
:
- Value but not previous value: insert operation.
- Value and previous value: mutate operation.
- No value: remove operation.
There are some special cases for the change function:
- If the bound element is an
input
or a textarea
, the default behavior will be to update the state when the input changes. This may be overridden with a custom change function. - If the bound element is the same as its parent, its value will not be iterated over if it is an array.
- If the change function returns
simulacra.retainElement
for a remove operation, then Node.removeChild
will not be called. This is useful for implementing animations when removing an element from the DOM. - If the change function is applied on a definition object, it will never be a mutate operation, it will first remove and then insert in case of setting a new object over an existing object.
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. They are optionalto use, and are opt-in functionality. To use them, one can define a change function like so:
var bindObject = require('simulacra')
var retainElement = bindObject.retainElement
var helpers = require('simulacra/helpers')
var animate = helpers.animate
var bindEvents = helpers.bindEvents
var bindFn = bindEvents({
click: function (event, path) {
event.target.classList.toggle('alternate')
}
})
var animateFn = animate('fade-in', 'bounce', 'fade-out', 1500)
function change (node, value) {
animateFn.apply(null, arguments)
bindFn.apply(null, arguments)
return value || retainElement
}
Server-Side Rendering#
Simulacra.js includes an optimized string rendering function. It implements a subset of Simulacra.js and the DOM, but it should work for most common use cases.
const render = require('simulacra/render')
const state = { message: 'Hello world!' }
const binding = { message: 'h1' }
const template = '<h1></h1>'
render(state, binding, template)
console.log(render(state, binding))
This will print the string <h1>Hello world!</h1>
to stdout
.
The DOM API in Node.js can also work, it should be called within the context of the window
global, however this may be optional in some implementations. In the following example, Domino is used as the DOM implementation.
const domino = require('domino')
const bindObject = require('simulacra')
const window = domino.createWindow('<h1></h1>')
const $ = bindObject.bind(window)
const state = { message: 'Hello world!' }
const binding = [ 'body', { message: 'h1' } ]
console.log($(state, binding).innerHTML)
This will also print the string <h1>Hello world!</h1>
to stdout
.
Rehydrating from Server Rendered Page#
Simulacra.js also allows server-rendered DOM to be re-used or rehydrated. The main function accepts an optional third argument for this purpose:
const bindObject = require('simulacra')
const state = { }
const binding = [ ... ]
const node = document.querySelector(...)
bindObject(state, binding, node)
Instead of returning a new Node, it will return the Node that was passed in, so it's not necessary to manually append the return value to the DOM. All change functions will be run so that event binding can happen, but return values will be ignored. If the Node could not be rehydrated properly, it will throw an error.
Benchmarks#
There are a few benchmarks implemented with Simulacra.js:
Philosophy#
The namesake of this library comes from Jean Baudrillard's *Simulacra and Simulation*. The mental model it provides is that the user interface is a first order simulacrum, or a faithful representation of state.
Its design is motivated by this quote:
"It is better to have 100 functions operate on one data structure than 10 functions on 10 data structures." —Alan Perlis
Simulacra.js does data binding differently:
- Rather than having much of a public API, it tries to be as opaque as possible. Every built-in way to mutate state is overridden, and becomes an integral part of how it works.
- There is no templating syntax at all. Instead, the binding structure determines how to render an element. This also means that the state has a one-to-one mapping to the DOM.
- All changes are atomic and run synchronously, there is no internal usage of timers or event loops and no need to wait for changes to occur.
- It does not force any component architecture, use a single bound object or as many as desired.
What Simulacra.js does is capture the intent of state changes, so it is important to use the correct semantics. Using state.details = { ... }
is different from Object.assign(state.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.
Nodes are updated if and only if their values change, that is each value has a 1:1 correspondence to the DOM. Generally, elements should be rendered based on their value alone, external inputs should be avoided.
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 state 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. Array mutator methods are overridden with optimized implementations, which are faster and simpler than diffing changes between DOM trees.
Caveats#
- The
delete
keyword will not trigger a DOM update. Although ES6 Proxy
has a trap for this keyword, its browser support is lacking and it can not be polyfilled. Also, it would break the API of Simulacra.js for this one feature, so the recommended practice is to set the value to null
rather than trying to delete
the key. - Out-of-bounds array index assignment will not work, because the number of setters is equal to the length of the array. Similarly, setting the length of an array will not work because a setter can't be defined on the
length
property.
Under the Hood#
This library requires these JavaScript features:
- Object.defineProperty (ES5): used for binding keys on objects.
It also makes use of these DOM API features:
- Node.contains (DOM Living Standard): used for checking if bound nodes are valid.
- Node.nextElementSibling (DOM Living Standard): used for checking if a node is the last child or not.
- Node.nextSibling (DOM Level 1): used for performance optimizations.
- Node.appendChild (DOM Level 1): used for appending nodes.
- Node.insertBefore (DOM Level 1): used for inserting nodes.
- Node.removeChild (DOM Level 1): used for removing nodes.
- Node.cloneNode (DOM Level 2): used for creating nodes.
- Node.normalize (DOM Level 2): cleaning up DOM nodes.
- Node.isEqualNode (DOM Level 3): used for equality checking after cloning nodes.
- TreeWalker (DOM Level 2): fast iteration through DOM nodes.
- MutationObserver (DOM Level 4): used for the
animate
helper.
No shims are included. The bare minimum should be IE9, which has object property support.
Similar Projects#
- Vue.js uses meta-programming to a limited extent. In contrast to Simulacra.js, it uses a templating language and comes with its own notion of components.
- Binding.scala also binds objects to the DOM, but uses a templating language and is written in Scala.
- Bind.js has a similar API, but only works on simple key-value pairs and is unoptimized for arrays.
- Plates has a similar concept, but uses string templating instead of the DOM API.
License#
This software is licensed under the MIT license.