sábado, 29 de outubro de 2016

Bringing order to a Marionette app: routing

As pointed in a previous post, i was using the recommended strategy of using the router as an entry point and handling state change through application interfaces instead of letting the URL changes drive the state transition.

While it worked, i soon realized that was not a DRY or scalable approach. So, after searching - and not finding - for libraries that provides the desired functionality (nested routes, async friendly), i decided to bite the bullet and implement an alternative.

Instead of relying on Backbone.Router or writing one from scratch, i used cherrytree as underlying engine, keeping to me the work to map the route transitions to Marionette concepts. What is accomplished so far:

Load data asynchronous


This is one of the key functionalities of routing libraries. In my previous approach, a pages object was defined with each entry defining the path and view options, the later being resolved asynchronous. Like below:

  pages: {
    newbuy: {
      path: 'newbuy',
      options: function(symbolName) {
        if (symbolName) {
          return dataService.getOwnedSymbols().then(function (data) {
             return {symbol: data.findWhere({name: symbolName})};
          });
        } else {
          return {symbol: null}
        }
      }
    }
  }

When no view class is explicitly defined it fallbacks to require the page name. dataService.getOwnedSymbols does an ajax request, caches the response and returns a promise

With the new routing strategy, each route is defined in a class and an activate hook is resolved asynchronous. The view options is resolved in an separated method:

//new buy
export default Route.extend({

  viewClass: ShareBuyView,

  viewOptions() {
    return {symbol: this.symbol}
  },

  activate(transition){
    let symbolName   
    if (symbolName = transition.params.symbolname) {
      return dataService.getOwnedSymbols().then((data) => {
        this.symbol = data.findWhere({name: symbolName});
      });
    }
  }
})

Its nicer, more organized but with same functionality, nothing that most Backbone routers could not handle

Nested routes / states


Since the app can be started with any URL and only one route is activated at time, in all routes i had to call dataService.getOwnedSymbols to ensure the availability data.

Here, the nested feature shines. When a nested route is activated we are sure that all parent routes where successfully activated, allowing to write something like:

//parent route
export default Route.extend({

  initialize() {
    Radio.channel('data').reply('ownedsymbols', this.getOwnedSymbols, this)
  },

  getOwnedSymbols() {
    return this.ownedsymbols  },

  activate(){
    return dataService.getOwnedSymbols().then((symbols) => {
      this.ownedsymbols = symbols
    })
  }
})

//child route
export default Route.extend({

  viewOptions() {
    return {
      collection: this.ownedsymbols    }
  },

  activate(){
    this.ownedsymbols = Radio.channel('data').request('ownedsymbols')
  }
})

Related commit

Note that in child route activate, the data is retrieved with a request to the Radio handler configured in the parent route. Since this radio call is synchronous we could even call it inside viewOptions making the activate hook optional here.

Route context


Setting global Radio channels and calling them through application is fine but can become hard to maintain as the project size and number of channels / registered requests grows.  An alternative is to pass down the required data into the application hierarchy, another approach that starts to be annoying as the hierarchy level count increases.

In the application i'm doing Marionette experiments, in some routes is necessary to retrieve a symbol instance. Since there are multiple symbols, this data is not suited to be stored / retrieved with a Radio global channel, so the current approach is to first load the global data than use it to find the symbol instance like below:

export default Route.extend({
  activate(transition){
    let buy   

    let ownedsymbols = Radio.channel('data').request('ownedsymbols')
    let symbol = ownedsymbols.findWhere({id: transition.params.symbolid});
    if (!symbol) {
      throw 'Unable to find symbol ' + transition.params.symbolid    }
    buy = symbol.buys.findWhere({id: transition.params.sharebuyid});
    if (!buy) {
      throw 'buy not found ' + transition.params.sharebuyid    }
    this.buy = buy    this.symbol = symbol  }
})
That's where the concept of route context comes in handy as a way to provide scoped data with easiness of Radio:

//a parent abstract route, activated when a symbol is loaded:
export default Route.extend({

  contextRequests: {
    'symbol': 'getSymbol'  },

  getSymbol() {
    return this.symbol  },

  activate(transition){
    let ownedsymbols = Radio.channel('data').request('ownedsymbols')
    this.symbol = ownedsymbols.findWhere({id: transition.params.symbolid})
    this.symbol.loadBuys()
  }
})
//a child route:
export default Route.extend({
  activate(transition){
    let buy    
    let symbol = this.getContext(transition).request('symbol');
    buy = symbol.buys.findWhere({id: transition.params.sharebuyid});
    if (!buy) {
      throw 'buy not found ' + transition.params.sharebuyid    }
    this.buy = buy    
    this.symbol = symbol  
  }
})

Related commit

Reuse route classes


It's possible to use same route class in different routes with the behavior defined by its context. Below is a route class that loads a symbol instance depending of params state (it can be called with or without symbolname param):
export default Route.extend({

  activate(transition){
    let symbolName    let ownedsymbols = Radio.channel('data').request('ownedsymbols')
    if (symbolName = transition.params.symbolname) {
      this.symbol = ownedsymbols.findWhere({name: symbolName});
    }
  }
})
//routes mapping:
router.map(function (route) {
  route('application', {path: '/', routeClass: ApplicationRoute, abstract: true}, function () {
    route('newbuy', {path: 'newbuy/:symbolname?', routeClass: NewBuyRoute})
    route('symbol', {path: 'symbols/:symbolid', routeClass: SymbolRoute,  abstract: true}, function () {
    })
    route('ownedsymbols', {path:':path*', routeClass: OwnedSymbolsRoute})
  })
});

Rewriting to use context:

export default Route.extend({
  activate(transition){
    this.symbol = this.getContext(transition).request('symbol');
  }
})

//The new routes mapping:

router.map(function (route) {
  route('application', {path: '/', routeClass: ApplicationRoute, abstract: true}, function () {
    route('newbuy', {path: 'newbuy', routeClass: NewBuyRoute})
    route('symbol', {path: 'symbols/:symbolid', routeClass: SymbolRoute,  abstract: true}, function () {
      route('symbolnewbuy', {path: 'newbuy', routeClass: NewBuyRoute})
    })
    route('ownedsymbols', {path:':path*', routeClass: OwnedSymbolsRoute})
  })
});

Related commit

Note a new 'symbolnewbuy' route, child of 'symbol' route, with the same class as 'newbuy' route. Retrieving symbol instance is cheap here, but is easy to foresee situations where the ability to reuse data already loaded in parent routes can improve performance.

This routing strategy opens the door to other improvements, not described here, like offloading some of the work that is currently done in the view.

Final remarks


Given the small amount of time invested in this new routing strategy, a week, the results so far are satisfactory. This new implementation allowed to replace the old router with large advantages, providing an intuitive way to do routing and primitives to better structure a Marionette application

On the other hand, is not ready for production. Its somewhere a proof of concept and alpha quality code. Some tasks in the queue to get in a good shape:
  • Tests
  • Examples
  • Adjust the interface, e.g, instead of returning a promise in activate to be resolved, pass a second argument to resolve explicitly (more control to developer)
  • Implement viewEvents
  • Define the behavior when the params of an already activated route changes (reactivate ? an event? an new hook?) 
  • Create a Marionette.Behavior to generate links according to markup
  • See if bundle splitting can be accomplished
  • See if is possible to define sub routes in route class definition
There are some drawbacks:
  • Duplication of code (Backbone.Router / Marionette.Router <> cherrytree)
  • Requires a Promise implementation

Nenhum comentário:

Postar um comentário