canjs / can-observe

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

Integrating can-observe into CanJS #35

Open justinbmeyer opened 6 years ago

justinbmeyer commented 6 years ago

tldr; I'm having trouble figuring out how to integrate can-observe into CanJS to make it work well with TodoMVC.

Current Working Example

Problems:

Type Approaches:

Getter approaches

old-school constructor / prototype functions

// Defining an observable list type
Todo.List = observe(function(values){
  canReflect.update(this, values); // INIT INSTANCE
});
Todo.List.prototype = Object.create(Array.prototype)
canReflect.assign(Todo.List.prototype,{
  constructor: Todo.List // setup if you want .constructor to be setup right :-( 
  ... 
})
// Defining an observable for the VM
var AppVM = observe(function(props){
  canReflect.update(this, props); // INIT INSTANCE
});
canReflect.assign(AppVM.prototype,{

  filter: "active", // DEFAULT

  // getter getter
  get todosPromise(){
      if(!this.filter) {
        return Todo.getList({});
      } else {
        return Todo.getList({complete: this.filter === "complete"});
      }
  }
  // memoized function
  todosPromise: memoize(function(){ ... })

  // async getter as resolve function
  todosList: resolve(function(resolve){
      this.todosPromise.then(resolve);
  })
})
// Integrating with a component
Component.extend({
  ViewModel: {
    // how do we specify initialization code?
    init: function(props){   canReflect.update(this, props) },
    filter: "active", 
    ...
  }
})

class, no decorators

// Defining an observable list type
Todo.List = observe(class TodoList extends Array {
  constructor(values){
    canReflect.update(this, values); // common initialization code might not actually be needed
  }
})
// Defining an observable for the VM
var AppVM = observe(class AppVM {
   constructor(props){
     canReflect.update(this, props); // Initializing the observable instance
     if(!this.hasOwnProperty("filter")) { // setting up default
        this.filter ="active";
    }
   },
   get todosPromise(){
      if(!this.filter) {
        return Todo.getList({});
      } else {
        return Todo.getList({complete: this.filter === "complete"});
      }
  }
});

AppVM.prototype.todosPromise = memoize(function(){ ... });
AppVM.prototype.todosList = resolve(function(resolve){ ... });
// Integrating with a component
Component.extend({
  ViewModel: observe(class AppVM{
    // NO WAY TO INTEGRATE `todosPromise` and `todos`
  })
})

class and decorators

// Defining an observable list type
@observe
class TodoList extends Array {
  constructor(values){
    canReflect.update(this, values); // common initialization code
  }
}
Todo.List = TodoList
// Defining an observable for the VM
@observe
class AppVM {
   constructor(props){
     canReflect.update(this, props); // Initializing the observable instance
     if(!this.hasOwnProperty("filter")) { // setting up default
        this.filter ="active";
    }
   },
   get todosPromise(){ ...},
  @memoize
  todosPromise(){ ... }

  @resolve
  todoList(resolve) { ... }
});

AppVM.prototype.todosPromise = memoize(function(){ ... });
AppVM.prototype.todosList = resolve(function(resolve){ ... });
// Integrating with a component
Component.extend({
  ViewModel: observe(class AppVM{
    // NO WAY TO INTEGRATE `todosPromise` and `todos`
  })
})

improved can-define-like thing (can-observable)

// Defining an observable list type
Todo.List = observable.Array.extend({
  ...
});
// Defining an observable for the VM
var AppVM = observable.Object.extend({
  // default value
  filter: "active",

  // getter getter
  get todosPromise(){
      if(!this.filter) {
        return Todo.getList({});
      } else {
        return Todo.getList({complete: this.filter === "complete"});
      }
  }
  // memoized function
  todosPromise: memoize(function(){ ... })

  // async getter as resolve function
  todosList: resolve(function(resolve){
      this.todosPromise.then(resolve);
  })
})
// Integrating with a component
Component.extend({
  ViewModel: { /*... same as above ...*/ }
})

Getter approaches

The built-in getter

The problem with the built-in getter is that:

  1. A normal object's getter is called many times.
  2. The getter is typically on the prototype, but called with this as the instance. This makes implementation tricky.
var proto = observe({
    get fullName(){
        fullNameInstanceCalls.push(this);
        return this.first + " "+ this.last;
    }
});
var instance = observe(canReflect.assign( Object.create(proto), {first: "Justin", last: "Meyer"}));

// this needs to find the observation on 
canReflect.onKeyValue(instance,"fullName", function(){ });

ObservationRecorder.start();
var fullName = instance.fullName;
var record = ObservationRecorder.stop();

record.keyDependencies //-> Map{instance: Set["fullName"]}

We'd need to somehow "install" an Observation "upward" in the instance proxy during an "observing" read. While this is achievable, I'm wary of making it the default behavior. Objects re-call their getter over and over. This is why I'm sorta favoring the memoize approach. Though it would need some sugar for observing tests:

disconnect = someBindHelper( () =>  todo.todosPromise() )

Furthermore, to make these bind-able outside of reads, we'd need to have bindings able to create the "observation" ... likely by exploring the proxies beneath themselves. That's sorta odd (and hopefully not too expensive). memoize avoids this problem. A function is called. Memoize figures out the observable to bind to.

NOTE: an alternate, but easier way would be to create these "observation"s on initialization. But this would make things less dynamic.

NOTE NOTE: Getter would make things weirdly non-dynamic. To do things right, we'd have to bind on the proto's fullName definition. If that changed, redo everything.

Lifecycle hooks to the rescue?

NOTE: I very much want to avoid events

For things like async getters (maybe even getters), we could encourage people to use the lifecycle hooks: https://github.com/canjs/can-define/issues/288

Todo and TodoList

Use with old-school constructor / prototype functions

