Monday, October 24, 2011

Refactoring Large Backbone.js Views into Sub-Views

‹prev | My Chain | next›

Tonight, I continue to refactor the calendar view in my Backbone.js application:


The appointments on the calendar are small Backbone views. The calendar itself is a recent convert to a Backbone view, the template of which is:
<script type="text/template" id="calendar-template">
<table>
  <thead>
    <tr>
      <th>S</th>
      <th>M</th>
      <th>T</th>
      <th>W</th>
      <th>T</th>
      <th>F</th>
      <th>S</th>
    </tr>
  </thead>
  <tbody>
  {[ _([0,1,2,3,4,5]).each(function(i) { ]}
  <tr class="week{{ i }}">
    <td class="sunday"></td>
    <td class="monday"></td>
    <td class="tuesday"></td>
    <td class="wednesday"></td>
    <td class="thursday"></td>
    <td class="friday"></td>
    <td class="saturday"></td>
  </tr>
  {[ }); ]}
  </tbody>
</table>
</script>
After the Calendar view renders, I have a fixer-upper method work with that empty calendar to add date IDs to the <td> cells and to draw the day of the month number inside the cells:
        var Calendar = new (Backbone.View.extend({
          el: $('#calendar'),
          template: _.template($('#calendar-template').html()),
          setDate: function(date) {
            this.date = date;
            this.render();
          },
          render: function() {
            $('span.year-and-month', 'h1').html(' (' + this.date + ')');

            $(this.el).html(this.template({
              date: this.date
            }));

            this.addDates();
            this.hideEmptyWeeks();

            return this;
          },
          addDates: function() {
            var firstOfTheMonth = Helpers.firstOfTheMonth(this.date),
                month = firstOfTheMonth.getMonth(),
                firstSunday = new Date(firstOfTheMonth.getTime() -
                                firstOfTheMonth.getDay()*24*60*60*1000);

            var date = firstSunday;
            _([0,1,2,3,4,5]).each(function(week) {
              var week_el = $('.week' + week, this.el),
                  day_elements = week_el.find('td');

              _([0,1,2,3,4,5,6]).each(function(day) {
                var day_element = day_elements[day],
                    other_month = (date.getMonth() != month),
                    html = '<span class="day-of-month">' + date.getDate() + '</span>';

                $(day_element).
                  html(html).
                  attr('id', Helpers.to_iso8601(date)).
                  addClass(other_month ? 'other-month' : '');

                date = Helpers.dayAfter(date);
              });
            });
          },
          hideEmptyWeeks: function() {
            // remove whole week from next month
            var week5_other_month = _($('td', '.week5')).all(function(el) {
              return $(el).hasClass('other-month')
            });
            if (week5_other_month) $('.week5').hide()
          }
        }));
Yikes! One thing I definitely notice (aside from tons to code) it that I repeat the iteration of the weeks both in the tempate and in the fixer-upper method. What this tells me is that I either need to move all of the calculation into the template or into Backbone views. Somehow doing this in templates feels wrong, so I give Backbone views a try.

First, I eliminate the template and all of that fixer-upper code. My calendar's render() then becomes nothing more than a call to two other views: a calendar header view (the days of the week at the top of the calendar) and the calendar body:
        var Calendar = new (Backbone.View.extend({
          el: $('#calendar'),
          setDate: function(date) {
            this.date = date;
            this.render();
          },
          render: function() {
            $('span.year-and-month', 'h1').html(' (' + this.date + ')');

            $(this.el).html('<table></table>');

            var table = $('table', this.el);

            var header = new CalendarMonthHeader({parent: table})
            header.render();

            var body = new CalendarMonthBody({
              parent: table,
              date: this.date
            });
            body.render();

            return this;
          }
        }));
That is considerably less code than my template + fixer-upper. But how much work is needed in the various sub-views?

The calendar header is blissfully simple:
        var CalendarMonthHeader = Backbone.View.extend({
          tagName: 'tr',
          initialize: function(options) {
            this.parent = options.parent;
          },
          render: function() {
            $(this.el).html('<th>S</th><th>M</th><th>T</th><th>W</th><th>T</th><th>F</th><th>S</th>');
            this.parent.append(this.el);
          }
        });
Simple, but then again, there are no calculations required. What about the rest of the calendar?

The body view class delegates all rendering to sub-views. Specifically, it iterates over weeks, creating new calendar month week views:
        var CalendarMonthBody = Backbone.View.extend({
          initialize: function(options) {
            this.parent = options.parent;
            this.date = options.date;
          },
          render: function() {
            var firstOfTheMonth = Helpers.firstOfTheMonth(this.date),
                month = firstOfTheMonth.getMonth(),
                firstSunday = new Date(firstOfTheMonth.getTime() -
                                firstOfTheMonth.getDay()*24*60*60*1000);

            var date = firstSunday;
            while (date == firstSunday || date.getMonth() <= month) {
              var week = new CalendarMonthWeek({
                parent: this.parent,
                date: date
              });
              week.render();

              date = Helpers.weekAfter(date);
            }
          }
        });
Most of the effort in that code is date calculations, which are a pain in Javascript.

The week view does nothing more than create a table row to which day views are attached:
        var CalendarMonthWeek = Backbone.View.extend({
          tagName: 'tr',
          initialize: function(options) {
            this.parent = options.parent;
            this.date = options.date;
          },
          render: function() {
            this.parent.append(this.el);

            var date = this.date;
            for (var i=0; i<7; i++) {
              var day = new CalendarMonthDay({
                parent: $(this.el),
                date: date
              });
              day.render();

              date = Helpers.dayAfter(date);
            }
          }
        });
And finally, the day view build table cells with the ISO 8601 date for the ID (used by the appointment views to attached to the calendar) and a <span> tag containing the day of the month:
        var CalendarMonthDay = Backbone.View.extend({
          tagName: 'td',
          initialize: function(options) {
            this.parent = options.parent;
            this.date = options.date;
          },
          render: function() {
            this.parent.append(this.el);

            this.el.id = Helpers.to_iso8601(this.date);
            var html = '<span class="day-of-month">' + this.date.getDate() + '</span>';
            $(this.el).html(html);

            return this;
          }
        });
Phew! Amazingly, that works:
I am none too certain that this is all that superior a solution to the template. The view code in the template was not too overwhelming. Still, each of these views is small and self-contained. Small and self-contained makes for easier testing and maintainability.

Day #184

No comments:

Post a Comment