Warning: opinions ahead
As result of this journey i settled with opinionated approaches for both data binding and routing. For data binding i'm using rivets and for routing a customized router built on top of cherrytree. While the rivets specific code can be easily replaced by, e.g., an traditional templating solution like Handlebars, the application structure used in this tutorial relies on specific router features making undoable to replace it.
This tutorial assumes the reader knows ES6 constructs as import statements, Promise, arrow functions and also is familiar with Backbone.
What?
The source code can be found at github.
Setting the environment
npm init
//Webpack (v1) with Babel and Html loaders
npm install webpack html-loader babel-loader babel-core babel-preset-es2015 --save-dev
//Marionette and its dependencies
npm install jquery underscore backbone backbone.radio backbone.marionette --save
//Bootstrap
npm install bootstrap --save
//The routing library
npm install marionette.routing --save
//Rivets
npm install rivets rivets-backbone-adapter --save
//in package.json:
"scripts": {
"build": "webpack",
"watch": "webpack --watch"
}
Download the assets and extract into directory srcCreate a simple index.html into root folder, only with a div and script tags:
<div id="app"></div>
<script src="build/bundle.js"></script>
Configuring webpack
var path = require('path');
module.exports = {
entry: __dirname + '/src/main.js',
output: {
path: __dirname + '/build',
filename: "bundle.js"
},
devtool: "source-map",
resolve: {
alias: {
marionette: 'backbone.marionette'
}
},
module: {
loaders: [
{test: /\.html$/, loader: 'html'},
{
test: /\.js$/, loader: 'babel?presets[]=es2015', include: [path.resolve(__dirname, 'src')]
}
]
}
};
Configuring the route map
import { createRouter, middleware} from 'marionette.routing';
let router = createRouter({log: true, logError: true});
router.map(function (route) {
route('application', {path: '/', abstract: true}, function () {
route('contacts', {}, function () {
route('contactdetail', {path: ':contactid'})
})
})
});
router.use(middleware);
router.listen();
Three routes are configured: application, the root one, contacts and the leaf contactid. The route nesting means that to a route be active all parents should also be active. So if contacts is active, we can assume that application route is also active, but we cannot know before hand if contactdetails is active.
The path is constructed using route owns path appended to the parent one. When the path is omitted, the name is used. So contactdetails will match the following URL pattern /contacts/:contactid.
Note also the abstract option passed to application route. It means that is not supposed to be navigated to. More info in cherrytree docs
The middleware is the bridge between cherrytree and Marionette Routing interfaces.
Calling listen, the router starts to monitor URL changes and does the first transition using the current location.
Time for the first build. Type
npm run build
and launch index.html. In the developer tools (F12) console, the transition info should be logged. Update the URL hash to index.html#contacts. An error message ("Error: Unable to create route instance: routeClass or viewClass must be defined") should appear. Let's fix it.Meeting the Route class
Create application/route.js (ApplicationRoute) file and put:
import {Route} from 'marionette.routing';
import {WebAPI} from '../web-api';
export default Route.extend({
activate(){
this.api = this.api || new WebAPI();
},
channelName: 'api',
radioRequests: {
'getContactList': function () {
return this.api.getContactList()
}
}
})
The activate method is called each time a route becomes active i.e. when the URL matches the route path pattern. Since we configured the application route path to '/' and all other routes are child of it, this means that it always be active. Such routes are useful for the setup of data or services required application wide.
The ApplicationRoute creates an api instance in activate and make its interface public through a Radio message system. The channelName and radioRequests properties are inherited from Marionette.Object. Basically it says to reply with a call to api.getContactList the request for 'getContactList' in 'api' channel. More info here.
Now the contacts/route.js (ContactsRoute) file:
import {Route} from 'marionette.routing';
import Radio from 'backbone.radio';
import {Contacts} from '../entities';
export default Route.extend({
activate(){
let contactsPromise = Radio.channel('api').request('getContactList');
return contactsPromise.then(contactsData => {
this.contacts = new Contacts(contactsData)
})
}
})
Another important characteristic of activate is that will resolve the return value as a promise and will only proceeds for the next route when is resolved. If the returned promise is rejected the transition is canceled.
Now update the route definitions in main.js to use its corresponding classes:
import ApplicationRoute from './application/route';
import ContactsRoute from './contacts/route';
[..]
router.map(function (route) {
route('application', {path: '/', abstract: true, routeClass: ApplicationRoute}, function () {
route('contacts', {routeClass: ContactsRoute}, function () {
route('contactdetail', {path: ':contactid'})
})
})
});
Rendering the contacts view
Put the markup below in contacts/template.html
<nav class="navbar navbar-default navbar-fixed-top" role="navigation">
<div class="navbar-header">
<a class="navbar-brand" href="#">
Contacts
</a>
</div>
</nav>
<div class="container">
<div class="row">
<div class="col-md-4">
<div class="contact-list">
</div>
</div>
<div class="col-md-8 contact-outlet"></div>
</div>
</div>
Below is the contact list view implementation in contacts/view.js:
import Mn from 'backbone.marionette';
import DataBinding from '../databinding';
const itemHtml = `
<a href="#">
<h4 class="list-group-item-heading">{model:firstName} {model:lastName}</h4>
<p class="list-group-item-text">{model:email}</p>
</a>`;
const ContactItemView = Mn.View.extend({
behaviors: [DataBinding],
tagName: 'li',
className: 'list-group-item',
html: itemHtml
});
const ContactListView = Mn.CollectionView.extend({
tagName: 'ul',
className: 'list-group',
childView: ContactItemView
});
DataBinding is a Behavior class that automatically binds the html using rivets.We define ContactListView that will render the markup of a Bootstrap list group using ContactItemView which is responsible to render each list item.
Now the main view, still in contacts/view.js, responsible for defining the layout
export default Mn.View.extend({
html: require('./template.html'),
behaviors: [DataBinding],
regions: {
contactlist: '.contact-list',
outlet: '.contact-outlet'
},
initialize(options) {
this.contacts = options.contacts
},
onRender() {
this.showChildView('contactlist', new ContactListView({collection: this.contacts}))
}
})
It defines two regions: contactlist and outlet. The onRender event is used to show a ContactListView instance in the contactlist region.Now tell the contacts route (contacts/route.js) what view to render:
[..]
import ContactsView from './view';
export default Route.extend({
[..]
viewClass: ContactsView,
viewOptions() {
return {
contacts: this.contacts
}
}
})
Do a new build a launch index.html#contacts
What? A blank page? An "Error: No outlet region" in console? There's a missing piece in route configuration:
//main.js
import Mn from 'backbone.marionette';
[..]
router.rootRegion = new Mn.Region({el: '#app'});
We need to define a root region in router where the top level routes will show its views.
If everything went right, reloading should show a unstyled list:
Time to configure Bootstrap. Copy bootstrap.min.css and glyphicons-halflings-regular.eot to assets folder and update index.html:
<head>
[..]
<link rel="stylesheet" href="assets/bootstrap.min.css">
<link rel="stylesheet" href="src/styles.css">
</head>
Much better.
Is possible to load Bootstrap using webpack, and the initial idea was to use in this tutorial, but, it requires additional configuration so, for sake of simplicity, just using the static files directly.
Setting the default route
Update the location hash before start listening (it will work only at startup):
In main.js:
if (location.hash.length <= 1) {
location.hash = '#contacts'
}
router.listen();
In application route activate, a not so good approach since ties the ApplicationRoute class to "/" path:
activate(transition){
this.api = this.api || new WebAPI();
if (transition.path === '/') {
transition.then(function () {
transition.redirectTo('contacts');
});
}
}
And finally using "before:transition" event, which we will use given its flexibility:
Radio.channel('router').on('before:transition', function (transition) {
if (transition.path === '/') {
transition.redirectTo('contacts')
}
});
router.listen();
Using redirect both in activate and in 'before:transition' event requires removing the abstract flag from application route options.
The contact detail
import Mn from 'backbone.marionette';
import DataBinding from '../databinding';
export default Mn.View.extend({
html: require('template.html'),
behaviors: [DataBinding],
triggers: {
'click #save-contact': 'save:model'
}
});
The markup is a basic Bootstrap form annotated with some rivets directives.
Let's implement the Route class that will be responsible to inject the model and listen to the view events.
In contactdetail/route.js:
//ContactDetailRoute
export default Route.extend({
activate(transition){
let contacts = this.getContext(transition).request('contacts');
this.contact = contacts.findWhere({id: +transition.params.contactid});
if (!this.contact) {
throw new Error('Unable to resolve contact with id', transition.params.contactid);
}
},
viewClass: ContactDetailView,
viewOptions() {
return {
model: this.contact.clone()
}
},
viewEvents: {
'save:model': 'onSaveModel'
},
onSaveModel(view) {
let attributes = _.clone(view.model.attributes);
this.contact.clear({silent:true}).set(attributes);
}
})
Is necessary to update contacts/route.js with the contextRequests definition
//ContactsRoute
export default Route.extend({
[..]
contextRequests: {
contacts: function () {
return this.contacts
}
}
})
In activate, we get a reference for the contacts collection and then the contact model corresponding to the contactid passed through transition.params. Noteworthy the usage of route context to retrieve the contacts collection, instead of using a global Radio channel, like was done for the WebAPI instance.The request will be resolved by the nearest parent (ContactsRoute) that registered a reply using contextRequests property. This features allows to use the same Radio semantics in a scoped way thus helping to prevent the global Radio namespace be cluttered.
In viewOptions, a clone of the model is injected into the view and in onSaveModel (bound to 'save:model' event through viewEvents) the changes are saved back to the original model instance. This approach seems overkill in this example, since we could simple pass the actual model instance and let the view save it directly, but it allows to make reusable views. For example, we can use ContactDetailView to also edit a newly created contact.
Rebuild and go to #contacts/1. You should be able to view, edit and save the contact info.
A note about nested rendering
One of the Marionette Routing benefits is the ability to handle nested state in a consistent manner. It also allows to nest views with a minimal declarative configuration. When a route is configured to show a view, it will look for the nearest parent route with a rendered view which must have a region named "outlet". If no parent route with a rendered view is found, the rootRegion is used.
In our app, the ContactDetailView is rendered into the outlet region defined in ContactsView. Note also that when changing from, e.g, #contacts/1 to #contacts/2 only contact detail view is rerendered.
Refinements
const ContactItemView = Mn.View.extend({
[..]
events: {
'click': 'onClick'
},
onClick(e) {
e.preventDefault();
Radio.channel('router').request('transitionTo', 'contactdetail', {contactid: this.model.get('id')})
}
});
We could also render the a element with the appropriate href attribute value that would work the same.
Highlighting the selected contact item, requires a bit more of work. In ContactsView.setSelected we get the ContactListView instance using getRegion, resets any item with an 'active' class, find the ContactItemView instance associated with the passed contactid using children.find and add 'active' class to its element.
Since the view is only rendered after all routes are activated, setSelected is called inside a resolve handler of the transition. Note that the "before:activate" event is registered in activate and unregistered in deactivate to catch only events of child routes.
//ContactsView
export default Mn.View.extend({
[..]
setSelected(contactId) {
let listView = this.getRegion('contactlist').currentView
listView.$('.list-group-item').removeClass('active')
if (contactId) {
let itemView = listView.children.find(function (view) {
return view.model.get('id') == contactId
})
if (itemView) {
itemView.$el.addClass('active')
}
}
}
})
//ContactsRoute
export default Route.extend({
activate(){
this.listenTo(Radio.channel('router'), 'before:activate', this.onChildActivate);
[..]
},
[..]
onChildActivate(transition, route) {
if (!transition.params.contactid) {
return;
}
//must be done after transition finishes to ensure view is rendered
transition.then(() => {
this.view.setSelected(transition.params.contactid)
})
},
deactivate() {
this.stopListening(Radio.channel('router'), 'before:activate')
}
})
This approach demonstrates how to catch events from child routes but is a lot of code for a common use case. With this in mind i created a Marionette Behavior (RouterLink) that takes care both of active class update as well click event configuration. This commit show how to use it.
Finally, let's render a view in the contact detail placeholder when no contact is selected. This can be done through a default child route for contacts route:
//main.js:
router.map(function (route) {
route('application', {path: '/', routeClass: ApplicationRoute}, function () {
route('contacts', {routeClass: ContactsRoute, abstract: true}, function () {
route('contacts.default', {path: '', viewClass: ContactNoSelectionView,
viewOptions: {message: 'Please Select a Contact.'}})
route('contactdetail', {path: ':contactid', routeClass: ContactDetailRoute})
})
})
});
Radio.channel('router').on('before:transition', function (transition) {
if (transition.path === '/') {
transition.redirectTo('contacts.default')
}
});
ContactNoSelectionView is an unremarkable Marionette view.Important here is the definition of 'contacts.default' route with an empty path. It means that it will be activated when URL matches the path of the parent route, i.e., it becomes the default child route.
Another point is the absence of a routeClass option. Instead, viewClass and viewOptions are defined directly, useful for views that do not require customized logic to be instantiated.
Final thoughts
After struggling with various approaches, i think the architecture presented in this tutorial, is suitable for large scale applications. More than an organized way of loading, manipulating state, it allows to create reusable / independent modules.
This application is an example. We could transplant all the code contained in contacts route and below, including HTML, as a sub application of a bigger one. The only requirement is to have a Radio 'api' channel with a suitable 'getContactList' request, no need to worry what URL will be used or where it will render. In this way, we could develop sub applications independently assembling them into the full application just swapping the route configuration.
There are still some improvements to be done, like the possibility of add a contact, show a loading indicator, implement transition effects and prevent the exit from the contact detail route when there are pending changes. It will be left for an eventual second part of this tutorial.