Use Cases
There's a few features we want to add the Aurelia binding system to cover common use cases and improve performance:
- One-time string interpolation bindings
Interpolation bindings use the one-way
binding mode by default. One way bindings observe the model's value and update the view when the model changes. If we know a model value is never going to change there's no need for this property observation overhead. We need a way to express a one-time
string interpolation binding.
- Throttle and debounce
The ability to rate-limit binding updates can come in handy. For example, maybe you have a search input bound to a property whose setter function calls your search API. It's common to debounce the user's data entry such that an API call is made only when the user has paused typing for 200 milliseconds.
Similarly, if you have a model value that updates at a continuously high rate you may want to throttle how often changes are pushed to the view so the value is readable.
- Ability to "signal" a binding to refresh
One-way and two-way bindings evaluate automatically when the model (or the view in the case of a two-way binding) changes. This behavior covers the vast majority of all binding scenarios. There are times when it's necessary to instruct a binding to re-evaluate, for example, consider gitter chat messages. Each message's timestamp is displayed in "relative" format ("just now", "a minute ago", "an hour ago", etc) and updates periodically as time passes.
To implement this feature in Aurelia you might create a value converter that uses the momentjs library's fromNow function to convert the date to a relative-time string: ${message.timestamp | fromNow}
. Of course this binding will never update because a chat message's timestamp never changes. As a workaround you might introduce a converter parameter that is updated on a 60 second interval: ${message.timestamp | fromNow:ticker}
. This isn't a terrible approach but it would be nice if we could express this more naturally and not need to expose a "ticker" prop on our view model every time we need to use the fromNow
value converter.
Binding Behaviors
The current plan to address these scenarios is to extend the binding language with a feature we're calling "Binding Behaviors". Binding behaviors are classes that control parts of a binding's lifecycle. Aurelia will ship with a few binding behaviors that cover the use cases described above. As with most everything in Aurelia, the implementation is extensible, enabling you to add your own behaviors using the ends with "BindingBehavior" naming convention or explicit resource type identification via metadata: @bindingBehavior(name)
. In these ways the binding behavior functionality is similar to value converters, however there are some key differences:
-
In a binding expression,
&
is used to denote a binding behavior:
<input value.bind="searchText & debounce:200" />
-
Binding behaviors have access to the binding instance and it's lifecycle events (
bind
andunbind
). -
Binding behaviors have the ability to intercept and control the synchronization process that occurs when the model changes (or when the view changes in two-way binding scenarios).
Prototype
I've spent some time prototyping this feature in Aurelia. It's been a fun and... challenging piece of work. Several repositories are involved:
- templating: support for binding behavior resources in the resource registry.
- binding: support for binding behavior expressions in the AST (abstract syntax tree). Binding behavior integration in binding expressions (
.bind
), listener binding expressions (.delegate
and.trigger
) and call binding expressions (.call
). - templating-binding: Binding behavior integration in interpolation binding expressions (
${...}
) - templating-resources: prototypes of the binding behaviors that will ship with Aurelia-
ThrottleBindingBehavior
,DebounceBindingBehavior
,SignalBindingBehavior
,OneTimeBindingBehavior
Binding Behavior Interface
Binding behavior classes must implement an interface that consists of one method, connect
, which is called at the beginning of the binding process. The connect method will be called with at least two arguments, the binding
instance and the source
(the model). If the binding behavior expression included some arguments they will be passed to the connect method as well.
The connect method isn't required to return a value. That said, any non-trivial binding behavior will return an object with one or more of the following callback functions:
-
unbind()
: called by the binding instance when unbinding. Useful for cleaning up/disposing the binding behavior. -
interceptUpdateTarget(updateTargetFn)
: called by the binding instance when setting up the subscription to model property changes. This gives the behavior an opportunity to intercept model changes to the view by wrapping theupdateTargetFn
. -
interceptUpdateSource(updateSourceFn)
: called by the binding instance when setting up the subscription to view property changes (in two-way binding scenarios). This gives the behavior an opportunity to intercept view changes to the model by wrapping theupdateSourceFn
.
Let's have a look at some actual binding behaviors. Keep in mind, these are just prototypes- the binding behavior implementation/design is not complete and will change...
OneTimeBindingBehavior
Perhaps the simplest possible behavior is the OneTimeBindingBehavior
. This behavior allows you to express one-time interpolation binding expressions like so: ${firstName & oneTime}
.
There's not much too it, on connect
it sets the binding's mode to ONE_TIME.
export class OneTimeBindingBehavior {
connect(binding) {
binding.mode = ONE_TIME;
}
}
### DebounceBindingBehavior
Here's the debounce binding behavior prototype. It uses simple logic to decide whether to debounce the view's changes to the model OR the model's changes to the view, depending on the characteristics of the binding instance. The ThrottleBindingBehavior
is very similar, the difference being in the rate-limiting logic.
export class DebounceBindingBehavior {
connect(binding, source, timeout = 200) {
var timeoutId = null, value, intercept, info;
// create an "interceptor" function that takes the property
// update function as input and returns a "wrapped" version
// containing the debounce logic.
intercept = updateFn => {
return newValue => {
value = newValue;
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
updateFn(value);
}, timeout);
};
};
// create the behavior "info" object that is the return value
// of this "connect" function.
info = {
unbind: () => {
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
}
};
// If mode is two-way we're dealing with an input. In this
// case, rate-limit the view's updates to the model.
// If the binding is an instance of the "Listener" binding
// we know this is a "delegate" or "trigger" binding such
// as "mousemove.delegate="mouseMoved($event)". Again, in this
// case we want to rate-limit the view's updates to the model.
// Otherwise rate-limit the view-model's updates to the view.
if (binding.mode === TWO_WAY || binding instanceof Listener) {
info.interceptUpdateSource = intercept;
} else {
info.interceptUpdateTarget = intercept;
}
return info;
}
}
Here's a couple examples using the throttle and debounce:
<!-- debouncing a text input -->
<input type="text" value.bind="searchText & debounce" />
<!-- explicitly specifying the timeout/rate (milliseconds) -->
<input type="text" value.bind="searchText & debounce:250" />
<!-- throttling mouse events -->
<div mousemove.delegate="mouseMoved($event) & throttle:500">
. . .
</div>
### SignalBindingBehavior Finally, let's take a look at the `SignalBindingBehavior` prototype. It's designed to be used like this:
markup:
<!-- give this interpolation binding a signal name of "tick" -->
${message.timestamp | fromNow & signal:'tick'}
javascript:
// periodically signal all bindings whose signal name is "tick"
setInterval(() => signaler.signal("tick"), 60000);
Here's the behavior implementation, there's isn't much to see- most of the logic is in the BindingSignaler
class.
@inject(BindingSignaler)
export class SignalBindingBehavior {
constructor(signaler) {
this.signaler = signaler;
}
connect(binding, source, name) {
// register the binding with the signaler.
var signaler = this.signaler;
signaler.registerBinding(binding, source, name);
// unhook the binding from the signaler when the binding "unbinded".
return {
unbind: () => {
signaler.unregisterBinding(binding);
}
}
}
}
BindingSignaler:
export class BindingSignaler {
constructor() {
this.bindings = {};
this.sources = {};
}
registerBinding(binding, source, name) {
var bindings = this.bindings[name] = this.bindings[name] || [],
sources = this.sources[name] = this.sources[name] || [];
bindings.push(binding);
sources.push(source);
}
unregisterBinding(binding) {
var bindings = this.bindings[name],
sources = this.sources[name],
index = bindings ? bindings.indexOf(binding) : -1;
if (index === -1) {
return;
}
bindings.splice(index, 1);
sources.splice(index, 1);
}
signal(name) {
var bindings = this.bindings[name],
sources = this.sources[name],
i = bindings ? bindings.length : 0,
binding, source, value;
while(i--) {
binding = bindings[i];
source = sources[i];
value = binding.sourceExpression.evaluate(source, binding.lookupFunctions);
if(value !== undefined){
binding.targetProperty.setValue(value);
}
}
}
}
## Links
- github issues: binding behaviors/signals, one-time interpolation binding, throttle/debounce
- feature branches: binding, templating-binding, templating, templating-resources
- live example: will create when we get closer to merging this feature into Aurelia
- "throttle" vs "debounce"