frb
v4.0.3
Published
Functional reactive bindings
Downloads
4,124
Readme
Functional Reactive Bindings
In their simplest form, bindings provide the illusion that two objects have the same property. Changing the property on one object causes the same change in the other. This is useful for coordinating state between views and models, among other entangled objects. For example, if you enter text into a text field, the same text might be added to the corresponding database record.
bind(object, "a.b", {"<->": "c.d"});
Functional Reactive Bindings go farther. They can gracefully bind long property paths and the contents of collections. They can also incrementally update the results of chains of queries including maps, flattened arrays, sums, and averages. They can also add and remove elements from sets based on the changes to a flag. FRB makes it easy to incrementally ensure consistent state.
bind(company, "payroll", {"<-": "departments.map{employees.sum{salary}}.sum()"});
bind(document, "body.classList.has('dark')", {"<-": "darkMode", source: viewModel});
FRB is built from a combination of powerful functional and generic building blocks, making it reliable, easy to extend, and easy to maintain.
Getting Started
frb
is a CommonJS package, with JavaScript modules suitable for use
with [Node.js][] on the server side or [Mr][] on the client side.
❯ npm install frb
Tutorial
In this example, we bind model.content
to document.body.innerHTML
.
var bind = require("frb/bind");
var model = {content: "Hello, World!"};
var cancelBinding = bind(document, "body.innerHTML", {
"<-": "content",
"source": model
});
When a source property is bound to a target property, the target gets reassigned to the source any time the source changes.
model.content = "Farewell.";
expect(document.body.innerHTML).toBe("Farewell.");
Bindings can be recursively detached from the objects they observe with the returned cancel function.
cancelBinding();
model.content = "Hello again!"; // doesn't take
expect(document.body.innerHTML).toBe("Farewell.");
Two-way Bindings
Bindings can go one way or in both directions. Declare one-way
bindings with the <-
property, and two-way bindings with the
<->
property.
In this example, the "foo" and "bar" properties of an object will be inexorably intertwined.
var object = {};
var cancel = bind(object, "foo", {"<->": "bar"});
// <-
object.bar = 10;
expect(object.foo).toBe(10);
// ->
object.foo = 20;
expect(object.bar).toBe(20);
Right-to-left
Note that even with a two-way binding, the right-to-left binding precedes the left-to-right. In this example, "foo" and "bar" are bound together, but both have initial values.
var object = {foo: 10, bar: 20};
var cancel = bind(object, "foo", {"<->": "bar"});
expect(object.foo).toBe(20);
expect(object.bar).toBe(20);
The right-to-left assignment of bar
to foo
happens first, so the
initial value of foo
gets lost.
Properties
Bindings can follow deeply nested chains, on both the left and the right side.
In this example, we have two object graphs, foo
, and bar
, with the
same structure and initial values. This binds bar.a.b
to foo.a.b
and also the other way around.
var foo = {a: {b: 10}};
var bar = {a: {b: 10}};
var cancel = bind(foo, "a.b", {
"<->": "a.b",
source: bar
});
// <-
bar.a.b = 20;
expect(foo.a.b).toBe(20);
// ->
foo.a.b = 30;
expect(bar.a.b).toBe(30);
Structure changes
Changes to the structure of either side of the binding are no matter. All of the orphaned event listeners will automatically be canceled, and the binders and observers will reattach to the new object graph.
Continuing from the previous example, we store and replace the a
object from one side of the binding. The old b
property is now
orphaned, and the old b
property adopted in its place.
var a = foo.a;
expect(a.b).toBe(30); // from before
foo.a = {}; // orphan a and replace
foo.a.b = 40;
// ->
expect(bar.a.b).toBe(40); // updated
bar.a.b = 50;
// <-
expect(foo.a.b).toBe(50); // new one updated
expect(a.b).toBe(30); // from before it was orphaned
Strings
String concatenation is straightforward.
var object = {name: "world"};
bind(object, "greeting", {"<-": "'hello ' + name + '!'"});
expect(object.greeting).toBe("hello world!");
Sum
Some advanced queries are possible with one-way bindings from collections. FRB updates sums incrementally. When values are added or removed from the array, the sum of only those values is taken and added or removed from the last known sum.
var object = {array: [1, 2, 3]};
bind(object, "sum", {"<-": "array.sum()"});
expect(object.sum).toEqual(6);
Average
The arithmetic mean of a collection can be updated incrementally. Each time the array changes, the added and removed values adjust the last known sum and count of values in the array.
var object = {array: [1, 2, 3]};
bind(object, "average", {"<-": "array.average()"});
expect(object.average).toEqual(2);
Rounding
The round
, floor
, and ceil
methods operate on numbers and return
the nearest integer, the nearest integer toward -infinity, and the
nearest integer toward infinity respectively.
var object = {number: -0.5};
Bindings.defineBindings(object, {
"round": {"<-": "number.round()"},
"floor": {"<-": "number.floor()"},
"ceil": {"<-": "number.ceil()"}
});
expect(object.round).toBe(0);
expect(object.floor).toBe(-1);
expect(object.ceil).toBe(0);
Last
FRB provides an operator for watching the last value in an Array.
var array = [1, 2, 3];
var object = {array: array, last: null};
Bindings.defineBinding(object, "last", {"<-": "array.last()"});
expect(object.last).toBe(3);
array.push(4);
expect(object.last).toBe(4);
When the dust settles, array.last()
is equivalent to
array[array.length - 1]
, but the last
observer guarantees that it
will not jitter between the ultimate value and null or the penultimate
value of the collection. With array[array.length]
, the underlying may
not change its content and length atomically.
var changed = jasmine.createSpy();
PropertyChanges.addOwnPropertyChangeListener(object, "last", changed);
array.unshift(0);
array.splice(3, 0, 3.5);
expect(object.last).toBe(4);
expect(changed).not.toHaveBeenCalled();
array.pop();
expect(object.last).toBe(3);
array.clear();
expect(object.last).toBe(null);
Only
FRB provides an only
operator, which can either observe or bind the
only element of a collection. The only
observer watches a collection
for when there is only one value in that collection and emits that
value.. If there are multiple values, it emits null.
var object = {array: [], only: null};
Bindings.defineBindings(object, {
only: {"<->": "array.only()"}
});
object.array = [1];
expect(object.only).toBe(1);
object.array.pop();
expect(object.only).toBe(undefined);
object.array = [1, 2, 3];
expect(object.only).toBe(undefined);
The only
binder watches a value. When the value is null, it does
nothing. Otherwise, it will update the bound collection such that it
only contains that value. If the collection was empty, it adds the
value. Otherwise, if the collection did not have the value, it replaces
the collection's content with the one value. Otherwise, it removes
everything but the value it already contains. Regardless of the means,
the end result is the same. If the value is non-null, it will be the
only value in the collection.
object.only = 2;
expect(object.array.slice()).toEqual([2]);
// Note that slice() is necessary only because the testing scaffold
// does not consider an observable array equivalent to a plain array
// with the same content
object.only = null;
object.array.push(3);
expect(object.array.slice()).toEqual([2, 3]);
One
Like the only
operator, there is also a one
operator. The one
operator will observe one value from a collection, whatever value is
easiest to obtain. For an array, it's the first value; for a sorted
set, it's whatever value was most recently found or added; for a heap,
it's whatever is on top. However, if the collection is null, undefined,
or empty, the result is undefined
.
var object = {array: [], one: null};
Bindings.defineBindings(object, {
one: {"<-": "array.one()"}
});
expect(object.one).toBe(undefined);
object.array.push(1);
expect(object.one).toBe(1);
// Still there...
object.array.push(2);
expect(object.one).toBe(1);
Unlike only
, one
is not bindable.
Map
You can also create mappings from one array to a new array and an expression to evaluate on each value. The mapped array is bound once, and all changes to the source array are incrementally updated in the target array.
var object = {objects: [
{number: 10},
{number: 20},
{number: 30}
]};
bind(object, "numbers", {"<-": "objects.map{number}"});
expect(object.numbers).toEqual([10, 20, 30]);
object.objects.push({number: 40});
expect(object.numbers).toEqual([10, 20, 30, 40]);
Any function, like sum
or average
, can be applied to the result of a
mapping. The straight-forward path would be
objects.map{number}.sum()
, but you can use a block with any function
as a short hand, objects.sum{number}
.
Filter
A filter block generates an incrementally updated array filter. The
resulting array will contain only those elements from the source array
that pass the test deescribed in the block. As values of the source
array are added, removed, or changed such that they go from passing to
failing or failing to passing, the filtered array gets incrementally
updated to include or exclude those values in their proper positions, as
if the whole array were regenerated with array.filter
by brute force.
var object = {numbers: [1, 2, 3, 4, 5, 6]};
bind(object, "evens", {"<-": "numbers.filter{!(%2)}"});
expect(object.evens).toEqual([2, 4, 6]);
object.numbers.push(7, 8);
object.numbers.shift();
object.numbers.shift();
expect(object.evens).toEqual([4, 6, 8]);
Scope
In a binding, there is always a value in scope. It is the implicit
value for looking up properties and for applying operators, like
methods. The value in scope can be called out explicitly as this
. On
the left side, the value in scope is called the target, on the right it
is called the source.
Each scope has a this
value and may have a parent scope. Inside a
map block, like the number
in numbers.map{number}
, the value in
scope is one of the numbers, and the value in the parent scope is an
object with a numbers
property. To access the value in a parent
scope, use the parent scope operator, ^
.
Suppose you have an object with numbers
and maxNumber
properties.
In this example, we bind a property, smallNumbers
to an array of all
the numbers
less than or equal to the maxNumber
.
var object = Bindings.defineBindings({
numbers: [1, 2, 3, 4, 5],
maxNumber: 3
}, {
smallNumbers: {
"<-": "numbers.filter{this <= ^maxNumber}"
}
});
Keywords like this
overlap with the notation normally used for
properties of this
. If an object has a this
property, you may use
the notation .this
, this.this
, or this['this']
. .this
is the
normal form.
var object = Bindings.defineBindings({
"this": 10
}, {
that: {"<-": ".this"}
});
expect(object.that).toBe(object["this"]);
The only other FRB keywords that collide with propery names are true
,
false
, and null
, and the same technique for disambiguation applies.
Some and Every
A some
block incrementally tracks whether some of the values in a
collection meet a criterion.
var object = Bindings.defineBindings({
options: [
{checked: true},
{checked: false},
{checked: false}
]
}, {
anyChecked: {
"<-": "options.some{checked}"
}
});
expect(object.anyChecked).toBe(true);
An every
block incrementally tracks whether all of the values in a
collection meet a criterion.
var object = Bindings.defineBindings({
options: [
{checked: true},
{checked: false},
{checked: false}
]
}, {
allChecked: {
"<-": "options.every{checked}"
}
});
expect(object.allChecked).toBe(false);
You can use a two-way binding on some
and every
blocks.
var object = Bindings.defineBindings({
options: [
{checked: true},
{checked: false},
{checked: false}
]
}, {
allChecked: {
"<->": "options.every{checked}"
},
noneChecked: {
"<->": "!options.some{checked}"
}
});
object.noneChecked = true;
expect(object.options.every(function (option) {
return !option.checked
}));
object.allChecked = true;
expect(object.noneChecked).toBe(false);
The caveat of an equals
binding applies. If the condition for every
element of the collection is set to true, the condition will be bound
incrementally to true on each element. When the condition is set to
false, the binding will simply be canceled.
object.allChecked = false;
expect(object.options.every(function (option) {
return option.checked; // still checked
}));
Sorted
A sorted block generates an incrementally updated sorted array. The resulting array will contain all of the values from the source except in sorted order.
var object = {numbers: [5, 2, 7, 3, 8, 1, 6, 4]};
bind(object, "sorted", {"<-": "numbers.sorted{}"});
expect(object.sorted).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
The block may specify a property or expression by which to compare values.
var object = {arrays: [[1, 2, 3], [1, 2], [], [1, 2, 3, 4], [1]]};
bind(object, "sorted", {"<-": "arrays.sorted{-length}"});
expect(object.sorted.map(function (array) {
return array.slice(); // to clone
})).toEqual([
[1, 2, 3, 4],
[1, 2, 3],
[1, 2],
[1],
[]
]);
The sorted binding responds to changes to the sorted property by removing them at their former place and adding them back at their new position.
object.arrays[0].push(4, 5);
expect(object.sorted.map(function (array) {
return array.slice(); // to clone
})).toEqual([
[1, 2, 3, 4, 5], // new
[1, 2, 3, 4],
// old
[1, 2],
[1],
[]
]);
Unique and Sorted
FRB can create a sorted index of unique values using sortedSet
blocks.
var object = Bindings.defineBindings({
folks: [
{id: 4, name: "Bob"},
{id: 2, name: "Alice"},
{id: 3, name: "Bob"},
{id: 1, name: "Alice"},
{id: 1, name: "Alice"} // redundant
]
}, {
inOrder: {"<-": "folks.sortedSet{id}"},
byId: {"<-": "folks.map{[id, this]}.toMap()"},
byName: {"<-": "inOrder.toArray().group{name}.toMap()"}
});
expect(object.inOrder.toArray()).toEqual([
object.byId.get(1),
object.byId.get(2),
object.byId.get(3),
object.byId.get(4)
]);
expect(object.byName.get("Alice")).toEqual([
object.byId.get(1),
object.byId.get(2)
]);
The outcome is a SortedSet
data structure, not an Array
. The sorted
set is useful for fast lookups, inserts, and deletes on sorted, unique
data. If you would prefer a sorted array of unique values, you can
combine other operators to the same effect.
var object = Bindings.defineBindings({
folks: [
{id: 4, name: "Bob"},
{id: 2, name: "Alice"},
{id: 3, name: "Bob"},
{id: 1, name: "Alice"},
{id: 1, name: "Alice"} // redundant
]
}, {
index: {"<-": "folks.group{id}.sorted{.0}.map{.1.last()}"}
});
expect(object.index).toEqual([
{id: 1, name: "Alice"},
{id: 2, name: "Alice"},
{id: 3, name: "Bob"},
{id: 4, name: "Bob"}
]);
Min and Max
A binding can observe the minimum or maximum of a collection. FRB uses a binary heap internally to incrementally track the minimum or maximum value of the collection.
var object = Bindings.defineBindings({}, {
min: {"<-": "values.min()"},
max: {"<-": "values.max()"}
});
expect(object.min).toBe(undefined);
expect(object.max).toBe(undefined);
object.values = [2, 3, 2, 1, 2];
expect(object.min).toBe(1);
expect(object.max).toBe(3);
object.values.push(4);
expect(object.max).toBe(4);
Min and max blocks accept an expression on which to compare values from the collection.
var object = Bindings.defineBindings({}, {
loser: {"<-": "rounds.min{score}.player"},
winner: {"<-": "rounds.max{score}.player"}
});
object.rounds = [
{score: 0, player: "Luke"},
{score: 100, player: "Obi Wan"},
{score: 250, player: "Vader"}
];
expect(object.loser).toEqual("Luke");
expect(object.winner).toEqual("Vader");
object.rounds[1].score = 300;
expect(object.winner).toEqual("Obi Wan");
Group
FRB can incrementally track equivalence classes within in a collection. The group block accepts an expression that determines the equivalence class for each object in a collection. The result is a nested data structure: an array of [key, class] pairs, where each class is itself an array of all members of the collection that have the corresponding key.
var store = Bindings.defineBindings({}, {
"clothingByColor": {"<-": "clothing.group{color}"}
});
store.clothing = [
{type: 'shirt', color: 'blue'},
{type: 'pants', color: 'red'},
{type: 'blazer', color: 'blue'},
{type: 'hat', color: 'red'}
];
expect(store.clothingByColor).toEqual([
['blue', [
{type: 'shirt', color: 'blue'},
{type: 'blazer', color: 'blue'}
]],
['red', [
{type: 'pants', color: 'red'},
{type: 'hat', color: 'red'}
]]
]);
Tracking the positions of every key and every value in its equivalence
class can be expensive. Internally, group
blocks are implemented with
a groupMap
block followed by an entries()
observer. The groupMap
produces a Map
data structure and does not waste any time, but does
not produce range change events. The entries()
observer projects the
map of classes into the nested array data structure.
You can use the groupMap
block directly.
Bindings.cancelBinding(store, "clothingByColor");
Bindings.defineBindings(store, {
"clothingByColor": {"<-": "clothing.groupMap{color}"}
});
var blueClothes = store.clothingByColor.get('blue');
expect(blueClothes).toEqual([
{type: 'shirt', color: 'blue'},
{type: 'blazer', color: 'blue'}
]);
store.clothing.push({type: 'gloves', color: 'blue'});
expect(blueClothes).toEqual([
{type: 'shirt', color: 'blue'},
{type: 'blazer', color: 'blue'},
{type: 'gloves', color: 'blue'}
]);
The group
and groupMap
blocks both respect the type of the source
collection. If instead of an array you were to use a SortedSet
, the
equivalence classes would each be sorted sets. This is useful because
replacing values in a sorted set can be performed with much less waste
than with a large array.
View
Suppose that your source is a large data store, like a SortedSet
from
the [Collections][] package. You might need to view a sliding window
from that collection as an array. The view
binding reacts to changes
to the collection and the position and length of the window.
var SortedSet = require("collections/sorted-set");
var controller = {
index: SortedSet([1, 2, 3, 4, 5, 6, 7, 8]),
start: 2,
length: 4
};
var cancel = bind(controller, "view", {
"<-": "index.view(start, length)"
});
expect(controller.view).toEqual([3, 4, 5, 6]);
// change the window length
controller.length = 3;
expect(controller.view).toEqual([3, 4, 5]);
// change the window position
controller.start = 5;
expect(controller.view).toEqual([6, 7, 8]);
// add content behind the window
controller.index.add(0);
expect(controller.view).toEqual([5, 6, 7]);
Enumerate
An enumeration observer produces [index, value]
pairs. You can bind
to the index or the value in subsequent stages. The prefix dot
distinguishes the zeroeth property from the literal zero.
var object = {letters: ['a', 'b', 'c', 'd']};
bind(object, "lettersAtEvenIndexes", {
"<-": "letters.enumerate().filter{!(.0 % 2)}.map{.1}"
});
expect(object.lettersAtEvenIndexes).toEqual(['a', 'c']);
object.letters.shift();
expect(object.lettersAtEvenIndexes).toEqual(['b', 'd']);
Range
A range observes a given length and produces and incrementally updates an array of consecutive integers starting with zero with that given length.
var object = Bindings.defineBinding({}, "stack", {
"<-": "&range(length)"
});
expect(object.stack).toEqual([]);
object.length = 3;
expect(object.stack).toEqual([0, 1, 2]);
object.length = 1;
expect(object.stack).toEqual([0]);
Flatten
You can flatten nested arrays. In this example, we have an array of arrays and bind it to a flat array.
var arrays = [[1, 2, 3], [4, 5, 6]];
var object = {};
bind(object, "flat", {
"<-": "flatten()",
source: arrays
});
expect(object.flat).toEqual([1, 2, 3, 4, 5, 6]);
Note that changes to the inner and outer arrays are both projected into the flattened array.
arrays.push([7, 8, 9]);
arrays[0].unshift(0);
expect(object.flat).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
Also, as with all other bindings that produce arrays, the flattened array is never replaced, just incrementally updated.
var flat = object.flat;
arrays.splice(0, arrays.length);
expect(object.flat).toBe(flat); // === same object
Concat
You can observe the concatenation of collection of dynamic arrays.
var object = Bindings.defineBinding({
head: 10,
tail: [20, 30]
}, "flat", {
"<-": "[head].concat(tail)"
});
expect(object.flat).toEqual([10, 20, 30]);
The underlying mechanism is equivalent to [[head], tail].flatten()
.
Reversed
You can bind the reversal of an array.
var object = {forward: [1, 2, 3]};
bind(object, "backward", {
"<->": "forward.reversed()"
});
expect(object.backward.slice()).toEqual([3, 2, 1]);
object.forward.push(4);
expect(object.forward.slice()).toEqual([1, 2, 3, 4]);
expect(object.backward.slice()).toEqual([4, 3, 2, 1]);
Note that you can do two-way bindings, <->
with reversed arrays.
Changes to either side are updated to the opposite side.
object.backward.pop();
expect(object.backward.slice()).toEqual([4, 3, 2]);
expect(object.forward.slice()).toEqual([2, 3, 4]);
Has
You can bind a property to always reflect whether a collection contains a particular value.
var object = {
haystack: [1, 2, 3],
needle: 3
};
bind(object, "hasNeedle", {"<-": "haystack.has(needle)"});
expect(object.hasNeedle).toBe(true);
object.haystack.pop(); // 3 comes off
expect(object.hasNeedle).toBe(false);
The binding also reacts to changes to the value you seek.
// Continued from above...
object.needle = 2;
expect(object.hasNeedle).toBe(true);
has
bindings are not incremental, but with the right data-structure,
updates are cheap. The [Collections][] package contains Lists, Sets,
and OrderedSets that all can send ranged content change notifications and thus
can be bound.
// Continued from above...
var Set = require("collections/set");
object.haystack = new Set([1, 2, 3]);
expect(object.hasNeedle).toBe(true);
Likewise, Maps implement addMapChangeListener
, so you can use a has
binding
to observe whether an entry exists with the given key.
// Continued from above...
var Map = require("collections/map");
object.haystack = new Map([[1, "a"], [2, "b"]]);
object.needle = 2;
expect(object.hasNeedle).toBe(true);
object.needle = 3;
expect(object.hasNeedle).toBe(false);
has
bindings can also be left-to-right and bi-directional.
bind(object, "hasNeedle", {"<->": "haystack.has(needle)"});
object.hasNeedle = false;
expect(object.haystack.has(2)).toBe(false);
The collection on the left-hand-side must implement has
or contains
,
add
, and delete
or remove
. FRB shims Array
to have has
,
add
, and delete
, just like all the collections in [Collections][].
It happens that the classList
properties of DOM elements, when they
are supported, implement add
, remove
, and contains
.
var model = {darkMode: false};
bind(document.body, "classList.has('dark')", {
"<-": "darkMode",
source: model
});
The DOM classList
does not however implement
addRangeChangeListener
or removeRangeChangeListener
, so it
cannot be used on the right-hand-side of a binding, and such bindings
cannot be bidirectional. With some DOM [Mutation Observers][], you
might be able to help FRB overcome this limitation in the future.
Get
A binding can observe changes in key-to-value mappings in arrays and map [Collections][].
var object = {
array: [1, 2, 3],
second: null
};
var cancel = bind(object, "second", {
"<->": "array.get(1)"
});
expect(object.array.slice()).toEqual([1, 2, 3]);
expect(object.second).toBe(2);
object.array.shift();
expect(object.array.slice()).toEqual([2, 3]);
expect(object.second).toBe(3);
object.second = 4;
expect(object.array.slice()).toEqual([2, 4]);
cancel();
object.array.shift();
expect(object.second).toBe(4); // still
The source collection can be a Map, Dict, MultiMap, SortedMap,
SortedArrayMap, or anything that implements get
and
addMapChangeListener
as specified in [Collections][]. The key can
also be a variable.
var Map = require("collections/map");
var a = {id: 0}, b = {id: 1};
var object = {
source: new Map([[a, 10], [b, 20]]),
key: null,
selected: null
};
var cancel = bind(object, "selected", {
"<-": "source.get(key)"
});
expect(object.selected).toBe(undefined);
object.key = a;
expect(object.selected).toBe(10);
object.key = b;
expect(object.selected).toBe(20);
object.source.set(b, 30);
expect(object.selected).toBe(30);
var SortedMap = require("collections/sorted-map");
object.source = SortedMap();
expect(object.selected).toBe(undefined);
object.source.set(b, 40);
expect(object.selected).toBe(40);
cancel();
object.key = a; // no effect
expect(object.selected).toBe(40);
You can also bind the entire content of a map-like collection to the content of another. Bear in mind that the content of the source replaces the content of the target initially.
var Map = require("collections/map");
var object = {
a: new Map({a: 10}),
b: new Map()
};
var cancel = bind(object, "a.mapContent()", {"<->": "b.mapContent()"});
expect(object.a.toObject()).toEqual({});
expect(object.b.toObject()).toEqual({});
object.a.set('a', 10);
expect(object.a.toObject()).toEqual({a: 10});
expect(object.b.toObject()).toEqual({a: 10});
object.b.set('b', 20);
expect(object.a.toObject()).toEqual({a: 10, b: 20});
expect(object.b.toObject()).toEqual({a: 10, b: 20});
In this case, the source of the binding is a different object than the target, so the binding descriptor specifies the alternate source.
Keys, Values, Entries
If the source of a binding is a map, FRB can also translate changes to
the map into changes on an array. The keys
, values
, and entries
observers produce incrementally updated projections of the
key-value-mappings onto an array.
var Map = require("collections/map");
var object = Bindings.defineBindings({}, {
keys: {"<-": "map.keysArray()"},
values: {"<-": "map.valuesArray()"},
entries: {"<-": "map.entriesArray()"}
});
object.map = new Map({a: 10, b: 20, c: 30});
expect(object.keys).toEqual(['a', 'b', 'c']);
expect(object.values).toEqual([10, 20, 30]);
expect(object.entries).toEqual([['a', 10], ['b', 20], ['c', 30]]);
object.map.set('d', 40);
object.map.delete('a');
expect(object.keys).toEqual(['b', 'c', 'd']);
expect(object.values).toEqual([20, 30, 40]);
expect(object.entries).toEqual([['b', 20], ['c', 30], ['d', 40]]);
Coerce to Map
Records (Objects with a fixed shape), arrays of entries, and Maps
themselves can be coerced to an incrementally updated Map
with the
toMap
operator.
var object = Bindings.defineBindings({}, {
map: {"<-": "entries.toMap()"}
});
// map property will persist across changes to entries
var map = object.map;
expect(map).not.toBe(null);
object.entries = {a: 10};
expect(map.keysArray()).toEqual(['a']);
expect(map.has('a')).toBe(true);
expect(map.get('a')).toBe(10);
The toMap
observer maintains the insertion order of the keys.
// Continued...
object.entries = [['b', 20], ['c', 30]];
expect(map.keysArray()).toEqual(['b', 'c']);
object.entries.push(object.entries.shift());
expect(map.keysArray()).toEqual(['c', 'b']);
If the entries do not have unique keys, the last entry wins. This is
managed internally by observing, entries.group{.0}.map{.1.last()}
.
// Continued...
object.entries = [['a', 10], ['a', 20]];
expect(map.get('a')).toEqual(20);
object.entries.pop();
expect(map.get('a')).toEqual(10);
toMap
binds the content of the output map to the content of the input
map and will clear and repopulate the output map if the input map is
replaced.
// Continued...
object.entries = new Map({a: 10});
expect(map.keysArray()).toEqual(['a']);
Equals
You can bind to whether expressions are equal.
var fruit = {apples: 1, oranges: 2};
bind(fruit, "equal", {"<-": "apples == oranges"});
expect(fruit.equal).toBe(false);
fruit.orange = 1;
expect(fruit.equal).toBe(true);
Equality can be bound both directions. In this example, we do a two-way binding between whether a radio button is checked and a corresponding value in our model.
var component = {
orangeElement: {checked: false},
appleElement: {checked: true}
};
Bindings.defineBindings(component, {
"orangeElement.checked": {"<->": "fruit == 'orange'"},
"appleElement.checked": {"<->": "fruit == 'apple'"},
});
component.orangeElement.checked = true;
expect(component.fruit).toEqual("orange");
component.appleElement.checked = true;
expect(component.fruit).toEqual("apple");
Because equality and assignment are interchanged in this language, you
can use either =
or ==
.
FRB also supports a comparison operator, <=>
, which uses
Object.compare
to determines how two operands should be sorted in
relation to each other.
Array and Map Content
In JavaScript, arrays behave both like objects (in the sense that every
index is a property, but also like a map collection of index-to-value
pairs. The [Collections][] package goes so far as to patch up the
Array
prototype so arrays can masquerade as maps, with the caveat that
delete(value)
behaves like a Set instead of a Map.
This duplicity is reflected in FRB. You can access the values in an array using the object property notation or the mapped key notation.
var object = {
array: [1, 2, 3]
};
Bindings.defineBindings(object, {
first: {"<-": "array.0"},
second: {"<-": "array.get(1)"}
});
expect(object.first).toBe(1);
expect(object.second).toBe(2);
To distinguish a numeric property of the source from a number literal, use a dot. To distingish a mapped index from an array literal, use an empty expression.
var array = [1, 2, 3];
var object = {};
Bindings.defineBindings(object, {
first: {
"<-": ".0",
source: array
},
second: {
"<-": "get(1)",
source: array
}
});
expect(object.first).toBe(1);
expect(object.second).toBe(2);
Unlike property notation, map notation can observe a variable index.
var object = {
array: [1, 2, 3],
index: 0
};
Bindings.defineBinding(object, "last", {
"<-": "array.get(array.length - 1)"
});
expect(object.last).toBe(3);
object.array.pop();
expect(object.last).toBe(2);
You can also bind all of the content of an array by range or by
mapping. The notation for binding ranged content is rangeContent()
.
Every change to an Array or SortedSet dispatches range changes and any
collection that implements splice
and swap
can be a target for such
changes.
var SortedSet = require("collections/sorted-set");
var object = {
set: SortedSet(),
array: []
};
Bindings.defineBindings(object, {
"array.rangeContent()": {"<-": "set"}
});
object.set.addEach([5, 2, 6, 1, 4, 3]);
expect(object.array).toEqual([1, 2, 3, 4, 5, 6]);
The notation for binding the content of any mapping collection using map
changes is mapContent()
. On the target of a binding, this will note
when values are added or removed on each key of the source collection
and apply the same change to the target. The target and source can be
arrays or map collections.
var Map = require("collections/map");
var object = {
map: new Map(),
array: []
};
Bindings.defineBinding(object, "map.mapContent()", {
"<-": "array"
});
object.array.push(1, 2, 3);
expect(object.map.toObject()).toEqual({
0: 1,
1: 2,
2: 3
});
Value
A note about the source value: an empty path implies the source value. Using empty paths and empty expressions is useful in some situations.
If a value is ommitted on either side of an operator, it implies the
source value. The expression sorted{}
indicates a sorted array, where
each value is sorted by its own numeric value. The expression
filter{!!}
would filter falsy values. The operand is implied.
Similarly, filter{!(%2)}
produces only even values.
This is why you can use .0
to get the zeroth property of an array, to
distingiush the form from 0
which would be a numeric literal, and why
you can use ()[0]
to map the zeroeth key of a map or array, to
distinguish the form from [0]
which would be an array literal.
With Context Value
Expressions can be evaluated in the context of another value using a variant of property notation. A parenthesized expression can follow a path.
var object = {
context: {a: 10, b: 20}
};
Bindings.defineBinding(object, "sum", {
"<-": "context.(a + b)"
});
expect(object.sum).toBe(30);
Bindings.cancelBinding(object, "sum");
object.context.a = 20;
expect(object.sum).toBe(30); // unchanged
To observe a constructed array or object literal, the expression does not need parentheses.
var object = {
context: {a: 10, b: 20}
};
Bindings.defineBindings(object, {
"duple": {"<-": "context.[a, b]"},
"pair": {"<-": "context.{key: a, value: b}"}
});
expect(object.duple).toEqual([10, 20]);
expect(object.pair).toEqual({key: 10, value: 20});
Bindings.cancelBindings(object);
Operators
FRB can also recognize many operators. These are in order of precedence
unary -
negation, +
numeric coercion, and !
logical negation and
then binary **
power, //
root, %%
logarithm, *
, /
, %
modulo,
%%
remainder, +
, -
, <
, >
, <=
, >=
, =
or
==
, !=
, &&
and ||
.
var object = {height: 10};
bind(object, "heightPx", {"<-": "height + 'px'"});
expect(object.heightPx).toEqual("10px");
The unary +
operator coerces a value to a number. It is handy for
binding a string to a number.
var object = {
number: null,
string: null,
};
Bindings.defineBinding(object, "+number", {
"<-": "string"
});
object.string = '10';
expect(object.number).toBe(10);
Functions
FRB supports some common functions. startsWith
, endsWith
, and
contains
all operate on strings. join
concatenates an array of
strings with a given delimiter (or empty string). split
breaks a
string between every delimiter (or just between every character).
join
and split
are algebraic and can be bound as well as observed.
Conditional
FRB supports the ternary conditional operator, if ?
then :
else.
var object = Bindings.defineBindings({
condition: null,
consequent: 10,
alternate: 20
}, {
choice: {"<->": "condition ? consequent : alternate"}
});
expect(object.choice).toBe(undefined); // no choice made
object.condition = true;
expect(object.choice).toBe(10);
object.condition = false;
expect(object.choice).toBe(20);
The ternary operator can bind in both directions.
object.choice = 30;
expect(object.alternate).toBe(30);
object.condition = true;
object.choice = 40;
expect(object.consequent).toBe(40);
And
The logical and operator, &&
, observes either the left or right
argument depending on whether the first argument is both defined and
true. If the first argument is null, undefined, or false, it will stand
for the whole expression. Otherwise, the second argument will stand for
the whole expression.
If we assume that the first and second argument are always defined and either true or false, the and operator serves strictly as a logical combinator. However, with bindings, it is common for a value to at least initially be null or undefined. Logical operators are the exception to the rule that an expression will necessarily terminate if any operand is null or undefined.
In this example, the left and right sides are initially undefined. We
set the right operand to 10
and the bound value remains undefined.
var object = Bindings.defineBindings({
left: undefined,
right: undefined
}, {
and: {"<-": "left && right"}
});
object.right = 10;
expect(object.and).toBe(undefined);
We set the left operand to 20
. The bound value becomes the value of
the right operand, 10
.
// Continued...
object.left = 20;
expect(object.and).toBe(10);
Interestingly, logical and is bindable. The objective of the binding is to do whatever is necessary, if possible, to make the logical expression equal the bound value.
Supposing that both the left and right operands are false, and the
result is or becomes true, to satisfy the equality left && right ==
true
, both left and right must be set and bound to true
.
var object = Bindings.defineBindings({}, {
"left && right": {
"<-": "leftAndRight"
}
});
object.leftAndRight = true;
expect(object.left).toBe(true);
expect(object.right).toBe(true);
As with the equals binder, logic bindings will prefer to alter the left operand if altering either operand would suffice to validate the expression. So, if the expression then becomes false, it is sufficient to set the left side to false to satisfy the equality.
// Continued...
object.leftAndRight = false;
expect(object.left).toBe(false);
expect(object.right).toBe(true);
This can facilitate some interesting, tri-state logic. For example, if you have a checkbox that can be checked, unchecked, or disabled, and you want it to be unchecked if it is disabled, you can use logic bindings to ensure this.
var controller = Bindings.defineBindings({
checkbox: {
checked: false,
disabled: false
},
model: {
expanded: false,
children: [1, 2, 3]
}
}, {
"checkbox.checked": {"<->": "model.expanded && expandable"},
"checkbox.disabled": {"<-": "!expandable"},
"expandable": {"<-": "model.children.length > 0"}
});
expect(controller.checkbox.checked).toBe(false);
expect(controller.checkbox.disabled).toBe(false);
// check the checkbox
controller.checkbox.checked = true;
expect(controller.model.expanded).toBe(true);
// alter the model such that the checkbox is unchecked and disabled
controller.model.children.clear();
expect(controller.checkbox.checked).toBe(false);
expect(controller.checkbox.disabled).toBe(true);
Or
As with the and operator, the logical or is an exception to the rule that an expression is null, undefined, or empty if any of the operands are null or undefined. If both operands are defined and boolean, or expressions behave strictly within the realm of logic. However, if the values are non-boolean or even non-values, they serve to select either the left or right side based on whether the left side is defined and true.
If the first argument is undefined or false, the aggregate expression will evaluate to the second argument, even if that argument is null or undefined.
Suppose we bind or
to left || right
on some object. or
will be
undefined
initially, but if we set the right
to 10
, or
will
become 10
, bypassing the still undefined left side.
var object = Bindings.defineBindings({
left: undefined,
right: undefined
}, {
or: {"<-": "left || right"}
});
object.right = 10;
expect(object.or).toBe(10);
However, the left hand side takes precedence over the right if it is defined and true.
// Continued...
object.left = 20;
expect(object.or).toBe(20);
And it will remain bound, even if the right hand side becomes undefined.
object.right = undefined;
expect(object.or).toBe(20);
Aside: JavaScript’s
delete
operator performs a configuration change, and desugars toObject.defineProperty
, and is not interceptable with an ES5 setter. So, don't use it on any property that is involved in a binding. Setting to null or undefined should suffice.
Logical or is bindable. As with logical and, the binding performs the minimum operation necessary to ensure that the expression is equal. If the expression becomes true, and either of the operands are true, the nothing needs to change. If the expression becomes false, however, both operands must be bound to false. If the expression becomes true again, it is sufficient to bind the left operand to true to ensure that the expression as a whole is true. Rather than belabor the point, I leave as an exercise to the reader to apply DeMorgan’s Theorem to the documentation for logical and bindings.
Default
The default operator, ??
, is similar to the or, ||
operator,
except that it decides whether to use the left or right solely based on
whether the left is defined. If the left is null or undefined, the
aggregate expression will evaluate to the right expression. If the left
is defined, even if it is false, the result will be the left expression.
var object = Bindings.defineBindings({
left: undefined,
right: undefined
}, {
or: {"<-": "left ?? right"}
});
object.right = 10;
expect(object.or).toBe(10);
object.left = false;
expect(object.or).toBe(false);
The default operator is not bindable, but weirder things have happened.
Defined
The defined()
operator serves a similar role to the default operator.
If the value in scope is null or undefined, it the result will be false,
and otherwise it will be true. This will allow a term that may be
undefined to propagate.
var object = Bindings.defineBindings({}, {
ready: {
"<-": "value.defined()"
}
});
expect(object.ready).toBe(false);
object.value = 10;
expect(object.ready).toBe(true);
The defined operator is also bindable. If the source is or becomes
false, the target will be bound to null
. If the source is null or
false, the binding has no effect.
var object = Bindings.defineBindings({
value: 10,
operational: true
}, {
"value.defined()": {"<-": "operational"}
});
expect(object.value).toBe(10);
object.operational = false;
expect(object.value).toBe(undefined);
If the source becomes null or undefined, it will cancel the previous binding but does not set or restore the bound value. Vaguely becoming “defined” is not enough information to settle on a particular value.
object.operational = true;
expect(object.value).toBe(undefined);
However, another binding might settle the issue.
Bindings.defineBindings(object, {
"value == 10": {
"<-": "operational"
}
});
expect(object.value).toBe(10);
Algebra
FRB can automatically invert algebraic operators as long as they operate strictly on the left-most expressions on both the source and target are bindable properties.
In this example, the primary binding is notToBe <- !toBe
, and the
inverse binding is automatically computed toBe <- !notToBe
.
var caesar = {toBe: false};
bind(caesar, "notToBe", {"<->": "!toBe"});
expect(caesar.toBe).toEqual(false);
expect(caesar.notToBe).toEqual(true);
caesar.notToBe = false;
expect(caesar.toBe).toEqual(true);
FRB does algebra by rotating the expressions on one side of a binding to the other until only one independent property remains (the left most expression) on the target side of the equation.
convert: y <- !x
revert: x <- !y
convert: y <- x + a
revert: x <- y - a
The left-most independent variable on the right hand side becomes the
dependent variable on the inverted binding. At present, this only works
for numbers and when the left-most expression is a bindable property
because it cannot assign a new value to the literal 10. For example,
FRB cannot yet implicitly revert y <-> 10 + x
.
Literals
You may have noticed literals in the previous examples. String literals take the form of any characters between single quotes. Any character can be escaped with a back slash.
var object = {};
bind(object, "greeting", {"<-": "'Hello, World!'"});
expect(object.greeting).toBe("Hello, World!");
Number literals are digits with an optional mantissa.
bind(object, 'four', {"<-": "2 + 2"});
Tuples
Bindings can produce fixed-length arrays. These are most useful in conjunction with mappings. Tuples are comma-delimited and parantheses-enclosed.
var object = {array: [[1, 2, 3], [4, 5]]};
bind(object, "summary", {"<-": "array.map{[length, sum()]}"});
expect(object.summary).toEqual([
[3, 6],
[2, 9]
]);
Records
Bindings can also produce fixed-shape objects. The notation is comma-delimited, colon-separated entries, enclosed by curly-braces.
var object = {array: [[1, 2, 3], [4, 5]]};
bind(object, "summary", {
"<-": "array.map{{length: length, sum: sum()}}"
});
expect(object.summary).toEqual([
{length: 3, sum: 6},
{length: 2, sum: 9}
]);
The left hand side of an entry in a record is any combination of letters or numbers. The right side is any expression.
Parameters
Bindings can also involve parameters. The source of parameters is by default the same as the source. The source, in turn, defaults to the same as the target object. It can be specified on the binding descriptor. Parameters are declared by any expression following a dollar sign.
var object = {a: 10, b: 20, c: 30};
bind(object, "foo", {
"<-": "[$a, $b, $c]"},
parameters: object
});
Bindings also react to changes to the parameters.
object.a = 0;
object.b = 1;
object.c = 2;
expect(object.foo).toEqual([0, 1, 2]);
The degenerate case of the property language is an empty string. This
is a valid property path that observes the value itself. So, as an
emergent pattern, a $
expression by itself corresponds to the whole
parameters object.
var object = {};
bind(object, "ten", {"<-": "$", parameters: 10});
expect(object.ten).toEqual(10);
Elements and Components
FRB provides a #
notation for reaching into the DOM for an element.
This is handy for binding views and models on a controller object.
The defineBindings
method accepts an optional final argument,
parameters
, which is shared by all bindings (unless shadowed by a more
specific parameters object on an individual descriptor).
The parameters
can include a document
. The document
may be any
object that implements getElementById
.
Additionally, the frb/dom
is an experiment that monkey-patches the DOM
to make some properties of DOM elements observable, like the value
or
checked
attribute of an input
or textarea element
.
var Bindings = require("frb");
require("frb/dom");
var controller = Bindings.defineBindings({}, {
"fahrenheit": {"<->": "celsius * 1.8 + 32"},
"celsius": {"<->": "kelvin - 272.15"},
"#fahrenheit.value": {"<->": "+fahrenheit"},
"#celsius.value": {"<->": "+celsius"},
"#kelvin.value": {"<->": "+kelvin"}
}, {
document: document
});
controller.celsius = 0;
One caveat of this approach is that it can cause a lot of DOM repaint and reflow events. The [Montage][] framework uses a synchronized draw cycle and a component object model to minimize the cost of computing CSS properties on the DOM and performing repaints and reflows, deferring such operations to individual animation frames.
For a future release of Montage, FRB provides an alternate notation for
reaching into the component object model, using its deserializer. The
@
prefix refers to another component by its label. Instead of
providing a document
, Montage provides a serialization
, which in
turn implements getObjectForLabel
.
var Bindings = require("frb");
var controller = Bindings.defineBindings({}, {
"fahrenheit": {"<->": "celsius * 1.8 + 32"},
"celsius": {"<->": "kelvin - 272.15"},
"@fahrenheit.value": {"<->": "+fahrenheit"},
"@celsius.value": {"<->": "+celsius"},
"@kelvin.value": {"<->": "+kelvin"}
}, {
serializer: serializer
});
controller.celsius = 0;
Observers
FRB’s bindings use observers and binders internally. You can create an
observer from a property path with the observe
function exported by
the frb/observe
module.
var results = [];
var object = {foo: {bar: 10}};
var cancel = observe(object, "foo.bar", function (value) {
results.push(value);
});
object.foo.bar = 10;
expect(results).toEqual([10]);
object.foo.bar = 20;
expect(results).toEqual([10, 20]);
For more complex cases, you can specify a descriptor instead of the
callback. For example, to observe a property’s value before it
changes, you can use the beforeChange
flag.
var results = [];
var object = {foo: {bar: 10}};
var cancel = observe(object, "foo.bar", {
change: function (value) {
results.push(value);
},
beforeChange: true
});
expect(results).toEqual([10]);
object.foo.bar = 20;
expect(results).toEqual([10, 10]);
object.foo.bar = 30;
expect(results).toEqual([10, 10, 20]);
If the product of an observer is an array, that array is always updated
incrementally. It will only get emitted once. If you want it to get
emitted every time its content changes, you can use the contentChange
flag.
var lastResult;
var array = [[1, 2, 3], [4, 5, 6]];
observe(array, "map{sum()}", {
change: function (sums) {
lastResult = sums.slice();
// 1. [6, 15]
// 2. [6, 15, 0]
// 3. [10, 15, 0]
},
contentChange: true
});
expect(lastResult).toEqual([6, 15]);
array.push([0]);
expect(lastResult).toEqual([6, 15, 0]);
array[0].push(4);
expect(lastResult).toEqual([10, 15, 0]);
Nested Observers
To get the same effect as the previous example, you would have to nest your own content change observer.
var i = 0;
var array = [[1, 2, 3], [4, 5, 6]];
var cancel = observe(array, "map{sum()}", function (array) {
function contentChange() {
if (i === 0) {
expect(array.slice()).toEqual([6, 15]);
} else if (i === 1) {
expect(array.slice()).toEqual([6, 15, 0]);
} else if (i === 2) {
expect(array.slice()).toEqual([10, 15, 0]);
}
i++;
}
contentChange();
array.addRangeChangeListener(contentChange);
return function cancelRangeChange() {
array.removeRangeChangeListener(contentChange);
};
});
array.push([0]);
array[0].push(4);
cancel();
This illustrates one crucial aspect of the architecture. Observers return cancelation functions. You can also return a cancelation function inside a callback observer. That canceler will get called each time a new value is observed, or when the parent observer is canceled. This makes it possible to nest observers.
var object = {foo: {bar: 10}};
var cancel = observe(object, "foo", function (foo) {
return observe(foo, "bar", function (bar) {
expect(bar).toBe(10);
});
});
Bindings
FRB provides utilities for declaraing and managing multiple bindings on
objects. The frb
(frb/bindings
) module exports this interface.
var Bindings = require("frb");
The Bindings
module provides defineBindings
and cancelBindings
,
defineBinding
and cancelBinding
, as well as binding inspector
methods getBindings
and getBinding
. All of these take a target
object as the first argument.
The Bindings.defineBinding(target, descriptors)
method returns the
target object for convenience.
var target = Bindings.defineBindings({}, {
"fahrenheit": {"<->": "celsius * 1.8 + 32"},
"celsius": {"<->": "kelvin - 272.15"}
});
target.celsius = 0;
expect(target.fahrenheit).toEqual(32);
expect(target.kelvin).toEqual(272.15);
Bindings.getBindings
in that case would return an object with
fahrenheit
and celsius
keys. The values would be identical to the
given binding descriptor objects, like {"<->": "kelvin - 272.15"}
, but
it also gets annotated with a cancel
function and the default values
for any ommitted properties like source
(same as target
),
parameters
(same as source
), and others.
Bindings.cancelBindings
cancels all bindings attached to an object and
removes them from the bindings descriptors object.
Bindings.cancelBindings(target);
expect(Bindings.getBindings(object)).toEqual({});
Binding Descriptors
Binding descriptors describe the source of a binding and additional
parameters. Bindings.defineBindings
can set up bindings (<-
or
<->
), computed (compute
) properties, and falls back to
defining ES5 properties with permissive defaults (enumerable
,
writable
, and configurable
all on by default).
If a descriptor has a <-
or <->
, it is a binding descriptor.
FRB creates a binding, adds the canceler to the descriptor, and adds the
descriptor to an internal table that tracks all of the bindings defined
on that object.
var object = Bindings.defineBindings({
darkMode: false,
document: document
}, {
"document.body.classList.has('dark')": {
"<-": "darkMode"
}
});
You can get all the binding descriptors with Bindings.getBindings
, or a
single binding descriptor with Bindings.getBinding
. Bindings.cancel
cancels
all the bindings to an object and Bindings.cancelBinding
will cancel just
one.
// Continued from above...
var bindings = Bindings.getBindings(object);
var descriptor = Bindings.getBinding(object, "document.body.classList.has('dark')");
Bindings.cancelBinding(object, "document.body.classList.has('dark')");
Bindings.cancelBindings(object);
expect(Object.keys(bindings)).toEqual([]);
Converters
A binding descriptor can have a convert
function, a revert
function,
or alternately a converter
object. Converters are useful for
transformations that cannot be expressed in the property language, or
are not reversible in the property language.
In this example, a
and b
are synchronized such that a
is always
half of b
, regardless of which property gets updated.
var object = Bindings.defineBindings({
a: 10
}, {
b: {
"<->": "a",
convert: function (a) {
return a * 2;
},
revert: function (b) {
return b / 2;
}
}
});
expect(object.b).toEqual(20);
object.b = 10;
expect(object.a).toEqual(5);
Converter objects are useful for reusable or modular converter types and converters that track additional state.
function Multiplier(factor) {
this.factor = factor;
}
Multiplier.prototype.convert = function (value) {
return value * this.factor;
};
Multiplier.prototype.revert = function (value) {
return value / this.factor;
};
var doubler = new Multiplier(2);
var object = Bindings.defineBindings({
a: 10
}, {
b: {
"<->": "a",
converter: doubler
}
});
expect(object.b).toEqual(20);
object.b = 10;
expect(object.a).toEqual(5);
Reusable converters have an implied direction, from some source type to
a particular target type. Sometimes the types on your binding are the
other way around. For that case, you can use the converter as a
reverter. This merely swaps the convert
and revert
methods.
var uriConverter = {
convert: encodeURI,
revert: decodeURI
};
var model = Bindings.defineBindings({}, {
"title": {
"<->": "location",
reverter: uriConverter
}
});
model.title = "Hello, World!";
expect(model.location).toEqual("Hello,%20World!");
model.location = "Hello,%20Dave.";
expect(model.title).toEqual("Hello, Dave.");
Computed Properties
A computed property is one that gets updated with a function call when one of its arguments changes. Like a converter, it is useful in cases where a transformation or computation cannot be expressed in the property language, but can additionally accept multiple arguments as input. A computed property can be used as the source for another binding.
In this example, we create an object as the root of multiple bindings. The object synchronizes the properties of a "form" object with the window’s search string, effectively navigating to a new page whenever the "q" or "charset" values of the form change.
Bindings.defineBindings({
window: window,
form: {
q: "",
charset: "utf-8"
}
}, {
queryString: {
args: ["form.q", "form.charset"],
compute: function (q, charset) {
return "?" + QS.stringify({
q: q,
charset: charset
});
}
},
"window.location.search": {
"<-": "queryString"
}
});
Debugging with Traces
A binding can be configured to log when it changes and why. The trace
property on a descriptor instructs the binder to log changes to the
console.
Bindings.defineBindings({
a: 10
}, {
b: {
"<-": "a + 1",
}
});
Polymorphic Extensibility
Bindings support three levels of polymorphic extensibility depending on the needs of a method that FRB does not anticipate.
If an operator is pure, meaning that all of its operands are value types
that will necessarily need to be replaced outright if they every change,
meaning that they are all effectively stateless, and if all of the
operands must be defined in order for the output to be defined, it is
sufficient to just use a plain JavaScript method. For example,
string.toUpperCase()
will work fine.
If an operator responds to state changes of its one and only operand, an
object may implement an observer method. If the operator is foo
in
FRB, the JavaScript method is observeFoo(emit)
. The observer must
return a cancel function if it will emit new values after it returns, or
if it uses observers itself. It must stop emitting new values if FRB
calls its canceler. The emitter may return a canceler itself, and the
observer must call that canceler before it emits a new value.
This is an example of a clock. The clock.time()
is an observable
operator of the clock in FRB, implemented by observeTime
. It will
emit a new value once a second.
function Clock() {
}
Clock.prototype.observeTime = function (emit) {
var cancel, timeoutHandle;
function tick() {
if (cancel) {
cancel();
}
cancel = emit(Date.now());
timeoutHandle = setTimeout(tick, 1000);
}
tick();
return function cancelTimeObserver() {
clearTimeout(timeoutHandle);
if (cancel) {
cancel();
}
};
};
var object = Bindings.defineBindings({
clock: new Clock()
}, {
"time": {"<-": "clock.time()"}
});
expect(object.time).not.toBe(undefined);
Bindings.cancelBindings(object);
If an operator responds to state changes of its operands, you will need
to implement an observer maker. An observer maker is a function that
returns an observer function, and accepts observer functions for all of
the arguments you are expected to observe. The observer must also
handle a scope argument, usually just passing it on at run-time,
observe(emit, scope)
. Otherwise it is much the same.
FRB would delegate to makeTimeObserver(observeResolution)
for a
clock.time(ms)
FRB expression.
This is an updated rendition of the clock example except that it will observe changes to a resolution operand and adjust its tick frequency accordingly.
function Clock() {
}
Clock.prototype.observeTime = function (emit, resolution) {
var cancel, timeoutHandle;
function tick() {
if (cancel) {
cancel();
}
cancel = emit(Date.now());
timeoutHandle = setTimeout(tick, resolution);
}
tick();
return function cancelTimeObserver() {
clearTimeout(timeoutHandle);
if (cancel) {
cancel();
}
};
};
Clock.prototype.makeTimeObserver = function (observeResolution) {
var self = this;
return function observeTime(emit, scope) {
return observeResolution(function replaceResolution(resolution) {
return self.observeTime(emit, resolution);
}, scope);
};
};
var object = Bindings.defineBindings({
clock: new Clock()
}, {
"time": {"<-": "clock.time(1000)"}
});
expect(object.time).not.toBe(undefined);
Bindings.cancelBindings(object);
Polymorphic binders are not strictly impossible, but you would be mad to try them.
Reference
Functional Reactive Bindings is an implementation of synchronous, incremental object-property and collection-content bindings for JavaScript. It was ripped from the heart of the [Montage][] web application framework and beaten into this new, slightly magical form. It must prove itself worthy before it can return.
- functional: The implementation uses functional building blocks to compose observers and binders.
- generic: The implementation uses generic methods on collections,
like
addRangeChangeListener
, so any object can implement the same interface and be used in a binding. - reactive: The values of properties and contents of collections react to changes in the objects and collections on which they depend.
- synchronous: All bindings are made consistent in the statement that causes the change. The alternative is asynchronous, where changes are queued up and consistency is restored in a later event.
- incremental: If you update an array, it produces a content
change which contains the values you added, removed, and the
location of the change. Most bindings can be updated using only
these values. For example, a sum is updated by decreasing by the
sum of the values removed, and increasing by the sum of the values
added. FRB can incrementally update
map
,reversed
,flatten
,sum
, andaverage
observers. It can also incrementally updatehas
bindings. - unwrapped: Rather than wrap objects and arrays with observable
containers, FRB modifies existing arrays and objects to make them
dispatch property and content changes. For objects, this involves
installing getters and setters using the ES5
Object.defineProperty
method. For arrays, this involves replacing all of the mutation methods, likepush
andpop
, with variants that dispatch change notifications. The methods are either replaced by swapping the__proto__
or adding the methods to the instance withObject.defineProperties
. These techniques should [work][Define Property] starting in Internet Explorer 9, Firefox 4, Safari 5, Chrome 7, and Opera 12.
Architecture
- [Collections][] provides property, mapped content, and ranged
content change events for objects, arrays, and other collections.
For objects, this adds a property descriptor to the observed object.
For arrays, this either swaps the prototype or mixes methods into
the array so that all methods dispatch change events.
Caveats: you have to use aset
method on Arrays to dispatch property and content change events. Does not work in older Internet Explorers since they support neither prototype assignment or ES5 property setters. - observer functions for watching an entire object graph for incremental changes, and gracefully rearranging and canceling those observers as the graph changes. Observers can be constructed directly or with a very small query language that compiles to a tree of functions so no parsing occurs wh