How to build a large, single-page javascript application with Backbone.js

In my last article on the “How to build a large, single-page javascript application” series we used the JavascriptMVC framework to build Rebecca Murphey’s javascript’s community challenge application - srchr. In this article we’ll follow the same coding pattern to show how to do the same using Backbone.js as the base javascript MVC framework instead.

You can download all the project’s code from here and see a working demo by clicking this link. You can also clone this github repo and compare the different srchr implementations by jumping between “extjs”, “javascriptmvc” and “backbone” branches.

Following the same workflow as in the two previous articles let’s start by breaking down the application into a set of smaller backbone.js components and then work on each of the component event-driven interactions at a time.

Designing the Backone.js application

Let’s start by breaking down the application into a set of maneageable backbone.js views:

And this is the folder organization for our backbone application:

Since we’re going to use requirejs as the dependency management tool for this project I’ve decided to use the AMD enabled version of both the underscore and backbone libraries.

We’re also going to use the requirejs text plugin to load all the templates througout the application.

Scaffolding the Backbone.js application

Let’s get something working as quickly as possible by scaffolding the main layout of the application so we can have a base from which we can flesh out the application components and associated event-driven interactions later on. Here’s the necessary code to get things started:

index.html
<!DOCTYPE HTML>
<html>
<head>
        <meta http-equiv='content-type' content='text/html; charset=utf-8'>
        <title>Index</title>
        <link rel="stylesheet" href="stylesheets/css/screen.css" type="text/css" media="screen, projection" charset="utf-8">
        <!--[if IE]>
                <link href="/stylesheets/ie.css" media="screen, projection" rel="stylesheet" type="text/css" />
        <![endif]--> 
</head>
<body>
        <div id='srchr_application'></div>      
        <script data-main='main' src='lib/require/require.js' type="text/javascript" charset="utf-8"></script>
</body>
</html>
main.js
requirejs.config({
        baseUrl: '/',
        paths: {
                text: 'lib/require/plugins/text',
                jquery: 'lib/jquery-1.8.3',
                underscore: 'lib/underscore',
                backbone: 'lib/backbone'
        }

});
requirejs(['jquery', 
           'underscore',
           'backbone',
           'srchr/srchr'], function($, _, Backbone, Srchr) {

                new Srchr({ 
                        el: $('#srchr_application')
                });
});
srchr/srchr.js
define(['jquery',
        'underscore',
        'backbone',

        'srchr/search_bar/search_bar',
        'srchr/history/history',
        'srchr/search_results/search_results',

        'text!srchr/templates/layout.tp'
        ],

        function( $, _, Backbone,

                  SearchBar,
                  History,
                  SearchResults,

                  layoutTp
        ) {

                        var Srchr = Backbone.View.extend({

                                initialize: function() {

                                        this.$el.html(_.template(layoutTp, {}));

                                        this.searchBar = new SearchBar({
                                                el: this.$el.find('.search_bar')
                                        });

                                        this.history = new History({
                                                el: this.$el.find('.history')
                                        });

                                        this.searchResults = new SearchResults({
                                                el: this.$el.find('.search_results')
                                        });
                                }
                        });

                        return Srchr;
        });
srchr/templates/layout.tp
<div class='search_bar'></div>
<div class='container'>
        <div class='history'></div>
        <div class='search_results'></div>
</div>
srchr/search_bar.js
define(['jquery',
        'underscore',
        'backbone',
        'text!srchr/search_bar/templates/search_bar.tp'
        ],

        function( $, _, Backbone, searchBarTp) {

                var SearchBar = Backbone.View.extend({

                        initialize: function() {

                                this.$el.html(_.template(searchBarTp, {}));
                        }
                });

                return SearchBar;
        });
srchr/search_bar/templates/search_bar.tp
<h1 class='placeholder'>SEARCH_BAR PLACEHOLDER</h1>
srchr/history.js
define(['jquery',
        'underscore',
        'backbone',
        'text!srchr/history/templates/history.tp'
        ],

        function( $, _, Backbone, historyTp) {

                var History = Backbone.View.extend({

                        initialize: function() {

                                this.$el.html(_.template(historyTp, {}));
                        }
                });

                return History;
        });
srchr/history/templates/history.tp
<h1 class='placeholder'>HISTORY PLACEHOLDER</h1>
srchr/search_results.js
define(['jquery',
        'underscore',
        'backbone',
        'text!srchr/search_results/templates/search_results.tp'
        ],

        function( $, _, Backbone, searchResultsTp) {

                var SearchResults = Backbone.View.extend({

                        initialize: function() {

                                this.$el.html(_.template(searchResultsTp, {}));
                        }
                });

                return SearchResults;
        });
