mikeric / rivets

Lightweight and powerful data binding.
http://rivetsjs.com
MIT License
3.23k stars 310 forks source link

A discussion on event binding #196

Closed alanchrt closed 11 years ago

alanchrt commented 11 years ago

I'm working on a project with rivets, and I wanted to see how common my use case is and maybe open up a discussion about an alternative approach to passing context to the event handler.

Here's the situation.

I'm using Stapes to build view-models that I bind to my DOM views. Here's an example of what one could look like:

MyViewModel = Stapes.subclass({
    constructor: function() {
        this.on('change', $.proxy(function() {
            // Open the sidebar?
        }, this));
    },

    toggleSidebar: function(event, binding) {
        if (binding.myViewModel.get('sidebarOpen'))
            binding.myViewModel.closeSidebar(event, binding); // not shown
        else
            binding.myViewModel.openSidebar(event, binding);
    },

    openSidebar: function(event, binding) {
        $('.sidebar').show();
        binding.myViewModel.set('sidebarOpen', true);
    }
});

rivets.bind($('.app'), {myViewModel: new MyViewModel()});

So, the part where it gets weird is binding this to the DOM:

<button data-on-click="myViewModel:toggleSidebar"></button>

The event gets fired, but the context of this passed to the method is event.target. The result is that to make references to methods on the object, I have to access it through the binding as shown above.

This is fine, but when I want to be able to call one of the event methods I'm using from another method in the class, I have to get access to the binding to inject the dependency. The constructor above is a good example. How do I open the sidebar? this.openSidebar() wouldn't work in the intuitive sense, because openSidebar() needs a binding to reference the view-model. The whole thing gets kind of hairy, and in my opinion, a bit unintuitive and ugly.

To rectify it in my app, I made the event binder work like this:

rivets.binders['on-*'] = {
    'function': true,

    unbind: function(el) {
        // Remove the binding
        if (this.handler)
            el.removeEventListener(this.args[0], this.handler);
    },

    routine: function(el, value) {
        // Remove any previous binding
        if (this.handler)
            el.removeEventListener(this.args[0], this.handler);

        // Listen for events
        el.addEventListener(this.args[0], this.handler = $.proxy(function(event) {
            // Pass events to handler with binding
            event.preventDefault();
            this.model[this.keypath](this, event);
        }, this));
    }
};

The listener that gets bound passes the rivets binding itself as the first parameter, the event as the second, and then sets the context of this to be the model itself (by just calling the method on the model). Obviously, I cheated and used the Zepto $.proxy, but an implementation of this with apply() is pretty straightforward.

The effect is that the above view-model becomes this, which behaves in a much more predictable way:

MyViewModel = Stapes.subclass({
    constructor: function() {
        this.on('change', $.proxy(function() {
            // Open the sidebar
            this.openSidebar();
        }, this));
    },

    toggleSidebar: function() {
        if (this.get('sidebarOpen'))
            this.closeSidebar(event, binding); // not shown
        else
            this.openSidebar();
    },

    openSidebar: function() {
        $('.sidebar').show();
        this.set('sidebarOpen', true);
    }
});

And, I don't lose access to anything I could access before, and even have greater access to the binding. If I wanted to access a person model that was bound to the view, it would look like this:

savePerson: function(binding) {
    var person = binding.models.person;
    person.save();
}

If I wanted to access the original target of the event, it would look like this:

hideParent: function(binding, event) {
    $(event.target).parent().hide();
}

I'm not sure if my use case is unique, but if not, would this make sense as the default event handler behavior in the core? I'd love to hear your thoughts on it.

mikeric commented 11 years ago

@alanctkc Since 0.5.4 there is a custom event handler API that you can define for all rivets-invoked event handlers. The default handler just mirrors a standard DOM event handler (called in the context of the element/target, with the event object passed in as the first argument) but also passes in a second argument — the model scope of the current view.

But that's just how it behaves out-of-the-box. You're encouraged to configure it however you like for your app. For example, if you want the handler to be called in the context of the model object, forgetting about the current target and the event object like you've shown above, you can define a handler like this:

rivets.configure({
  handler: function(context, ev, binding) {
    this.call(binding.model)
  }
})

To explain things a bit, the above handler function is called in the context of the actual handler function — this is so you can augment the way it gets called. You have a few object available that you can call the handler with; context which is the original context of the handler (likely the element/target), ev which is the event object, and binding with is the Rivets.Binding instance. You should have access to pretty much anything from the Rivets.js world that you'd want to pass to your event handler from that single binding instance.

Here's another example handler configuration that works similarly to the one above, but also passes in the event object and the model scope of the view (this is similar to how we have ours set up).

rivets.configure({
  handler: function(context, ev, binding) {
    this.call(binding.model, ev, binding.view.models)
  }
})

For some context, this is the original discussion that the feature stemmed from and here is the pull request for the initial implementation of the event handler configuration.

alanchrt commented 11 years ago

Nice! Wow, 10 steps ahead of me. I really like the flexibility. So sorry I somehow completely brushed past it in the docs. Thanks for taking the time to explain and show some examples.

Really loving working with Rivets. I'm in the process of re-implementing a project with un-opinionated libraries instead of the full MVC framework I was using, and it's been a lot of fun discovering the versatility in little things in Rivets. Thanks again.