Monday, June 10, 2013

Referencing DOM from JS: there must be a DRYer, safer way

In our JS apps at Coursera, here's what a typical Backbone view might look like:

var ReporterView = Backbone.View.extend({
  render: function() {
    this.$el.html(ReporterTemplate());
  },
  events: {
     'change .coursera-reporter-input': 'onInputChange'
     'click .coursera-reporter-submit': 'onSubmitClick'
  },
  onInputChange: function() {
    this.$('.coursera-reporter-submit').attr('disabled', null);
  },
  onSubmitClick: function() {
    this.model.set('title', this.$('.coursera-reporter-input').val());
    this.model.save();
  }
});

We render out a basic template, setup a few event listeners, and respond to them by manipulating the DOM or sending some data to the server. But there are a few things that irk me about this setup:

  • We are repeating those class names in multiple places.
  • We are using CSS class names for events and manipulation.

They've been irking for a while, but today I finally decided to try out a few approaches to those issues.


DRYing It Out

It worries me whenever I see class names repeated, because I know I have to either remember to update them if we change the class name, or I need full coverage tests. Ideally we'd have the latter for all our views, but hey, if there's something I can do to make my code generally safer, why not?

To avoid repeating those class names, I could store them in some sort of constants that are accessible anywhere in the view, and only access them via the constants. For example:

var ReporterView = Backbone.View.extend({
  dom: {
     SUBMIT_BUTTON: '.coursera-reporter-submit',
     INPUT_FIELD:   '.coursera-reporter-input'
  },
  render: function() {
    this.$el.html(ReporterTemplate());
  },
  events: function() {
    var events = {};
    events['change ' + this.dom.INPUT_FIELD]    = 'onInputChange';
    events['click ' +  this.dom.SUBMIT_BUTTON]  = 'onSubmitClick';
    return events;
  },
  onInputChange: function() {
    this.$(this.dom.SUBMIT_BUTTON).attr('disabled', null);
  },
  onSubmitClick: function() {
    this.model.set('title', this.$(this.dom.INPUT_FIELD).val());
    this.model.save();
  }
});

There are a few drawbacks to that approach: 1) we add more constants to our code, which may increase file size, and 2) our events definition gets a little bit more harder to read.

But it is certainly DRYer and there are also more benefits to it besides that, like easier-to-maintain testing code:

it('enables the submit button on change', function() {
  chai.expect(view.$(view.dom.SUBMIT_BUTTON).attr('disabled'))
 .to.be.equal('disabled');
  view.$(view.dom.INPUT_FIELD).trigger('change');
 chai.expect(view.$(view.dom.SUBMIT_BUTTON).attr('disabled'))
 .to.be.equal(undefined);
});

De-class-ification

It bothers me when I realize that I'm relying on CSS class names in my JS, because that means that:

  1. I need to create long, specific class names, since we compile the CSS used by our many views into just a few CSS bundles, and it would be a bad thing if two class names overlapped.
  2. I need to warn our designers not to touch those class names when they're doing a re-style of our HTML Jade templates, and/or I need to make sure that I have 100% coverage on the code using those class names.

I'd much rather have every CSS class name in our HTML templates be there purely for styling reasons, so that we only put effort into avoiding name collisions if it's actually necessary, and so that our designers can safely refactor the CSS independently of the JS.

An alternative to using CSS class names is to use data attributes instead, perhaps prefixing with js-* to indicate their use in JS. That would mean changing our selectors to something like:

var ReporterView = Backbone.View.extend({
  dom: {
     SUBMIT_BUTTON: '[data-js-submit-button]',
     INPUT_FIELD:   '[data-js-input-field]'
  },
...
});

There is a drawback to using data attributes, however: performance. According to local legend and this jsperf from Craig Patik, it is faster in all browsers to query by class instead of by data attributes, both via jQuery and the native document.querySelectorAll. Given that, a compromise would be to use CSS class names prefixed with js-* and to make it very clear to the engineering and design teams that they are not to be used in CSS class names. For example:

var ReporterView = Backbone.View.extend({
  dom: {
     SUBMIT_BUTTON: '.js-submit-button',
     INPUT_FIELD:   '.js-input-field'
  },
...
});

So, that's what I came up with. Now, your turn. What's your approach to referencing the DOM from your Backbone views or Javascript app? How does it do in terms of DRYness, future-proof-ness, testability, and all those fancy terms that may not actually exist?

No comments: