Backbone.js "fat router" design conundrum
Asked Answered
P

2

5

I have spent the last 2 weeks learning backbone and related tools as well as writing an application. I have hit a wall with a design issue and would like to know what kind of solutions are available and whether Backbone experts even regard this as an issue.

Problem: I am ending up having to put all my view dependencies in my router.js and am unable to figure out if their is a way around that. Below is the code from my router.js:

// router.js
define([
  'jquery',
  'underscore',
  'backbone',
  'text',
  'views/landing',
  'views/dashboard',
],  
    function($, _, Backbone, t,LandingView,DashboardView){
        var AppRouter = Backbone.Router.extend({
        routes: {
          // Define some URL routes
          '': 'showLanding',
          'projects': 'showProjects',
          // Default
          '*actions': 'defaultAction'
        },
        navigate_to: function(model){
                alert("navigate_to");
            },

        showProjects: function() {},
        showLanding: function() {},
    });

    var initialize = function() {
        var app_router = new AppRouter;
        Backbone.View.prototype.event_aggregator = _.extend({}, Backbone.Events);
        // Extend the View class to include a navigation method goTo
        Backbone.View.prototype.goTo = function (loc) {
            app_router.navigate(loc, true);
        };
        app_router.on('route:showLanding', function(){
            var landing = new LandingView();
        });
        app_router.on('route:showProjects', function(){
            var dashboard=new DashboardView();
        });
        app_router.on('defaultAction', function(actions){
            alert("No routes");
            // We have no matching route, lets just log what the URL was
            console.log('No route:', actions);
        });
        Backbone.history.start({pushState: true});
    };
    return {
        initialize: initialize
    };
});

router.js includes the LandingView and DashboardView views which in turn fetch the respective templates. The initial route loads the LandingView which has a login template. After logging in, it calls the goTo method of router.js to spawn a DashboardView(). Although this works, I feel that it's a bit ugly. But I can't figure how else to spawn a new DashboardView from LandingView without either directly referencing DashboardView() from inside of LandingView() or from the router.

If I continue doing this via router.js I will end up pulling, directly or indirectly, all my views js files from the router. Sounds a bit ugly!

I looked at Derick Baileys' event aggregator pattern but faced the question of how does the DashboardView subscribe to an event generate by the LandingView if an instance of DashboardView doesn't even exist yet? Someone has to create and initialize it for it to subscribe to an event aggregator, right? And if that someone is the router, do I need to instantiate all the views upfront in the router? That doesn't make sense.

Pollinosis answered 6/1, 2013 at 19:35 Comment(5)
I think I updated this the same time you did and accidentally reverted some of your changes.Fornicate
I think the mediator pattern would solve your issue: pivotallabs.com/users/mrushakoff/blog/articles/…Baldheaded
Thanks - going over the mediator now.Pollinosis
@nimrod SO the Mediator is the same as the event aggregator of Marionette, correct?Pollinosis
@Pollinosis it's based on that pattern, but as far as I understand Marionette does a whole lot more and has quite a few dependancies. If you want to focus just on mediation I'd recommend that blogpost. Also, if you think about building a big app, checkout other frameworks that provide all the structure so you don't need to think about architecture too much, like angular.js for example, or ember...Baldheaded
U
11

I've tackled this problem by only importing the views when the route is first hit:

define(['backbone'], function(Backbone) {
    var AppRouter = Backbone.Router.extend({
        routes: {
            '':      'home',
            'users': 'users'
        },

        home: function() {
            requirejs(["views/home/mainview"], function(HomeView) {
                //..initialize and render view
            });
        },

        users: function() {
            requirejs(["views/users/mainview"], function(UsersView) {
                //..initialize and render view
            });
        }
    });

    return AppRouter;
});

It doesn't solve the issue of having to eventually import all the views to the router, but the lazy requirejs calls don't force loading and evaluating all scripts and templates up front.

Fact of the matter is that someone, somewhere, must import the modules. The router is a sensible location, because typically it's the first piece of code that's hit when user navigates to a certain page (View). If you feel like one router is responsible for too much, you should consider splitting your router into multiple routers, each responsible for different "section" of your application. For a good analogy think of the Controller in a typical MVC scenario.

Example of multiple routers

userrouter.js handles all User-related views (routes under 'users/'):

define(['backbone'], function(Backbone) {
    var UserRouter = Backbone.Router.extend({
        routes: {
            'users',        'allUsers',
            'users/:id',    'userById'
        },
        allUsers: function() {
            requirejs(["views/users/listview"], function(UserListView) {
                //..initialize and render view
            });
        },
        userById: function(id) {
            requirejs(["views/users/detailview"], function(UserDetailView) {
                //..initialize and render view
            });
        }
    });
    return UserRouter;
});

postrouter.js handles all Post-related views (routes under 'posts/'):