var Todo = observe(function(props){
    canReflect.update(this, props); // common initialization code :-(
});
Todo.prototype.complete = false; // Can setup defaults like this :-)

Todo.List = observe(function(values){
  canReflect.update(this, props); // common initialization code :-(
});
Todo.List.prototype = Object.create(Array.prototype)
canReflect.assign(Todo.List.prototype,{
  constructor: Todo.List // setup if you want .constructor to be setup right :-(
  get active(){
      return this.filter({complete: false});
  },
  get complete(){
      return this.filter({complete: true});
  },
  get allComplete(){
    return this.length === this.complete.length;
  },
  get saving(){
    return this.filter(function(todo){
      return todo.isSaving();
    });
  },
  updateCompleteTo: function(value){
    this.forEach(function(todo){
      todo.complete = value;
      todo.save();
    });
  },
  destroyComplete: function(){
    this.complete.forEach(function(todo){
      todo.destroy();
    });
  }
})

Use with class, no decorators

var Todo = observe(class Todo {
  constructor(){
    canReflect.assign(this, props); // common initialization code
    if(!this.hasOwnProperty("complete")) { // setting up default
        this.complete = false;
    }
  }
})

Use with class and decorators

@observe
class Todo {
  constructor(){
    canReflect.assign(this, props);              // common initialization code
    if(!this.hasOwnProperty("complete")) { // setting up default
        this.complete = false;
    }
  }
}

can-define ish thing (w/ proxy and some other improvements)

var Todo = observe.Map.extend("Todo",{
  complete: false
})
justinbmeyer commented 6 years ago

can-component new VM(props) vs canReflect.update(props)

matthewp commented 6 years ago

I think can-observe pairs better with plain objects and using can-compute directly, rather than OOP style with methods and getter/setters. Here's the TodoMVC example using observe+computes:


function todosVM(todos) {
    var active = compute(() => filter(todos, {complete: false}));
    var complete = compute(() => filter(todos, {complete: true}));
    var allComplete = compute(() => todos.length === complete().length);
    var saving = compute(() => filter(todo => isSaving(todo)));

    function updateCompleteTo(value) {
        todos.forEach(function(todo){
           todo.complete = value;
           saveTodo(todo);
        });
    }

    function destroyComplete() {
        complete().forEach(todo => destroyTodo(todo));
    }

    return {
        active, complete, allComplete, saving,
        updateCompleteTo, destroyComplete
    };
}

var todos = observe([]);
var vm = todosVM(todos);

document.body.appendChild(view(vm));
justinbmeyer commented 6 years ago

@matthewp how would this work w/ composition / can-component?

Also, this doesn't show a version of the async getter logic (which requires some sort of binding and changing of a value, coupled with the ability to tear-down that binding)

matthewp commented 6 years ago

Probably something like the old viewModel: function(){}. I'm not sure if this would still work or not, but something like this:

Component.extend({
  tag: "todo-app",
  view,

  viewModel: function(props, scope) {
    var todos = scope.compute("todos");
    return todosVM(todos);
  }
});

Since todos is a compute now, the todosVM implementation would change to call the function in all of the places where it needs an array.

justinbmeyer commented 6 years ago

Here's what todomvc might look like:

class Todo extends observe.Object {
    constructor(props) {
        super(props);
        this.complete = false;
    }
}

class TodoList extends observe.Array {
    get active() {
        return this.filter({
            complete: false
        });
    }
    get complete() {
        return this.filter({
            complete: true
        });
    }
    get allComplete() {
        return this.length === this.complete.length;
    }
    get saving() {
        return this.filter(function(todo) {
            return todo.isSaving();
        });
    }
    updateCompleteTo: function(value) {
        this.forEach(function(todo) {
            todo.complete = value;
            todo.save();
        });
    }
    destroyComplete: function() {
        this.complete.forEach(function(todo) {
            todo.destroy();
        });
    }
}

superMap({
    url: "/api/todos",
    Map: Todo,
    List: TodoList,
    name: "todo",
    algebra: todoAlgebra
});

Component.extend({
    tag: "todo-create",
    view: stache.from("todo-create-template"),
    ViewModel: class TodoCreateVM extends observe.Object {
        constructor(props) {
            super(props);
            this.todo = new Todo();
        }
        createTodo: function() {
            this.todo.save().then(() => {
                this.todo = new Todo();
            });
        }
    }
});

Component.extend({
    tag: "todo-list",
    view: stache.from("todo-list-template"),
    ViewModel: class TodoListVM extends observe.Object {
        isEditing(todo) {
            return todo === this.editing;
        }
        edit(todo) {
            this.backupName = todo.name;
            this.editing = todo;
        }
        cancelEdit() {
            if (this.editing) {
                this.editing.name = this.backupName;
            }
            this.editing = null;
        }
        updateName() {
            this.editing.save();
            this.editing = null;
        }
    }
});

class AppVM extends observe.Object {
    connectedCallback() {
        this.listensTo("todosPromise", (promise) => {
            promise.then((todos) => {
                this.todosList = todos;
            });
        });
    }
    get todosPromise() {
        if (!this.filter) {
            return Todo.getList({});
        } else {
            return Todo.getList({
                complete: this.filter === "complete"
            });
        }
    }
    get allChecked() {
        return this.todosList && this.todosList.allComplete;
    }
    set allChecked(newVal) {
        this.todosList && this.todosList.updateCompleteTo(newVal);
    }
}

route.data = new AppVM();
route("{filter}");
route.start();

can.Component.extend({
    tag: "todo-mvc",
    viewModel: route.data,
    view: stache.from("todomvc-template")
});
justinbmeyer commented 6 years ago

can-tag proposal: https://gist.github.com/justinbmeyer/ecf98e0ff7bcc166493877438f5c3740

Things to know:

Other: