quinta-feira, 11 de agosto de 2016

Bringing order to a Marionette app: data binding

One of the problems i stumbled in my first working Marionette application was data binding / templating. I used a mix of Handlebars for read only views and Stickit for forms.

This solution showed its shortcomings soon, as described in an early post. So i decided to change the strategy and use a markup based binding solution, which is becoming quite popular (Aurelia, Vue etc). In the Backbone world, the most popular libraries are Rivets and Epoxy, the former being the chosen one since fits my needs better.

Here are the remarks of the migration with the advantages and problems found

Meeting Share Monitor

This is an app to monitor the stock prices of Bovespa (Brazilian stock exchange). It stores the owned shares with buy price and compare with current prices to see how much the investor won (or loose). While simple and only targeted for my personal usage, is fully functional.

Technically is a web mobile app, that uses Ionic CSS to style. The share buy info is stored in local storage. There’s also a backend written in Pascal that retrieves the stock info from Yahoo finance (needed to avoid same origin issue and normalize the data format). Sources here. Online version here.

Problem 1: dynamic DOM update

The app fetches stock info regularly from the backend and updates the owned stocks prices and state. Here’s the code to update each list view item using Handlebars and jQuery:

//template
  <div class="col">{{name}}</div>
  <div class="col stock-price">
    {{formatCurrency latestPrice}}
  </div>
  <div class="col stock-variation {{ifThen (isNegative latestVariation) 'negative-value'}}">
    <i class="ion-arrow-{{ifThen (isNegative latestVariation) 'down' 'up'}}-a"></i> {{suffix (formatFloat (absolute latestVariation)) '%'}}
  </div>
  <i class="icon ion-chevron-right icon-accessory"></i> 
  //view
  onLatestPriceChange: function () {
    var price = this.model.get('latestPrice');
    var variation = this.model.get('latestVariation');
    var variationText = variation ? Math.abs(variation).toFixed(2) + '%' : '--';
    var iconClass = 'ion-arrow-' + (variation > 0 ? 'up': 'down') + '-a';
    this.$el.find('.stock-variation').toggleClass('negative-value', variation < 0)
      .contents().filter(function() {
      return this.nodeType == 3 && this.textContent.trim().length > 0
    }).replaceWith(' ' + variationText);

    this.$el.find('.stock-variation i').removeClass('ion-arrow-up-a ion-arrow-down-a').addClass(iconClass);
    this.$el.find('.stock-price').html(price ? 'R/pre> + price.toFixed(2) : '--');
  }

I could just call render and replace the content altogether but seems a bit overkill just to update the value of a few nodes values and classes.

Problem 2: collection view boilerplate

I used a CompositeView to hold the list of owned stocks (can’t remember exactly why not used a CollectionView). This is the template:

<ul id="symbol-list" class="list">
</ul>

That’s right. A separated template file with two lines of pretty generic code. To assemble the list, needs two view classes each own with a bunch of configuration:

var OwnedSymbolItemView = Marionette.ItemView.extend({
 template: require('../templates/ownedsymbolitem.hbs'),
 tagName: 'li',
 className: 'item item-icon-right row',
  modelEvents: {
    'change:latestPrice': 'onLatestPriceChange'
  },
  events: {
    'click': 'onClick'
  },
  onLatestPriceChange: function () {
    [..]
  },
  onClick: function (e) {
    e.preventDefault();
    Radio.channel('navigation').request('goToPage', 'symboldetails', this.model.get('id'));
  }
});

var OwnedSymbolsView = Marionette.CompositeView.extend({
  template: require('../templates/ownedsymbols.hbs'),
  childView: OwnedSymbolItemView,
  childViewContainer: '#symbol-list',
  pageHeader: {..},
  onAddBuy: function () {
      Radio.channel('navigation').request('goToPage', 'newbuy');
  }
});

Now the code after migration to rivets:

var OwnedSymbolsView = Marionette.ItemView.extend({
  template: false,
  html: require('../templates/ownedsymbols.html'),
  pageHeader: {..},
  onBeforeRender: function () {
    if (this.view) {
      this.view.unbind();
      this.view = null;
    }
    this.attachElContent(this.html);
    this.view = rivets.bind(this.el, this);
  },
  onDestroy: function () {
    if (this.view) {
      this.view.unbind();
      this.view = null;
    }
  },
  onAddBuy: function () {
      Radio.channel('navigation').request('goToPage', 'newbuy');
  },
  onItemClick: function (e, scope) {
    Radio.channel('navigation').request('goToPage', 'symboldetails', scope.model.get('id'));
  }
});

A single view class with just the necessary code to initialize rivets binding, which can easily extracted into a behavior.

Template diff. View diff.

Problem 3: form boilerplate

I used Stickit to bind forms. With rivets i got rid of code like:

bindings: {
    '[name=symbol]': {
      observe: 'symbol',
      events: ['blur']
    },
    '[name=price]': {
      observe: 'price',
      onGet: 'numToStr',
      onSet: 'strToNum'
    },
    '[name=quantity]': {
      observe: 'quantity',
      onGet: 'numToStr',
      onSet: 'strToNum'
    },
    '[name=date]': 'date'
  },
  strToNum: function (val) {
      if (val != null) {
        return +val;
      }
    },
    numToStr: function (val) {
      if (val != null) {
        return val.toString();
      }
    }

Also could simplify the binding of view related computed values. With Stickit i needed to create a separated Backbone Model to hold the view state and bind it separately from actual model. Now i can use a simple object to hold the state, so no more:

this.stickit();
    this.stickit(this.state, {
      '#remove-buy': {
        observe: 'isNew',
        onGet: function (value) {
          return !value
        },
        visible: true
      },
      '[name=symbol]':{
        attributes: [
           {
          name: 'disabled',
          observe: 'hasSymbol',
         onGet: function (value) {
           return value
         }
        }]
      }
    })

Template diff. View diff.

Final remarks

  • The view code simplification accomplished with the migration to Rivets by far outweighs the extra markup required
  • Rivets is not as light weight as it seems. Every single binding instance holds the global library options and attaches bound functions at will. The iteration each binder copies scope properties to every child view regardless of nest level
  • I had to add some functionality in rivets, like ability to change the event used by input
  • The Backbone adapter recommends to bind to Collection.models with the default adapter but this will intercept direct changes to models array defeating the purpose of the adapter

Nenhum comentário:

Postar um comentário