After many hours of digging and reading, I have a proof of concept for a decorator that allows you to specify a BindingBehaviour
from the ViewModel
. Still quite a lot of testing to do before it’s useable. Posting here in case anyone has any comments / suggestions. I may tidy it up and publish it as a package if I can get it working well enough.
Usage
Take the following bindable property:
@bindable({ bindingMode: bindingMode.twoWay })
test = "";
For pre-registered behaviours (such as throttle and debounce) you can pass in the name as a string (followed by any arguments) like so:
@bindable({ bindingMode: bindingMode.twoWay })
@useBindingBehavior("throttle", 3000)
test = "";
Where you have your own custom behaviors, you can pass in a class (followed by any arguments) like so:
@bindable({ bindingMode: bindingMode.twoWay })
@useBindingBehavior(MyCustomBindingBehavior, "arg1", 2)
test = "";
(Note: you do not have to <require>
the behavior in the view as it is automatically registered).
You can also stack the decorator to use multiple behaviors:
@bindable({ bindingMode: bindingMode.twoWay })
@useBindingBehavior("throttle", 3000)
@useBindingBehavior(MyCustomBindingBehavior, "arg1", 2)
test = "";
Code
Below is the POC code (needs a lot of cleaning up). If you are wondering about Some()
and None()
. they are type guards used for a custom option type Maybe<T>
. Just read them as null / undefined checks.
import { Binding, BindingBehavior, camelCase, Container, Expression, LookupFunctions, Scope, ViewResources } from "aurelia-framework";
import { None, Some } from './lib/utils/Maybe';
// Gets the camelCased name for the behavior
const resolveName = (nameOrClass: any) => {
const name = (typeof nameOrClass === "string") ?
nameOrClass : nameOrClass.name;
if (name.endsWith('BindingBehavior'))
return camelCase(name.substring(0, name.length - 15));
return camelCase(name);
}
// Register the binding behavior resource
const registerBindingBehavior = (nameOrClass: any) => {
const viewResources = Container.instance
.get(ViewResources);
const name = resolveName(nameOrClass);
const behavior = viewResources
.getBindingBehavior(name);
if (Some(behavior))
return;
if (typeof nameOrClass === "string")
throw `Could not find behavior named ${name}`;
const instance = Container.instance
.get(nameOrClass as any);
if (None(instance))
throw `Could not get instance of ${name}`;
viewResources.registerBindingBehavior(
name, instance);
}
// Create a wrapped binding function to replace
// the one on the behaviour class
const getPatchedBind = (...args: any[]) => {
return function (
this: any, binding: Binding, scope: Scope, lookupFunctions: LookupFunctions) {
// Everything from here until the last line is
// copied from source.
if (this.expression.expression && this.expression.bind) {
this.expression.bind(binding, scope, lookupFunctions);
}
let behavior = lookupFunctions.bindingBehaviors(this.name);
if (!behavior) {
throw new Error(`No BindingBehavior named "${this.name}" was found!`);
}
let behaviorKey = `behavior-${this.name}`;
if ((binding as any)[behaviorKey]) {
throw new Error(`A binding behavior named "${this.name}" has already been applied to "${this.expression}"`);
}
(binding as any)[behaviorKey] = behavior;
// This is the line we replace, so we can pass in
// our resolved arguments. Normally, this line would
// get the list of arguments as Expressions, then eval
// them, then pass them in. We already have the resolved
// args, so we just pass them in directly.
behavior.bind.apply(behavior, [binding, scope, ...args]);
}
}
// We need to traverse the list of bindings and
// find ones that match our property name. This
// is a basic (and probably not very robust) way
// to get it from the source expression. It assumes
// no other binding behaviors or value converters.
const resolvePropertyName = (expression: any): any => {
// Ensure no nested properties for now.
if (expression.isAssignable === true && expression.ancestor === 0) {
return expression.name;
}
return null;
}
// Our decorator creator
export const useBindingBehavior = (nameOrClass: any, ...args: any[]) => {
if (None(nameOrClass))
throw "No bindingbehaviour or name provided.";
const name = resolveName(nameOrClass);
// The decorator
return (target: any, key: any) => {
const oldActivate = target["activate"];
// Wrap the old activate function with ours.
target["activate"] = (...activateArgs: any[]) => {
const viewResources = Container.instance.get(ViewResources);
// Register view engine hooks.
viewResources.registerViewEngineHooks({
beforeCreate: (viewFactory: any) => {
// Register our binding behavior in the view
registerBindingBehavior(nameOrClass);
},
beforeBind: (context: any) => {
// Todo, pick the correct binding(s)
context.bindings.forEach((binding: any) => {
const resolvedPropertyName =
resolvePropertyName(
binding.sourceExpression)
// Not a match
if (resolvedPropertyName !== key)
return;
const newExpression = new BindingBehavior(
binding.sourceExpression as Expression,
name,
[],
) as any;
// Swap the original bind funtion for our
// patched one.
newExpression.bind = getPatchedBind(...args);
// Replace the original source expression
binding.sourceExpression = newExpression;
})
},
});
// Call the original activate function as well.
if (oldActivate) {
oldActivate(...activateArgs);
}
}
}
}
Edit: Fixed mistake with args being passed to oldActivate.