srchr/search_results/templates/search_results.tp
<h1 class='placeholder'>SEARCH_RESULTS PLACEHOLDER</h1>
srchr/stylesheets/sass/screen.scss

@import "compass/reset";
@import "srchr"

srchr/stylesheets/sass/_srchr.scss
body {
        margin: 0;
        font-family: tahoma, arial, verdana, sans-serif;
        h1.placeholder {
                font-size: 18px;
                font-weight: bold;
                margin: 10px;
        }
}
#srchr_application {
        position: absolute;
        top: 0;
        right: 0;
        bottom: 0;
        left: 0;
        .search_bar {
                position: absolute;
                top:0;
                right: 0;
                left: 0;
                height: 60px;
                border: 1px solid #3ba3d0;
        }
        .container {
                position: absolute;
                top: 61px;
                right: 0;
                bottom: 0;
                left: 0;
                border-bottom: 1px solid #3ba3d0;
                .history {
                        position: absolute;
                        top: 0;
                        bottom: 0;
                        left: 0;
                        width: 320px;
                        border-right: 1px solid #3ba3d0;
                }
                .search_results {
                        position: absolute;
                        top: 0;
                        right: 0;
                        bottom: 0;
                        left: 321px;
                }
        }
}

You can see how the scaffolded application looks in the following image:

Event-driven interaction between the SearchBar and History views

Following an event-driven API design pattern we will now implement the subset of the application consisting of the interaction between the SearchBar and History backbone views.

Every time the user hits the search button, SearchBar will be responseable for throwing a “search” event that will be caught by the main Srchr view that then commands History to add a new search entry to it’s search command list (stored in the browser’s localStorage).

The following image shows in detail the sequence of interactions described above:

The relevant changes to the codebase follow:

srchr/search_bar/search_bar.js
// ...
var SearchBar = Backbone.View.extend({

	events: {
		'click .search': 'search'
	},

	initialize: function() {

		this.$el.html(_.template(searchBarTp, {}));
	},

	search: function(ev) {

		var twitterChecked = $('#twitter').attr('checked'),
		    answersChecked = $('#answers').attr('checked'),
		    query = $('#query').val();

		if(twitterChecked) {

			this.$el.trigger('search', {
				query: query,
				service: 'twitter'
			});

		} else if(answersChecked) {

			this.$el.trigger('search', {
				query: query,
				service: 'answers'
			});

		} else {

			//console.log('Invalid service');
		}
	}
});
srchr/search_bar/templates/search_bar.tp
<div class='search_form'>
        <label for='query'>Query:</label>
        <input id='query' type='text' name='query' value=''>
        <button class='search'>Search</button>
        <label for="twitter">Twitter</label>
        <input id='twitter' type='radio' name='service' value='twitter' checked>
        <label for="answers">Answers</label>
        <input id='answers' type='radio' name='service' value='answers'>
</div>
srchr/stylesheets/sass/_srchr.scss

@mixin search_bar_background_gradient($startColor: #024a68, $endColor: #0772a1) {
  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr=$startColor, endColorstr=$endColor); /* for IE */
  background-image: -webkit-gradient(linear, 50% 0%, 50% 100%, color-stop(0%, $startColor), color-stop(100%, $endColor));
  background-image: -webkit-linear-gradient($startColor, $endColor);
  background-image: -moz-linear-gradient($startColor, $endColor);
  background-image: -o-linear-gradient($startColor, $endColor);
  background-image: linear-gradient($startColor, $endColor);
}
// ...
#srchr_application {
        position: absolute;
        top: 0;
        right: 0;
        bottom: 0;
        left: 0;
        .search_bar {
                position: absolute;
                top:0;
                right: 0;
                left: 0;
                height: 60px;
                border: 1px solid #3ba3d0;
           	@include search_bar_background_gradient;
	        .search_form {
			margin: 20px 0 0 0;
			label {
				margin-left: 20px;
				color: #fff;
			}
			#query {
				width: 200px;
				padding: 2px;
				border: 1px solid #333;
			}
			button {
				cursor: pointer;
			}
			#twitter, #answers {
				margin-left: 10px;
			}
                }
	}
	// ...
}

