In my last post I compared some basic ways in which Marionette and React make it possible to develop single-page applications (SPAs). In my next posts I’ll compare the ways in which they facilitate URL routing within SPAs. To demonstrate the routing capabilities of the libraries I’ll develop a simple app in each. The app will take the form of a tabbed UI in which each tab can be loaded via the URL. My next post will concentrate on React; this post will concentrate on Marionette.
URL routing
What exactly is URL routing? A Simple Introduction to URL Routing provides the following definition:
URL routing means that you when click on a link, instead of being routed to another page, you stay on the same page and the content changes. When this happens, usually a “hash” will be appended to your current URL so that the user can go directly to the content they need as well as using back and forward buttons in the browser.
As this definition suggests URL routing is important because, at the risk of stating the obvious, content within an SPA is typically loaded on a single page. (This contrasts with traditional server-side applications in which any given user interaction, e.g., a form submission, typically loads a new page from the server.) Without URL routing SPAs break the back and forward buttons in the browser, making apps harder to navigate. In order to address such problems many contemporary client-side libraries and frameworks provide URL routing implementations.
Marionette.AppRouter
Marionette’s URL routing implementation comes in the form of the AppRouter class. Using this class it’s possible to define a pattern (route) that matches a URL and a callback function (route handler) that is invoked whenever a route is matched. AppRouter offers two approaches for defining routes and route handlers:
- appRoutes: When using this approach the route handler must be present on a “controller” object provided to the router.
- routes: When using this approach the route handler must be present on the router itself.
Demo app with Marionette
The demo app will use the routes approach, in which the route handler must be present on the router. The router will define routes and route handlers such that when a route is matched the the appropriate tab will be loaded into the UI. The code for the app is available on CodePen. Before diving into the implementation details I’ll first provide a brief overview of the requirements, data model and code design.
Requirements
First let’s define some basic requirements for the app:
- Visiting the page for the first time should load the first tab.
- It should be possible to load tabs by clicking on links.
- It should be possible to load tabs by changing the URL hash to match a route. For example a route of “tabs/2” should load the second tab.
- It should be possible to load tabs using the browser’s back and forward buttons.
- Refreshing the page should preserve the URL and load the corresponding tab. For example if the second tab was loaded prior to refreshing the page, the second tab should still be loaded after refreshing the page.
Data model
The app employs a simple data model to represent the idea of a tab.
id: Number
title: String
description: String
active: Boolean
The model’s id attribute uniquely identifies the tab. The active attribute indicates whether a tab is in an active or inactive state. The title and description attributes are just for display purposes.
Code design
The ticking clock app I developed for my last post demonstrated how a Marionette app can be composed by piecing together a number of the library’s classes. For example it used the LayoutView class to contain nested views, the Region class to contain the layout and the Application class to contain the rest of the code. The app I’ve developed for this post reuses some of these classes and introduces some new ones. The basic building blocks of the app are as follows:
- loadInitialData
Function
Loads the data that provides the content for the UI.
- Tabs
Extends Marionette.CollectionView
Represents a collection of tabs.
- Tab
Extends Marionette.ItemView
Represents an individual tab. Clicking a tab loads the corresponding tab panel and changes the URL, engaging the router.
- TabContent
Extends Marionette.CollectionView
Represents a collection of tab panels.
- TabPanel
Extends Marionette.ItemView
Represents an individual tab panel.
- Layout
Extends Marionette.LayoutView
Serves as a container for the tabs and tab content views.
- Router
Extends Marionette.AppRouter
Defines the route and route handler for the application. A matching route invokes the route handler; the route handler loads the correct tab into the page.
- App
Extends Marionette.Application
Serves as a container for the rest of the application code.
The remainder of this post discusses how these building blocks fit together.
Loading the data
The initial set of data for the app is loaded via the aptly named function loadInitialData.
const loadInitialData = () => {
const dfd = $.Deferred();
dfd.resolve(
new Backbone.Collection(
[
{id: 1, title: 'Tab one', description: 'This is tab one.'},
{id: 2, title: 'Tab two', description: 'This is tab two.'},
{id: 3, title: 'Tab three', description: 'This is tab three.'}
]
)
);
return dfd.promise();
};
The function creates a Backbone collection based on the data model. To mimic asynchronous loading the function uses a jQuery Deferred Object to expose the data. Invoking the deferred’s promise method exposes another deferred method, then, which allows an additional handler to be attached. The handler receives the loaded data as input. The then method itself can be chained to the invocation of loadInitialData.
loadInitialData().then((initialData) => {
// Use initialData here
});
Creating the tabs
Since the data is exposed as a Backbone collection the app can use a Marionette CollectionView for rendering the models. From the Marionette docs:
The CollectionView will loop through all of the models in the specified collection, render each of them using a specified childView, then append the results of the child view’s el to the collection view’s el.
As this definition implies, a CollectionView actually consists of two separate views: a parent view for rendering the collection and a child view for rendering each of the individual models. Within the app these views are represented by Tabs and Tab respectively.
const Tab = Marionette.ItemView.extend({
tagName: 'li',
getTemplate() {
return _.template((!this.model.get('active')) ? '<a href="#tabs/<%= id %>"><%= title %></a>' : '<%= title %>');
}
});
const Tabs = Marionette.CollectionView.extend({
childView: Tab,
tagName: 'ul',
collectionEvents: {
'change': 'render'
}
});
The Tabs class identifies the Tab class as its child view using the aptly named childView property. It also declares its root HTML element (UL) with tagName. Finally it uses the collectionEvents property to instruct Marionette to re-render the collection whenever the latter’s change event fires.
Meanwhile the Tab class declares its own root HTML element (LI) with tagName. It also leverages the getTemplate method to decide upon a template to use depending on the state of the model. An individual tab exists in one of two possible states: active or inactive. When the tab is active, the model’s title should be rendered without a hyperlink so that it can’t be clicked; when the tab is inactive, the model’s title should be rendered with a hyperlink so that it can be clicked.
Creating the tab content
The process for creating the tab content resembles the process for creating the tabs: A parent view renders the collection and an associated child view renders each of the individual models. These views are represented in the app by TabContent and TabPanel respectively.
const TabPanel = Marionette.ItemView.extend({
template: _.template('<div><h2><%= title %></h2><p><%= description %></p></div>'),
onBeforeAttach() {
return (!this.model.get('active')) ? this.$el.hide() : null;
}
});
const TabContent = Marionette.CollectionView.extend({
childView: TabPanel,
collectionEvents: {
'change': 'render'
}
});
The TabContent class identifies the TabPanel class as its child view and, just like the Tabs class, instructs Marionette to re-render the collection whenever the collection’s change event fires. Unlike the Tabs class, TabContent doesn’t explicitly declare its root HTML element–in this case Marionette uses a default (DIV).
Meanwhile the TabPanel class defines its template and, using the onBeforeAttach lifecycle method, decides whether a given panel should be shown or hidden in the DOM. Just as a tab exists in an active or inactive state, so too do tab panels. When a panel is active it should be shown; when inactive, hidden.
Creating the layout
Since the UI has two main views–represented by Tabs and TabContent–a Marionette LayoutView can be used to contain them. The Marionette docs again:
A LayoutView is a hybrid of an ItemView and a collection of Region objects. They are ideal for rendering application layouts with multiple sub-regions managed by specified region managers.
Sounds ideal! All that remains is to define the class.
const Layout = Marionette.LayoutView.extend({
template: _.template('<div><h1>URL routing with Marionette</h1></div><div id="tabs-region"></div><div id="tab-content-region"></div>'),
regions: {
tabsRegion: '#tabs-region',
tabContentRegion: '#tab-content-region'
},
initialize(options) {
this.tabs = options.tabs;
this.tabContent = options.tabContent;
},
onShow() {
this.tabsRegion.show(this.tabs);
this.tabContentRegion.show(this.tabContent);
}
});
The Layout class defines a template with elements corresponding to the regions defined in the regions hash. Then, after the template has been added to the DOM during the onShow lifecycle event, it adds the views to the DOM with region.show(view).
Creating the router
The next step is to define the Router class that will be responsible for loading the correct tab into the UI.
const Router = Marionette.AppRouter.extend({
routes: {
"tabs/:tab": "tab",
"*path": "default"
},
initialize(options) {
this.collection = options.collection;
this.defaultTab = options.defaultTab;
},
tab(tab) {
const tab_ = --tab;
this.collection.set(this.collection.map((model, index) => {
model.set('active', tab_ === index);
return model;
}));
},
default() {
this.tab(this.defaultTab);
}
});
Router’s routes property maps a route (tabs/:tab
) to a route handler (changeTab). The route handler works by updating the Backbone collection that provides the data to the UI. Specifically it sets the active attribute to true on the model whose id matches the parameter contained in the route (:tab
). Updating the collection in this way causes the collection’s change event to fire, which in turns causes the Tabs and TabContent views to re-render (see Creating the tabs and Creating the tab content).
Creating the application
With both the router and the views defined all that remains is to create the top-level application for containing the rest of the code.
const App = Marionette.Application.extend({
initialize(options) {
this.rootElement = options.rootElement;
this.defaultTab = options.defaultTab;
this.collection = options.collection;
},
onBeforeStart() {
this.router = new Router({
collection: this.collection,
defaultTab: this.defaultTab
});
this.layout = new Layout({
tabs: new Tabs({
collection: this.collection
}),
tabContent: new TabContent({
collection: this.collection
})
});
this.region = new Marionette.Region({
el: this.rootElement
});
},
onStart() {
Backbone.history.start();
this.region.show(this.layout);
}
});
The App class employs three methods, all native to Marionette Application:
- initialize: This method simply saves references to any parameters passed into it. The rootElement parameter corresponds to the id attribute of the HTML element in which the layout view is embedded:
<div id="app"></div>.
- onBeforeStart: This method instantiates several of the classes required by the application, including Router, Layout, Tabs and TabContent. Notice how the collection loaded by loadInitialData is passed into Router, Tabs and TabContent. Since all three classes reference the same collection, all three classes receive Router’s updates to the collection, the updates being necessary for re-rendering.
- onStart: This method sets the initial state of the app. Specifically it monitors for “hashchange” events via
Backbone.history.start()
and adds the layout view to the DOM.
The App class is instantiated after the deferred object created by loadInitialData has been resolved:
loadInitialData().then((initialData) => {
const app = new App({
rootElement: '#app',
defaultTab: '1',
collection: initialData
});
app.start();
});
Acceptance testing
To test that the app meets the requirements stated above, export the CodePen to a ZIP, unzip the archive and open index.html in a browser.
Conclusion
Such requirements would not be as easy to implement without AppRouter, Marionette’s implementation of URL routing. In my next post I’ll attempt to recreate the exact same app with the exact same behaviors using React. Not that AppRouter made it too hard but let’s see if React makes it any easier!