Skip to content
This repository has been archived by the owner on Jun 21, 2023. It is now read-only.

Commit

Permalink
release 1.0
Browse files Browse the repository at this point in the history
  • Loading branch information
0x8890 committed May 30, 2016
1 parent d797ba7 commit 87d2175
Show file tree
Hide file tree
Showing 12 changed files with 257 additions and 261 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
# Changelog


### 1.0.0 (2016-05-30)
- Breaking change: remove `defineBinding` function, now the default exported function does only one thing: binding an object to the DOM.
- Breaking change: removed `return false` behavior to retain DOM element, it should instead return `simulacra.retainElement`.
- Feature: change function may accept a return value, which sets `textContent`, `value`, or `checked`. Returning `undefined` will have no effect.
- Polish: rename *mutator* function to *change* function.


### 0.16.1 (2016-05-26)
- Polish: remove redundant logic in mutator function.

Expand Down
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
The MIT License (MIT)

Copyright (c) 2015 0x8890 <0x8890@airmail.cc> (http://0x8890.com)
Copyright (c) 2016 0x8890 <0x8890@airmail.cc> (http://0x8890.com)

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
103 changes: 55 additions & 48 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ $ npm i simulacra --save

## 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.
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.

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](#benchmarks).

Expand Down Expand Up @@ -43,69 +43,76 @@ var data = {
}
```

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:
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:

```js
var $ = require('simulacra') // or `window.simulacra`
- **Index 0**: either a DOM element or a CSS selector string.
- **Index 1**: either a nested definition array, or a *change* function.
- **Index 2**: if index 1 is a nested definition, this should be an optional *mount* function.

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

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

document.body.appendChild(content)
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.


## Mutator Function
## Change 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`):
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`):

- `node`: the local DOM node.
- `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 array containing the full path to the value. For example: `[ 'users', 2, 'email' ]`. Integer values indicate array indices. The root object is accessible at the `root` property of the path array, i.e. `path.root`, and the deepest bound object is accessible at the `target` property, i.e. `path.target`.
- **`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 array containing the full path to the value. For example: `[ 'users', 2, 'email' ]`. Integer values indicate array indices. The root object is accessible at the `root` property of the path array, i.e. `path.root`, and the deepest bound object is accessible at the `target` property, i.e. `path.target`.

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

```js
$(node || selector, function mutator (node, value) {
node.textContent = 'Hi ' + value + '!'
})
[ element || selector, function change (element, value) {
return '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`:
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.
- **Value but not previous value**: insert operation.
- **Value and previous value**: mutate operation.
- **No value**: remove operation.

There are some special cases for the mutator function:
There are some special cases for the *change* function:

- If the bound node is the same as its parent, its value will not be iterated over if it is an array.
- If the mutator function returns `false` for a remove operation, then `Node.removeChild` will not be called. This is useful for implementing animations when removing a Node from the DOM.
- 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.


## 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.
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`.

```js
$(node || selector, { ... }, function mount (node, value) {
[ element || selector, { ... }, function mount (element, value) {
if (value !== null) {
// Mounting a node, maybe attach event listeners here.
// 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 false for an unmount, it will skip removing the node from the DOM. This is useful for implementing animations.
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
Expand All @@ -114,15 +121,15 @@ Since Simulacra.js is intended to be deterministic, the bound object can be clon

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

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

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

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

This is just one way to implement time travel, but not the most efficient.
Expand All @@ -147,9 +154,9 @@ To run the benchmarks, you will have to clone the repository and build it by run

## 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.
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 Node corresponding to the value. This is faster and simpler than diffing changes between DOM trees.
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
Expand All @@ -164,11 +171,11 @@ When a bound key is assigned, it gets internally casted into an array if it is n

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

- Object.defineProperty (ES5)
- WeakMap (ES6)
- TreeWalker (DOM Level 2)
- Node.isEqualNode (DOM Level 3)
- Node.contains (DOM Living Standard)
- **Object.defineProperty** (ES5)
- **WeakMap** (ES6)
- **TreeWalker** (DOM Level 2)
- **Node.isEqualNode** (DOM Level 3)
- **Node.contains** (DOM Living Standard)

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

Expand All @@ -184,9 +191,9 @@ const simulacra = require('simulacra')
const window = domino.createWindow('<h1></h1>')
const $ = simulacra.bind(window)
const data = { message: 'Hello world!' }
const binding = $('body', {
message: $('h1')
})
const binding = [ 'body', {
message: 'h1'
} ]

console.log($(data, binding).innerHTML)
```
Expand Down
14 changes: 7 additions & 7 deletions benchmark/simulacra.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<p>To run an execution time test on this page, run the profiler from your browser's developer tools and measure the running time of a page refresh. (Lower is better)</p>
<div id="container"></div>
<script>
(function () {
void function () {
var data = {
items: [{name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}, {name: "a"}, {name: "b"}, {name: "c"}]
}
Expand All @@ -17,14 +17,14 @@
template.innerHTML = '<input>'
var fragment = template.content

var bindings = $(fragment, {
items: $(fragment.querySelector('input'), function (node, value) {
node.value = value.name
})
})
var bindings = [ fragment, {
items: [ 'input', function (node, value) {
return value.name
} ]
} ]

document.getElementById('container').appendChild($(data, bindings))
}())
}()
</script>
</body>
</html>
63 changes: 38 additions & 25 deletions lib/bind_keys.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ function bindKeys (scope, obj, def, parentNode, path) {
function define (key) {
var initialValue = obj[key]
var branch = def[key]
var mutator = branch.mutator
var mount = branch.mount
var definition = branch.definition
var node = branch[0]
var change = !branch.__hasDefinition && branch[1]
var definition = branch.__hasDefinition && branch[1]
var mount = branch[2]

// Keeping state in this closure.
var keyPath = path.concat(key)
Expand Down Expand Up @@ -65,7 +66,7 @@ function bindKeys (scope, obj, def, parentNode, path) {
if (definition && x != null)
bindKeys(scope, x, definition, parentNode, keyPath)

else if (mutator) mutator(parentNode, x, previousValue, keyPath)
else if (change) change(parentNode, x, previousValue, keyPath)

return null
}
Expand Down Expand Up @@ -99,9 +100,8 @@ function bindKeys (scope, obj, def, parentNode, path) {
for (i = 0, j = Math.max(previousValues.length, value.length);
i < j; i++) checkValue(value, i)

// Reset length to current values, implicitly deleting indices from
// `previousValues` and `activeNodes` and allowing for garbage
// collection.
// Reset length to current values, implicitly deleting indices and
// allowing for garbage collection.
previousValues.length = activeNodes.length = value.length

return x
Expand Down Expand Up @@ -143,8 +143,8 @@ function bindKeys (scope, obj, def, parentNode, path) {
endPath.target = path.target
}

if (mutator)
returnValue = mutator(activeNode, null, previousValue, endPath)
if (change)
returnValue = change(activeNode, null, previousValue, endPath)
else if (definition && mount) {
endPath.target = endPath.root

Expand All @@ -154,17 +154,20 @@ function bindKeys (scope, obj, def, parentNode, path) {
returnValue = mount(activeNode, null, endPath)
}

// If a mutator function returns false, skip removing from DOM.
if (returnValue !== false)
branch.marker.parentNode.removeChild(activeNode)
// If a change or mount function returns the retain element symbol,
// skip removing the element from the DOM.
if (returnValue !== bindKeys.retainElement)
branch.__marker.parentNode.removeChild(activeNode)

delete activeNodes[i]
}
}

function addNode (value, previousValue, i) {
var j, k, node, nextNode, activeNode = activeNodes[i]
var activeNode = activeNodes[i]
var currentNode = node
var endPath = keyPath
var j, k, nextNode, returnValue

// Cast previous value to null if undefined.
if (previousValue === void 0) previousValue = null
Expand All @@ -185,38 +188,48 @@ function bindKeys (scope, obj, def, parentNode, path) {

if (definition) {
if (activeNode) removeNode(value, previousValue, i)
node = processNodes(scope, branch.node.cloneNode(true), definition, i)
currentNode = processNodes(scope, node.cloneNode(true), definition, i)
endPath.target = isArray ? value[i] : value
bindKeys(scope, value, definition, node, endPath)
bindKeys(scope, value, definition, currentNode, endPath)
if (mount) {
endPath.target = endPath.root

for (j = 0; j < keyPath.length - 1; j++)
endPath.target = endPath.target[keyPath[j]]

mount(node, value, endPath)
mount(currentNode, value, endPath)
}
}

else if (mutator) {
if (activeNode) {
mutator(activeNode, value, previousValue, endPath)
return
}
else {
currentNode = activeNode || node.cloneNode(true)
returnValue = change ?
change(currentNode, value, previousValue, endPath) :
value !== void 0 ? value : null
}

node = branch.node.cloneNode(true)
mutator(node, value, previousValue, endPath)
if (returnValue !== void 0) switch (branch.__replaceAttribute) {
case 'checked':
if (returnValue) currentNode.checked = 'checked'
else currentNode.removeAttribute('checked')
break
default:
currentNode[branch.__replaceAttribute] = returnValue
}

// Do not actually add an element to the DOM if it's only a change
// between non-empty values.
if (!definition && activeNode) return

// Find the next node.
for (j = i + 1, k = activeNodes.length; j < k; j++)
if (activeNodes[j]) {
nextNode = activeNodes[j]
break
}

activeNodes[i] = branch.marker.parentNode.insertBefore(
node, nextNode || branch.marker)
activeNodes[i] = branch.__marker.parentNode
.insertBefore(currentNode, nextNode || branch.__marker)
}


Expand Down

0 comments on commit 87d2175

Please sign in to comment.