srchr/srchr.js
define(['jquery',
        'underscore',
        'backbone',

        'srchr/models/search_list',

        'srchr/search_bar/search_bar',
        'srchr/history/history',
        'srchr/search_results/search_results',

        'text!srchr/templates/layout.tp'
        ],

        function( $, _, Backbone,

                  SearchList,

                  SearchBar,
                  History,
                  SearchResults,

                  layoutTp
        ) {

                        var Srchr = Backbone.View.extend({

                                events: {
                                        'search .search_bar': 'newSearch'
                                },

                                initialize: function() {

                                        this.$el.html(_.template(layoutTp, {}));

                                        this.searchBar = new SearchBar({
                                                el: this.$el.find('.search_bar')
                                        });

                                        this.history = new History({
                                                el: this.$el.find('.history'),
                                                searches: new SearchList()
                                        });

                                        this.searchResults = new SearchResults({
                                                el: this.$el.find('.search_results')
                                        });
                                },

                                newSearch: function(ev, search) {

                                        this.history.addSearch(search);
                                }
                        });

                        return Srchr;
        });
srchr/models/search_list.js
define(['jquery', 'underscore', 'backbone',
        'lib/backbone.localStorage'],

       function($, _, Backbone) {

                var SearchList = Backbone.Collection.extend({

                        localStorage: new Backbone.LocalStorage('searches')

                });

                return SearchList;
        });
srchr/history/history.js
define(['jquery',
        'underscore',
        'backbone',
        'text!srchr/history/templates/history.tp'
        ],

        function( $, _, Backbone, historyTp) {

                var History = Backbone.View.extend({

                        events: {
                                'click .remove': 'removeSearch',
                                'mouseleave .search': 'mouseleave',
                                'mouseenter .search': 'mouseenter'
                        },

                        initialize: function() {

                                this.options.searches.fetch();

                                this.$el.html(_.template(historyTp, {
                                        searches: this.options.searches.models
                                }));

                                this.options.searches.on('add',
                                        $.proxy(this.searchAdded, this));

                                this.options.searches.on('remove',
                                        $.proxy(this.searchRemoved, this));
                        },

                        addSearch: function(search) {

                                search.id = +new Date();
                                this.options.searches.create(search);
                        },

                        removeSearch: function(ev) {

                                ev.stopPropagation();

                                this._getSearch(ev).destroy();
                        },

                        searchAdded: function(search, searches) {

                                this.show(searches.models);
                        },

                        searchRemoved: function(search, searches) {

                                this.show(searches.models);
                        },

                        show: function(searches) {

                                this.$el.html(_.template(historyTp, {
                                        searches: searches
                                }));
                        },

                        mouseleave: function(ev) {

                                this._getSearchEl(ev).removeClass('hover');
                        },

                        mouseenter: function(ev) {

                                this._getSearchEl(ev).addClass('hover');
                        },


                        _getSearch: function(ev) {

                                var searchEl = this._getSearchEl(ev),
                                    id = searchEl.attr('data-id');

                                return this.options.searches.get(id);
                        },

                        _getSearchEl: function(ev) {

                                var target = $(ev.target);

                                return target.closest('.search');
                        }
                });

                return History;
        });
