Extending Aurelia's Binding Language

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 and unbind).

  • 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 the updateTargetFn.

  • 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 the updateSourceFn.

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);
      }
    }
  }
}