ractivejs / ractive

Next-generation DOM manipulation
http://ractive.js.org
MIT License
5.94k stars 396 forks source link

Declarative DOM event binding #27

Closed Rich-Harris closed 11 years ago

Rich-Harris commented 11 years ago

This is a common pattern:

<div class='dialog'>
  <p>{{dialogText}}</p>
  <a id='okay' class='button'>OK</a>
</div>
view = new Ractive({
  el: '#container',
  template: dialogTemplate,
  data: { dialogText: 'Press OK to close this dialog' }
};

document.getElementById( 'okay' ).addEventListener( 'click', function () {
  view.teardown();
});

This is fine, as far as it goes, and using jQuery or similar makes it slightly less verbose. But the fact that we're giving an element an ID just so we can easily find it a moment later and bind event handlers to it is a bit... yucky. It goes against the spirit of declarative programming to have to throw in these hooks. Sure, the ID could be more descriptive - 'closeDialog' or something - but that's not really what IDs are for. Our abstractions are leaking.

It would be nicer to be able to do something like this:

<div class='dialog'>
  <p>{{dialogText}}</p>
  <a on-click='closeDialog' class='button'>OK</a>
</div>
view = new Ractive({
  el: '#container',
  template: dialogTemplate,
  data: { dialogText: 'Press OK to close this dialog' }
};

view.on( 'closeDialog', function () {
  this.teardown();
});

That way things are better separated. It also allows us to define multiple behaviours in a nicer way:

view.on({
  collapse: function () {
    // code goes here
  },
  expand: function () {
    // code goes here
  }
});

Some additional thoughts right off the bat: it would be useful to pass along the event data, and also the element that was the subject of the DOM event (i.e. the event's target or one of its ancestors). Normally with event handlers, this is the element, but within a view.on() handler this === view, and it probably isn't a good idea to change that. A good compromise would be to pass along the element as an argument:

view.on( 'open', function ( event, el ) {
  var target;

  if ( event.shiftKey ) {
    el = el.getAttribute( 'data-target' ); // or whatever
    // do something
  }
});

So. The real problem to figure out here is what syntax to use in templates to make this happen. It's probably best to use an attribute, as many editors barf if you start throwing illegal characters around inside a document. The colour coding gets messed up. That matters, because the moment that starts happening everything begins to feel like a hack.

For that reason I'm not all that keen on something like Ember's {{action}} helper.

Angular does it using an ng-click attribute (though rather than an event label, you're using pseudo-JavaScript which corresponds to a method on the relevant controller's $scope object). This isn't a bad solution - it means the template doesn't validate as HTML, but who really cares? We could do something similar with rv-click, or something. I'm not in love with the aesthetics though.

One thing we definitely shouldn't do is use an on-click attribute or similar, example above notwithstanding. That's too similar to onclick where the value is eval'ed as JavaScript in the global context. Ugly, ugly, ugly stuff.

Two final considerations:

Rich-Harris commented 11 years ago

I quite like this syntax:

<div class='dialog'>
  <p>{{dialogText}}</p>
  <a click='closeDialog' class='button'>OK</a>
</div>

It sort of implies that click is a variable and closeDialog is the value, which I suppose it is. It gets right to the point and is aesthetically pleasing (to me at least). AFAIK there are no event names that are also valid element attributes, so no collisions. And it is easy to test whether we're dealing with an event directive or an attribute:

if ( node[ 'on' + attrName ] !== undefined ) {
  // event directive
} else {
  // attribute
}

For the initial implementation I will ignore situations where you need to pass along additional data, or have values that include mustaches. If they turn out to be necessary we can update the implementation.

Rich-Harris commented 11 years ago

Hmm. On second thoughts this doesn't work so well. It really needs to be something that can be identified at compile-time, rather than at render time. A good way to do so (and also to disambiguate between these and regular attributes) would be to prefix every attribute. A short but accurately descriptive prefix is proxy:

<div class='dialog'>
  <p>{{dialogText}}</p>
  <a proxy-click='closeDialog' class='button'>OK</a>
</div>

I'm going to go with that.