define(['backbone'], function(Backbone) {
    var PostRouter = Backbone.Router.extend({
        routes: {
            'posts',        'allPosts',
            'posts/:id',    'postById'
        },
        allPosts: function() {
            requirejs(["views/posts/listview"], function(PostListView) {
                //..initialize and render view
            });
        },
        postById: function(id) {
            requirejs(["views/posts/detailview"], function(PostDetailView) {
                //..initialize and render view
            });
        }
    });
    return PostRouter;
});

approuter.js is the main router, which is started up on application start and initializes all other routes.

define(['backbone', 'routers/userrouter', 'routers/postrouter'], 
function(Backbone, UserRouter, PostRouter) {

    var AppRouter = Backbone.Router.extend({

        routes: {
            '',        'home',
        },
        initialize: function() {
            //create all other routers
            this._subRouters = {
                'users' : new UserRouter(),
                'posts' : new PostRouter()
            };
        },
        start: function() {
            Backbone.history.start();
        },
        home: function() {
            requirejs(["views/home/mainview"], function(HomeView) {
                //..initialize and render view
            });
        }
    });
    return UserRouter;
});

And finally, your application's main.js, which starts the app router:

new AppRouter().start();

This way you can keep each individual router lean, and avoid having to resolve dependency trees before you actually need to.

Sidenote: If you use nested requirejs calls and you're doing a build with r.js, remember to set the build option findNestedDependencies:true, so the lazily loaded modules get included in the build.

Edit: Here's a gist that explains lazy vs. immediate module loading in RequireJS.

Undertow answered 6/1, 2013 at 21:8 Comment(3)
+1 Thanks, the lazy loading sounds like a good idea. I will wait for a few more answers.Pollinosis
hey there, nice idea. but, shouldnt the last function return AppRouter instead of UserRouter?Triangulate
Can you tell me what is "_subroutes"? Where is it defined? is it part of backbone? How does it work? I have seen no reference to this anywhere and I'm trying to find it for days now :)Bilicki
L
1

We use a factory for this it simply returns a view instance, it can also cache instances:

define(function() {
  // Classes are defined like this { key1: Class1, key2: Class2 }
  // not cachedObjects are defined like this { notCached : { key3: Class3 }}
  return function(Classes) {
    var objectCache = {};

    return {
      get: function(key, options) {
        var cachedObject = objectCache[key];
        if (cachedObject){
          return cachedObject;
        }

        var Class = Classes[key];
        if (Class) {
          cachedObject = new Class(options);
          objectCache[key] = cachedObject;
          return cachedObject;
        }

        Class = Classes.notCached[key];
        if (Class) {
          return new Class(options);
        }
      }
    };
  };
});

Then we have a module that creates the factory:

define([
  'common/factory',
  'views/view1',
  'views/view2',
  'views/view3',
  ], function(
    viewCache,
    View1,
    View2,
    View3
  ) {

  var views = {
    route1: View1,
    route2: View2,
    notCached: {
      route3: View3,
    }
  };

  return viewCache(views);
});

In the router you could then easily get the view by calling viewCache.get(route). The benefit is to decouple creating/caching of the views, which now can be test separately.

Also as we use Marionette we dont use the viewCache in the router but in the RegionManager which is a better fit for creating the views. Our router just trigger events with the actual state and route of the app.

Lasky answered 6/1, 2013 at 22:0 Comment(9)
I like the factory idea but doesn't that "move" the problem of loading all views upfront from the router to the factory? As in, downloading all the view related .js files.Pollinosis
Yes, this will load all the code of your views, but it decouple the router from and the creating of views. But you can refactor the factory to work with requirejs, so you store just the path to your require module instead of module itself.Barite
Update to show how this could work with requirejs (not tested).Barite
@AndreasKöberle your requirejs pattern would be great, but unfortunately I don't think it works. For one the require API is async, so you'd have to make the factory async too. The bigger problem is that the r.js compiler can only resolve nested dependencies if the require call is used with a literal string path, like require(['module/name'], cb).Undertow
Are your sure, we use require and it seems to wait until the file is loaded, also the main idea was not to compile the code for the view in the main app.js but load it when needed.Barite
@AndreasKöberle, You're right in fact that you can call require synchronously, but only thanks to some trickery RequireJS performs for you. Depending on whether you use the AMD or CommonJS style header, it calling require synchronously can either be only working due to sheer luck, or may not be doing what you imagine it would. You got me doubting myself, so I had to go test. Here's the gist of it: gist.github.com/4475666Undertow
@AndreasKöberle, it doesn't work, because RequireJS can't parse require(Class). You would need to pass it a literal: require('views/View2'), which would still be only a partial solution, because it would load the module as soon as the common/factory module is loaded for the first time. See the gist in my previous comment for examples.Undertow
Ok, your right, we're using it to workaround a an circular dependency, and we import the module in the define declaration.Barite
@Pollinosis - Yeah, it seems to be useful. I already managed to use it at work to "educate" some folks and win a dirty bet. I edited it into my answer, maybe it'll be useful to others too.Undertow

© 2022 - 2024 — McMap. All rights reserved.