canjs / can-observe

Observable objects
https://canjs.com/doc/can-observe.html
MIT License
20 stars 2 forks source link

Enable mixing in "observable"-based decorators #39

Closed justinbmeyer closed 6 years ago

justinbmeyer commented 6 years ago

I want to make it possible to easily mixin behaviors like async computes and the new resolver observables:

ViewModel extends observe.Object {
  get todosPromise(){
    return Todo.getList({filter: this.filter})
  },
  @async
  todos(resolve){
    this.todosPromise.then(resolve)
  },
  @resolver
  requestCounter(prop){
    var count = 0;
    prop.resolve(count);
    prop.listenTo("todosPromise", function(){
      prop.resolve( ++count );
    })
  }
}

We need to be able to make it easy to mixin these observables in a performant way similar to can-define. can-define is able to lazily construct observables for instances only when the instance property is actually bound, read or set.

As this is beyond what Object.defineProperty enables (only get and set, no binding lifecycle hooks), we need to make something ourselves.

Here's some background on how can-define works. can-define keeps a definitions object on the prototype with a computedInitializers object:

Person.prototype.definitions.computedInitializers = { ... }

A computedInitializer object looks like a object of property names to functions that return a computeObj:

{
  fullName() {
    return {
      // NOTICE how GETTER needs to be available in the scope
      compute: new Observation( GETTER, this),
      oldValue: undefined,  // cache the old value for performance reasons
      count: 0, // number of bindings
      handler: (newVal) => { // forward events to map
        var oldValue = computeObj.oldValue;
        computeObj.oldValue = newVal;
        this.dispatch({ type: prop, target: this }, [newVal, oldValue]); 
      }
    }
  }
}

can-define also creates a lazily-defined _computed property on the prototype (~ denotes lazy). When _computed is read for the first time, it will create create an object of lazily defined getters that return the computeObj from above:

Person.prototype~_computed = function(){
  var instanceComputed = {};
  for(initializerFn of computedInitializers) {
    instanceComputed~[prop] = initializerFn.bind(this)
  }
  return instanceComputed;
}

NOTE: the lazily defined computed property is always read during initialization of the define map instance. I think this is to work better with sealed instances, but I'm not 100% sure. This means that it really doesn't need to written as lazy. We could have had a this._computed = this.initializeComputed() sort of thing in the constructor.

Putting this another way, when an instance is created, it does some partial work to make a _computed object on the instance that lazily creates the computedObj. The partial work is really just making sure fullName above will be called with the right this.

Finally, can-define just looks in _computed when binding and unbinding.

Making this useful

We want nice APIs that people can hook into. I'll start with rough proposals that work backwards in terms of performance:

installing an already instantiated observable value as a property on an instance

var obs = observe({});
obs[can.computed].fullName = {count, handler, compute, oldValue}

NOTES:

  • compute should probably be called observable
  • ideally we could just set the observable and everything else would happen auto-magically. Perhaps the computed object should be a proxy itself? Scary.

lazily installing observable property behavior on an instance

var obs = observe({});
defineLazyValue(obs[can.computed],"fullName", function(){
  return {count, handler, compute: new Observation( ..., this ), oldValue}
})

lazily installing observable property behavior on all instances via the prototype

Person.prototype[can.computedDefinitions].fullName = function(){
  return {count, handler, compute: new Observation( ..., this ), oldValue}
}
justinbmeyer commented 6 years ago

So the problem is really that we want to see if we have a computedInitializer and created the computedObj for our specific instance if it hasn't already been created.

WeakSets might provide a better way of doing this. Given the instance and the computedDefinitions, we should be able to do what we need.

Only problem w/ weakSets is that it's not as easy to trace.

justinbmeyer commented 6 years ago

Goals:

justinbmeyer commented 6 years ago

on inheritance ...

// When can we do this?  On new SuperHero()
SuperHero.prototype.computedDefinitions = Object.create( Person.prototype.computedDefinitions )
matthewp commented 6 years ago

I think there should be some lower-level resolver and async, etc. functions. The signature would be resolver(fn) -> fn. Then the decorators could use these under the hood adding whatever decorator stuff is necessary.

The use-case is to have this sort of function in non-class based APIs. For example can-element will (likely) not be class based. Since decorators don't work on object literal methods we would need a functional composition method, something like:

computed: {
  fullName: resolver(function({resolve}){
    // ...
  })
}
christopherjbaker commented 6 years ago

@matthewp https://github.com/canjs/can-observe/pull/40/commits/6e756941efd9f5685deb26d1d9b57d413a64aacd Oh, I misread your comment. I added the resolver decorator, but not lower level functions.

There is an addComputedPropertyDefinition method that these are using under the hood. The following is literally all the code (other than dev-only error checks) for the @resolver decorator.

addComputedPropertyDefinition(target, key, function(instance, property) {
  return new ResolverObservable(method, instance);
});

The asyncGetter decorator is a little more complex (because it accepts a getter and a method, and handles them a little differently, and to change the call signature on the method). For methods:

addComputedPropertyDefinition(target, key, function(instance, property) {
  var observable = new AsyncObservable(function() {
    method.call(this, observable.resolve);
  }, instance, config.default);

  return observable;
});

For getters:

addComputedPropertyDefinition(target, key, function(instance, property) {
  var observable = new AsyncObservable(function() {
    var promise = getter.call(this);
    if (promise !== undefined) {
      if (promise !== null && typeof promise.then === "function") {
        promise.then(observable.resolve);
      }
      //!steal-remove-start
      else {
        throw new Error("asyncGetter: getters must return undefined or a promise.");
      }
      //!steal-remove-end
    }
  }, instance, config.default);

  return observable;
});

I could certainly wrap these so they are more easily usable without decorators, though the syntaxes would need to be called with the instance or prototype (depending on if you have a singleton or a class), teh key to store it under, and the function to run.