srchr/history/templates/history.tp
<% for(var i=0; i<searches.length; i++) {
        var s = searches[i]; %>

        <div class='search' data-id='<%= s.get('id') %>'>
                <span class='query'><%= s.get('query') %></span>
                (<span class='service'><%= s.get('service') %></span>)
                <span class='remove'>X</span>
        </div>

<% } %>
srchr/stylesheets/sass/_srchr.scss
// ...
.container {
	// ...
	.history {
		// ...
		.search {
			height: 25px;
			padding: 10px 0 0 20px;
			border-bottom: 1px solid #333;
			cursor: pointer;
			.query {
				font-weight: bold;
			}
			.remove {
				cursor: pointer;
			}
			&.hover {
				background-color: #ffc;
			}
		}
	}
// ...

You can see how the application looks so far in the image bellow:

Event-driven interaction between the SearchBar and SearchResults views

Continuing with the event-driven API pattern we will now work on the interaction between the SearchBar and SearchResults views.

Upon the user hiting the search button, the SearchBar view throws a “search” event, the main Srchr view listens to it, activates the correct tab and tells the relevant SearchResults view to execute the search for the query provided by the user.

A simplified image of the event-driven interaction is provided bellow:

The changes to the code follow:

srchr/srchr.js
define(['jquery',
        'underscore',
        'backbone',

        'srchr/models/search_list',
        'srchr/models/twitter',
        'srchr/models/answers',

        'srchr/search_bar/search_bar',
        'srchr/history/history',
        'srchr/tabs/tabs',
        'srchr/search_results/search_results',

        'text!srchr/templates/layout.tp',
        'text!srchr/search_results/templates/twitter.tp',
        'text!srchr/search_results/templates/answers.tp'
        ],

        function( $, _, Backbone,

                  SearchList,
                  Twitter,
                  Answers,

                  SearchBar,
                  History,
                  Tabs,
                  SearchResults,

                  layoutTp,
                  twitterTp,
                  answersTp
        ) {

                        var Srchr = Backbone.View.extend({

                                events: {
                                        'search .search_bar': 'newSearch'
                                },

                                initialize: function() {

                                        this.$el.html(_.template(layoutTp, {}));

                                        this.searchBar = new SearchBar({
                                                el: this.$el.find('.search_bar')
                                        });

                                        this.history = new History({
                                                el: this.$el.find('.history'),
                                                searches: new SearchList()
                                        });

                                        this.tabs = new Tabs({
                                                el: this.$el.find('.tabs_wrapper')
                                        });

                                        this.twitterSearchResults = new SearchResults({
                                                el: $('#twitter_panel'),
                                                model: new Twitter,
                                                template: twitterTp
                                        });

                                        this.answersSearchResults = new SearchResults({
                                                el: $('#answers_panel'),
                                                model: new Answers,
                                                template: answersTp
                                        });
                                },

                                newSearch: function(ev, search) {

                                        this.history.addSearch(search);

                                        this.performSearch(search);
                                },
                                performSearch: function(search) {

                                        var service = search && search.service,
                                            tabId = '#'+service+'_panel',
                                            viewId = service + 'SearchResults',
                                            view = this[viewId];

                                        if(view) {

                                                this.tabs.select(tabId);
                                                view.search(search);
                                        }
                                }
                        });

                        return Srchr;
        });
srchr/templates/layout.tp
<div class='search_bar'></div>
<div class='container'>
        <div class='history'></div>
        <div class='search_results'>
                <div class='tabs_wrapper'>
                        <ul class='tabs'>
                                <li class='tab current'><a href='#twitter_panel'>Twitter</a></li>
                                <li class='tab'><a href='#answers_panel'>Answers</a></li>
                        </ul>
                        <div id='twitter_panel' class='panel'></div>
                        <div id='answers_panel' class='panel'></div>
                </div>
        </div>
</div>
srchr/search_results/search_results.js
define(['jquery',
        'underscore',
        'backbone'
        ],

        function( $, _, Backbone) {

                var SearchResults = Backbone.View.extend({

                        search: function(search) {

                                this.options.model.search(
                                        search,
                                        $.proxy(this.resultsLoaded, this),
                                        $.proxy(this.errorLoadingResults, this)
                                );
                        },

                        resultsLoaded: function(results) {

                                this.$el.html( _.template(this.options.template, {
                                        results: results
                                }));
                        },

                        errorLoadingResults: function(model, xhr, options) {

                                console.log('Error loading searches.');
                        }
                });

                return SearchResults;
        });
srchr/search_results/templates/twitter.tp
<% for(var i=0; i<results.length; i++) {
        var r = results[i]; %>

<div class='search_result clearfix'>
        <div class='avatar'>
                <img src='<%= r.get('profile_image_url') %>'/>
        </div>
        <div class='info'>
                <div class='from_user'><%= r.get('from_user') %></div>
                <div class='text'><%= r.get('text') %></div>
        </div>
</div>

<% } %>
srchr/search_results/templates/answers.tp
<% for(var i=0; i<results.length; i++) {
        var r = results[i]; %>

<div class="search_result answer">
        <div class="info">
                <div class="text"><span class="question">Question:</span><%= r.get('Content') %></div>
                <div class="text"><span class="answer">Answer:</span><%= r.get('ChosenAnswer') %></div>
        </div>
</div>

<% } %>
srchr/models/twitter.js
define(['jquery',
        'underscore',
        'backbone'
        ],

        function( $, _, Backbone) {

                var Twitter = Backbone.Model.extend({

                        search: function(params, success, error) {

                                var urlRoot = 'http://search.twitter.com/search.json';

                                this.fetch({
                                        url: urlRoot + '?q=' + encodeURIComponent(params.query),
                                        type: 'get',
                                        dataType: 'jsonp',
                                        success: function(model, response, options) {
                                                var results = response && response.results || [],
                                                    instances = [];
                                                for(var i=0;i<results.length;i++) {
                                                        var r = results[i];
                                                        instances.push( new Twitter(r));
                                                }
                                                success(instances);
                                        },
                                        error: function() {
                                                error(arguments);
                                        }
                                });
                        }
                });

                return Twitter;
        });
srchr/models/answers.js
define(['jquery',
        'underscore',
        'backbone'
        ],

        function( $, _, Backbone) {

                var Answers = Backbone.Model.extend({

                        search: function(params, success, error) {

                                var urlRoot = 'http://query.yahooapis.com/v1/public/yql';

                                var yql = 'select * from answers.search where query="' + params.query + '"';
                                yql = yql + ' and type="resolved"';

                                this.fetch({
                                        url: urlRoot + '?q=' + encodeURIComponent(yql) + '&format=json',
                                        type: 'get',
                                        dataType: 'jsonp',
                                        success: function(model, response, options) {
                                                var instances = [],
                                                    query = response && response.query,
                                                    results = query && query.results,
                                                    question = results && results.Question,
                                                    len = question && question.length;
                                                if(question && len) {
                                                        for(var i=0; i<len; i++) {
                                                                var q = question[i];
                                                                instances.push( new Answers(q));
                                                        }
                                                }
                                                success(instances);
                                        },
                                        error: function() {
                                                error(arguments);
                                        }
                                });
                        }
                });

                return Answers;
        });
srchr/stylesheets/sass/_srchr.scss
// ...
@mixin rounded_corners($radius: 5px) {
        -moz-border-radius-topright: $radius;
        -moz-border-radius-topleft: $radius; 
        -webkit-border-top-right-radius: $radius;
        -webkit-border-top-left-radius: $radius;
        border-top-right-radius: $radius;
        border-top-left-radius: $radius;
}
.clearfix:after{
  clear: both;
  content: ".";
  display: block;
  height: 0;
  visibility: hidden;
  font-size: 0;
}
// ...
.search_results {
	// ...
	.tabs_wrapper {
		position: absolute;
		top: 0;
		right: 0;
		bottom: 0;
		left: 0;
		.tabs {
			clear: both;
			height: 30px;
			margin: 0;
			padding: 0;
			border-bottom: 1px solid #333;
			.tab {
				float: left;
				list-style-type: none;
				margin: 8px 1px 0 0;
				padding: 5px 10px 0 10px;
				border-top: 1px solid #333;
				border-right: 1px solid #333;
				border-left: 1px solid #333;
				@include search_bar_background_gradient;
				@include rounded_corners;
				&.current {
					a {
						color: #333;
					}
					background-image: none;
					border-bottom: 1px solid #fff;
				}
				a {
					color: #fff;
					text-decoration: none;
				}
			}
		}
		.panel {
			position: absolute;
			top: 30px;
			right: 0;
			bottom: 0;
			left: 0;
			overflow: auto;
			.search_result {
				padding: 15px 0 0 5px;
				border-bottom: 1px solid #333;
				.avatar {
					float: left;
					margin: 0 5px 0 0;
				}
				.info {
					float: left;
					.text {
						max-width: 960px;
						padding: 2px;
					}
				}
				&.answer {
					.info {
						float: none;
						.text {
							.question,
							.answer {
								font-weight: bold;
							}
						}
					}
				}
			}
		}
	}
}
// ...

And this is how the application looks after these changes:

Event-driven interaction between History and SearchResults views

We now move on to implement the final event-driven interaction between the History and SearchResults views. In this use case the user clicks the search button, the SearchBar view throws a “search” event, the Srchr view is listening for it, activates the correct tab panel and calls the search method on the relevant SearchResults view.

A diagram for this sequence of interactions is shown bellow:

Here are the changes to the code:

srchr/history/history.js
// ...
var History = Backbone.View.extend({

	events: {
		'click .remove': 'removeSearch',
		'click': 'selectSearch',
		'mouseleave .search': 'mouseleave',
		'mouseenter .search': 'mouseenter'
	},
	// ...
	selectSearch: function(ev) {

		this.$el.trigger('search', this._getSearch(ev).attributes);
	},

	searchAdded: function(search, searches) {
	// ...
srchr/srchr.js
var Srchr = Backbone.View.extend({

	events: {
		'search .search_bar': 'newSearch',
		'search .history': 'historySearch'
	},
	// ...
	historySearch: function(ev, search) {

		this.performSearch(search);
	},

	performSearch: function(search) {
	// ...

Final thoughts

In this article we’ve implemented Rebecca Murphey’s srchr application using Backbone.js javascript MVC framework following the same development flow as we did for the ExtJs and JavascriptMVC frameworks.

The full codebase can be downloaded from here and an online demo can be seen by clicking this link. A github repository with a branch for each of the different implementations can be cloned from this github page so you can easely compare the different versions.

In the next article we’ll tackle building the srchr application using AngularJS.

I hope this article was useful for you and thank you for reading my blog post.

comments powered by Disqus