How to add bindable attributes or any other decora

2020-02-13 05:06发布

问题:

I want to extend the behavior and data of a class using decorators instead of inheritance. I also want to apply decorators as well to the newly created properties or methods. Is there an example of how to do this? Is this even possible?

Imagine a set of classes where some of these classes share a bindable property named span. Also let there be a computed property named leftMargin dependent on the span property. The desired way to implement this would be to decorate the class with a decorator named @addSpan for example, that adds both the bindable property and the computed property to the class.

回答1:

TL;DR: Scroll to the bottom for a complete code snippet.

Adding bindable properties using a decorator and thus implementing composition instead of inheritance is possible, though not as easy as one might guess. Here's how to do it.

The Approach

Lets imagine we have multiple components that calculate the square of a number. For this two properties are required: One taking the base number as input (we'll call this property baseNumber) and one providing the result of the calculation (let's call this property result). The baseNumber-property needs to be bindable so we can pass a value in. The result-property needs to depend on the baseNumber-property, because if the input changes, for sure will the result.

Neither do we want to implement the calculation over and over again in our properties. Nor can we use inheritance here, because inheriting bindable and computed properties in Aurelia is not possible at the time of writing this. It might not be the best of the bets for our application architecture, too.

So in the end we would like to use a decorator to add the requested functionality to our class:

import { addSquare } from './add-square';

@addSquare
export class FooCustomElement {
  // FooCustomElement now should have
  // @bindable baseNumber: number;
  // @computedFrom('baseNumber') get result(): number {
  //   return this.baseNumber * this.baseNumber;  
  //}
  // without us even implementing it!
}

The Simple Solution

If you need to place just a bindable property on your class, things are simple. You can just invoke the bindable decorator manually. That works, because under the hood decorators are nothing more than functions actually. So to get a simple bindable property the following code is enough:

import { bindable } from 'aurelia-framework';

export function<T extends Function> addSquare(target: T) {
  bindable({
    name: 'baseNumber'
  })(target);
}

This call to the bindable-function adds a property named baseNumber to the decorated class. You can assign or bind a value to the property just like this:

<foo base-number.bind="7"></foo>
<foo base-number="8"></foo>

You can of course also use the string interpolation syntax to bind to display this property's value: ${baseNumber}.

The Challenge

The challenge however is to add another property that is computed using the value provided by the baseNumber-property. For a proper implementation we need to access the value of the baseNumber-property. Now decorators like our addSquare-decorator are not evaluated during instanciation of a class, but rather during the declaration of a class. Unfortunately during this phase there simply is no instance we could possibly read the desired value from.

(This does not hinder us to use the bindable-decorator in first place, because this also is a decorator function. Thus it expects to be applied during declaration of a class and is implemented accordingly).

The computedFrom-decorator in Aurelia is a different matter. We cannot use it the same way as we did with the bindable-decorator, because it assumes that the decorated property already exists on the class instance.

So implementing a computed property from our newly created bindable one seems to be a pretty impossibly thing right?

Well, luckily there is an easy way to access the instance of a decorated class from within the decorator: By extending its constructor function. In the extended constructor we can then add a computed property that has access to instance-members of our decorated class.

Creating a Computed Property

Before showing how all parts fit together let me explain how we can manually add a computed property to a class in its constructor:

// Define a property descriptor that has a getter that calculates the
// square number of the baseNumber-property.
let resultPropertyDescriptor = {
  get: () => {
    return this.baseNumber * this.baseNumber;
  }
}

// Define a property named 'result' on our object instance using the property
// descriptor we created previously.
Object.defineProperty(this, 'result', resultPropertyDescriptor);

// Finally tell aurelia that this property is being computed from the
// baseNumber property. For this we can manually invoke the function
// defining the computedFrom decorator. 
// The function accepts three arguments, but only the third one is actually 
// used in the decorator, so there's no need to pass the first two ones.
computedFrom('baseNumber')(undefined, undefined, resultPropertyDescriptor);

The Complete Solution

To bring everything together we need to accomplish several steps:

  • Create a decorator function that takes the constructor of our class
  • Add a bindable property named baseNumber to the class
  • Extend the constructor to add our own computed property named result

The following snippet defines a decorator named addSquare that fulfills the requirements stated above:

import { bindable, computedFrom } from 'aurelia-framework';

export function addSquare<TConstructor extends Function>(target: TConstructor) {

  // Store the original target for later use
  var original = target;

  // Define a helper function that helps us to extend the constructor
  // of the decorated class.
  function construct(constructor, args) {

    // This actually extends the constructor, by adding new behavior
    // before invoking the original constructor with passing the current
    // scope into it.
    var extendedConstructor: any = function() {

      // Here's the code for adding a computed property
      let resultPropertyDescriptor = {
        get: () => {
          return this.baseNumber * this.baseNumber;
        }
      }
      Object.defineProperty(this, 'result', resultPropertyDescriptor);
      computedFrom('baseNumber')(target, 'result', resultPropertyDescriptor);

      // Here we invoke the old constructor.
      return constructor.apply(this, args);
    }

    // Do not forget to set the prototype of the extended constructor
    // to the original one, because otherwise we would miss properties
    // of the original class.
    extendedConstructor.prototype = constructor.prototype;

    // Invoke the new constructor and return the value. Mind you: We're still
    // inside a helper function. This code won't get executed until the real
    // instanciation of the class!
    return new extendedConstructor();
  }

  // Now create a function that invokes our helper function, by passing the
  // original constructor and its arguments into it.
  var newConstructor: any = function(...args) {
    return construct(original, args);
  }

  // And again make sure the prototype is being set correctly.
  newConstructor.prototype = original.prototype;

  // Now we add the bindable property to the newly created class, much
  // as we would do it by writing @bindinable on a property in the definition
  // of the class.
  bindable({
    name: 'baseNumber',
  })(newConstructor);

  // Our directive returns the new constructor so instead of invoking the
  // original one, javascript will now use the extended one and thus enrich
  // the object with our desired new properties.
  return newConstructor;
}

And we're done! You can see the whole thing in action here: https://gist.run/?id=cc3207ee99822ab0adcdc514cfca7ed1

One more thing

Adding properties dynamically at runtime will unfortunately break your TypeScript development experience. The decorator introduces two new properties, but the TypeScript compiler has no means to know about them at compiletime. Someone suggested an improvement to TypeScript enhancing this behavior however over at GitHub, but this suggestions is far from being actually implemented, because this introduces quite some interesting questions and challenges. So should you need to access one of the newly created properties from the code of your class you could always cast your instance to any:

let myVariable = (<any>this).baseNumber;

While this works, this is neither type safe nor does it look nice. With a bit more effort you could both make the code look nice and type safe. All you need to do is to implement an interface providing the new properties:

export interface IHasSquare {
    baseNumber: number;
    result: number;
}       

Simply assigning the interface to our class won't work though: remember, the newly created properties only exist at runtime. To use the interface we can implement a property on our class that returns this, but previously cast it to IHasSquare. To trick the compiler however into allowing this, we need to cast this to any first however:

get hasSquare(): IHasSquare {
    return <IHasSquare>(<any>this);
}

Kudos to atsu85 for pointing out casting this to an interface it does not implement actually can work!