How to build a large, single-page javascript application with KnockoutJS

In my previous posts, I’ve outlined and implemented a possible workflow for developing large, single-page javascript applications using backbone.js, angular.js, extjs and javascriptmvc. In this article we’ll focus on using the knockout.js MVVM framework to implement srchr, the canonical single-page javascript application we’ve been using throughout the series.

If you’re the curious type you can download the code and try out the online demo. There’s also a github repository with a branch for each of the alternative MVC framework implementations that can be used for ease of comparison between the different codebases.

Let’s start by breaking down the application into a set of knockout specifc components that we’ll later glue together in order to build the final knockout.js version of the srchr application.

Designing the KnockoutJS application

A common question developers ask when developing with the knockout.js MVVM framework is how to organize their code in a maintainable way when their codebase starts getting too big to fit inside a single ViewModel. This article tries to show an innovative solution to this problem that enables developers to breakdown a large application into multiple ViewModels thus getting closer to the goal of having a clear separation of concerns and clean maintainable code as a consequence.

So let’s start by breaking the srchr application into multiple ViewModels:

And here’s an overview of the file organization structure where we breakdown our application into the set of subcomponents that we’ll be working with for the rest of this article.

Requirejs will be used as our dependency management tool and grunt-sass as the sass pre-compiler.

Scaffolding the KnockoutJS application

Following the same pattern as in previous articles in this series we’ll start by quickly sccafolding the main layout for our application so we can have a base from which we’ll flesh out the details of each one of the multiple components that we’ll glue together to build the application.

srchr/index.html
<!DOCTYPE HTML>
<html>
<head>
    <meta http-equiv="content-type" content="text/html; charset=utf-8">
    <title>Index</title>
    <link rel="stylesheet" href="styles/styles.css" type="text/css" media="screen" charset="utf-8">
</head>
<body>
    <div id="srchr"></div>
    <script data-main="main" src="lib/require.js" charset="utf-8"></script>
</body>
</html>
srchr/main.js
    requirejs.config({
        paths: {
            'text': 'lib/text',
            'jquery': 'http://ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery',
            'underscore': 'lib/underscore',
            'knockout': 'lib/knockout'
        },
        shim: {
            'knockout': {
                deps: [],
                exports: 'ko'
            }
        }
});

require(['jquery', 'knockout', 'js/srchr/srchr'], function($, ko, SrchrViewModel) {

        // ALLOW FOR NESTED VIEW MODELS
        ko.bindingHandlers.stopBinding = {

            init: function() {

                return { controlsDescendantBindings: true }
            }

        };
        ko.virtualElements.allowedBindings.stopBinding = true;

        // BOOTSTRAP THE APPLICATION
        var srchrEl = $('#srchr');
        ko.applyBindings(new SrchrViewModel({
            element: srchrEl
        }), srchrEl[0]);
});
srchr/js/srchr.js
define(['jquery',
        'knockout',

        'js/srchr/search_bar/search_bar',
        'js/srchr/history/history',
        'js/srchr/search_results/twitter/twitter',
        'js/srchr/search_results/answers/answers',

        'js/srchr/tabs/tabs',

        'text!js/srchr/views/layout.tmpl'],

        function($, ko,

            SearchBarViewModel,
            HistoryViewModel,
            TwitterSearchResultsViewModel,
            AnswersSearchResultsViewModel,

            TabsViewModel,

            layoutTmpl) {

            var SrchrViewModel = function(options) {

                // KEEP A REFERENCE TO THE VIEW'S ROOT ELEMENT
                this.element = options.element;

                // RENDER THE APPLICATION LAYOUT
                this.element.html(layoutTmpl);

                // SETUP THE SEARCH BAR VIEW MODEL
                var searchBarEl = this.element.find('.search_bar'),
                    searchBar = new SearchBarViewModel({
                        element: searchBarEl
                });
                ko.applyBindings(searchBar, searchBarEl[0]);

                // SETUP THE HISTORY VIEW MODEL
                var historyEl = this.element.find('.history'),
                    history = new HistoryViewModel({
                        element: historyEl
                });
                ko.applyBindings(history, historyEl[0]);

                // SETUP THE TWITTER SEARCH RESULTS VIEW MODEL
                var twitterSearchResultsEl = this.element.find('.twitter .search_results'),
                    twitterSearchResults = new TwitterSearchResultsViewModel({
                        element: twitterSearchResultsEl
                });
                ko.applyBindings(twitterSearchResults, twitterSearchResultsEl[0]);

                // SETUP THE ANSWERS SEARCH RESULTS VIEW MODEL
                var answersSearchResultsEl = this.element.find('.answers .search_results'),
                    answersSearchResults = new AnswersSearchResultsViewModel({
                        element: answersSearchResultsEl
                });
                ko.applyBindings(answersSearchResults, answersSearchResultsEl[0]);

                // SETUP THE TABS VIEW MODEL
                var tabsContainerEl = this.element.find('.tabs_container'),
                    tabs = new TabsViewModel({
                        element: tabsContainerEl
                });
                ko.applyBindings(tabs, tabsContainerEl[0]);
            };

            return SrchrViewModel;
});
srchr/js/srchr/views/layout.tmpl
<div class="header">
    <!-- ko stopBinding: true -->
    <div class="search_bar"></div>
    <!-- /ko -->
</div>
<div class="content">
    <!-- ko stopBinding: true -->
    <div class="history"></div>
    <!-- /ko -->
    <!-- ko stopBinding: true -->
    <div class="search_results_container">
        <div class="tabs_container">
            <ul class="tabs clearfix">
                <li class="tab" data-bind="css: { selected: twitterVisible }"><a href="#twitter" data-bind="click: showTwitterPanel">Twitter</a></li>
                <li class="tab" data-bind="css: { selected: answersVisible }"><a href="#answers" data-bind="click: showAnswersPanel">Answers</a></li>
            </ul>
            <div class="twitter service" data-bind="visible: twitterVisible">
                <!-- ko stopBinding: true -->
                <div class="search_results">TWITTER SEARCH RESULTS PLACEHOLDER</div>
                <!-- /ko -->
            </div>
            <div class="answers service" data-bind="visible: answersVisible">
                <!-- ko stopBinding: true -->
                <div class="search_results">ANSWERS SEARCH RESULTS PLACEHOLDER</div>
                <!-- /ko -->
            </div>
        </div>
    </div>
    <!-- /ko -->
</div>
srchr/js/srchr/search_bar/search_bar.js
define(['jquery',
        'knockout',

        'text!js/srchr/search_bar/views/search_bar.tmpl'],

        function($, ko, searchBarTmpl) {

        var SearchBarViewModel = function(options) {

            // KEEP A REFERENCE TO THE VIEW'S ROOT ELEMENT
            this.element = options.element;

            this.element.html(searchBarTmpl);
        };

        return SearchBarViewModel;
});
srchr/js/srchr/search_bar/views/search_bar.tmpl
    

SEARCH BAR PLACEHOLDER

srchr/js/srchr/history/history.js
define(['jquery',
        'knockout',

        'text!js/srchr/history/views/history.tmpl'],

        function($, ko,

            historyTmpl) {

        var HistoryViewModel = function(options) {

            var self = this;

                // KEEP A REFERENCE TO THE VIEW'S ROOT ELEMENT
                this.element = options.element;

                // RENDER THE HISTORY VIEW
                this.element.html(historyTmpl);
            };

            return HistoryViewModel;
        });
srchr/js/srchr/history/views/history.tmpl
    

HISTORY PLACEHOLDER

srchr/js/srchr/tabs/tabs.js
define(['jquery',
        'knockout'],

        function($, ko) {

        var TabsViewModel = function(options) {

            this.twitterVisible = ko.observable(true);
            this.answersVisible = ko.observable(false);

            this.showTwitterPanel = function() {

                this.twitterVisible(true);
                this.answersVisible(false);
            };

            this.showAnswersPanel = function() {

                this.twitterVisible(false);
                this.answersVisible(true);
            };
        };

        return TabsViewModel;
});
srchr/js/srchr/search_results/twitter/twitter.js
define(['jquery',
        'knockout',

        'text!js/srchr/search_results/twitter/views/twitter.tmpl'],

        function($, ko, twitterTmpl) {

            var TwitterSearchResultsViewModel = function(options) {

                var self = this;

                // KEEP A REFERENCE TO THE VIEW'S ROOT ELEMENT
                this.element = options.element;

                this.element.html(twitterTmpl);
        };

        return TwitterSearchResultsViewModel;
});
srchr/js/srchr/search_results/twitter/views/twitter.tmpl
    

TWITTER PLACEHOLDER

srchr/js/srchr/search_results/answers/answers.js
define(['jquery',
        'knockout',

        'text!js/srchr/search_results/answers/views/answers.tmpl'],

        function($, ko, answersTmpl) {

            var AnswersSearchResultsViewModel = function(options) {

                var self = this;

                // KEEP A REFERENCE TO THE VIEW'S ROOT ELEMENT
                this.element = options.element;

                this.element.html(answersTmpl);
            };

            return AnswersSearchResultsViewModel;
        });
srchr/js/srchr/search_results/answers/views/answers.tmpl
    

ANSWERS PLACEHOLDER

srchr/styles/styles.scss
.clearfix:after {
    content: ".";
    display: block;
    clear: both;
    visibility: hidden;
    line-height: 0;
    height: 0;
}

.clearfix {
    display: inline-block;
}

html[xmlns] .clearfix {
    display: block;
}

* html .clearfix {
    height: 1%;
}

#srchr {
    position: fixed;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    .header {
        position: absolute;
        top: 0;
        right: 0;
        height: 65px;
        left: 0;
        border-bottom: 1px solid #333;
    }
    .content {
        position: absolute;
        top: 66px;
        right: 0;
        bottom: 0;
        left: 0;
        .history {
            position: absolute;
            top: 0;
            width: 320px;
            bottom: 0;
            left: 0;
            border-right: 1px solid #333;
      }
      .search_results_container {
            position: absolute;
            top: 0;
            right: 0;
            bottom: 0;
            left: 321px;
                  .tabs_container {
                        position: absolute;
                        top: 0;
                        right: 0;
                        bottom: 0;
                        left: 0;
                        .tabs {  
                              list-style: none;
                              margin: 0;
                              padding: 0;
                              .tab {
                                    float: left;
                                    margin: 0 0 0 5px;
                                    padding: 5px 5px 0 5px;
                                    border-left: 1px solid #999;
                                    border-top: 1px solid #999;
                                    border-right: 1px solid #999;
                                    border-bottom: 1px solid #999;
                                    background-color: #ddd;
                                    &.selected {
                                         border-bottom: none;
                                         background-color: #fff;
                                    }
                                    a {
                                         text-decoration: none;
                                    }
                             }
                      }
                  }
                  .twitter, .answers {
                      margin-top: -6px;
                      border-top: 1px solid #999;
                  }
        }
    }
}

And this is how the application looks like:

Now let’s split the application into the following three use cases:

  • Use Case #1: As an user, when I click the search button, the history panel is updated with a new search entry
  • Use Case #2: As an user, when I click the search button, the relevant tab opens and search results show up on the search results panel
  • Use Case #3: As an user, when I select a previous search from the history panel, the relevant tab opens and the search results show up on the results panel

and let’s work on each of these, one at a time.

Use Case #1: As an user, when I click the search button, the history panel is updated with a new search entry

In the previous posts we tried to follow an event-driven workflow where the SearchBar component would trigger a ‘search’ event that would be caught by the main Srchr component that in turn would tell the history component to add a new search entry. As it happens Knockout.js doesn’t have the concept of an event but we can workaround this by working with observable attributes and shared state.

The way shared state is going work is by declaring it in the main component (SrchrViewModel) and then pass it to the sub-views (SearchBar) that will be responsible for updating it. The main component (SrchrViewModel) will then subscribe to changes to shared state and act accordingly by acting on the relevant subviews (HistoryViewModel).

Here are the changes to the code:

srchr/js/srchr/search_bar/search_bar.js
var SearchBarViewModel = function(options) {

    // KEEP A REFERENCE TO THE VIEW'S ROOT ELEMENT
    this.element = options.element;

    // KEEP A REFERENCE TO THE CLIENT STATE
    this.clientState = options.clientState;

    this.element.html(searchBarTmpl);

    // LOCAL BINDINGS
    this.query = ko.observable('');
    this.service = ko.observable('twitter');

    // UPDATE CLIENT STATE SO THAT PARENT
    // MODEL VIEW CAN HANDLE A NEW SEARCH
    this.search = function() {

        this.clientState.search({
            query: this.query(),
            service: this.service(),
            isNew: true
        });
    };
};

return SearchBarViewModel;
srchr/js/srchr/search_bar/views/search_bar.tmpl
<input class="query" data-bind="value: query" />
<span class="twitter">
    <label>Twitter</label>
    <input class="check" type="radio" name="service" value="twitter" data-bind="checked: service">
</span>
<span class="asnwers">
    <label>Answers</label>
    <input class="check" type="radio" name="service" value="answers" data-bind="checked: service">
</span>
<button data-bind="click: search">Search</button>
srchr/js/srchr/srchr.js
var SrchrViewModel = function(options) {

    // KEEP A REFERENCE TO THE VIEW'S ROOT ELEMENT
    this.element = options.element;

    // RENDER THE APPLICATION LAYOUT
    this.element.html(layoutTmpl);

    // KEEP A REFERENCE TO SHARED STATE
    this.clientState = {
        search: ko.observable({})
    };

    // SETUP THE SEARCH BAR VIEW MODEL
    var searchBarEl = this.element.find('.search_bar'),
        searchBar = new SearchBarViewModel({
            element: searchBarEl,
            clientState: this.clientState
    });
    ko.applyBindings(searchBar, searchBarEl[0]);

    // SETUP THE HISTORY VIEW MODEL
    var historyEl = this.element.find('.history'),
        history = new HistoryViewModel({
            element: historyEl,
            clientState: this.clientState
    });
    ko.applyBindings(history, historyEl[0]);

    // SETUP THE TWITTER SEARCH RESULTS VIEW MODEL
    var twitterSearchResultsEl = this.element.find('.twitter .search_results'),
        twitterSearchResults = new TwitterSearchResultsViewModel({
            element: twitterSearchResultsEl,
            clientState: this.clientState
        });
    ko.applyBindings(twitterSearchResults, twitterSearchResultsEl[0]);
    // SETUP THE ANSWERS SEARCH RESULTS VIEW MODEL
    var answersSearchResultsEl = this.element.find('.answers .search_results'),
        answersSearchResults = new AnswersSearchResultsViewModel({
            element: answersSearchResultsEl,
            clientState: this.clientState
        });
    ko.applyBindings(answersSearchResults, answersSearchResultsEl[0]);

    // SETUP THE TABS VIEW MODEL
    var tabsContainerEl = this.element.find('.tabs_container'),
        tabs = new TabsViewModel({
            element: tabsContainerEl
    });
    ko.applyBindings(tabs, tabsContainerEl[0]);

    // LISTEN TO USER SUBMITTING A NEW SEARCH
    this.clientState.search.subscribe(function(search) {

        if(search.isNew)  {

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

return SrchrViewModel;
srchr/js/srchr/history/history.js
var HistoryViewModel = function(options) {

    var self = this;

    // KEEP A REFERENCE TO THE VIEW'S ROOT ELEMENT
    this.element = options.element;

    // RENDER THE HISTORY VIEW
    this.element.html(historyTmpl);

    // KEEP A REFERENCE TO THE CLIENT STATE
    this.clientState = options.clientState;

    // GET PREVIOUS SEARCHES FROM LOCAL STORAGE
    var json = localStorage.getItem('searches') || '[]',
        searches = $.parseJSON(json);

    // BINDINGS: START
    this.searches = ko.observableArray(searches);

    // API
    this.addSearch = function(search) {

        this.searches.push(search);

        localStorage.setItem(
                'searches',
                ko.toJSON(this.searches())
                );
    };

    this.remove = function(search) {

        self.searches.remove(search);

        localStorage.setItem(
            'searches',
            ko.toJSON(self.searches())
        );
    };
};
return HistoryViewModel;
srchr/js/srchr/history/views/history.tmpl
<ul class="searches" data-bind="foreach: searches">
    <li class="search">
        <span class="query" data-bind="text: query"></span>
        <span class="sep">|</span>
        <span class="service" data-bind="text: service"></span>
        <span class="remove" data-bind="click: $parent.remove">X</span>
    </li>
</ul>

And this is how the application looks like after the changes we made above:

Use Case #2: As an user, when I click the search button, the relevant tab opens and search results show up on the search results panel

To implement this use case we’ll change the code in the main view model (SrchrViewModel) where we subscribe to changes on the shared state so it does two things:

  • Changes the tab to the relevant search results panel
  • Commands the relevant search results view to execute a search and show the associated results

Here are the changes to the codebase:

srchr/js/srchr/srchr.js
// LISTEN TO USER SUBMITTING A NEW SEARCH
this.clientState.search.subscribe(function(search) {

        if(search.isNew)  {

            history.addSearch(search);
        }

        if(search.service == 'twitter') {

            tabs.showTwitterPanel();
            twitterSearchResults.search(search.query);

        } else {

            tabs.showAnswersPanel();
            answersSearchResults.search(search.query);
        }
});
srchr/js/srchr/search_results/twitter/twitter.js
define(['jquery',
        'knockout',

        'js/srchr/models/twitter',

        'text!js/srchr/search_results/twitter/views/twitter.tmpl'],

        function($, ko, Twitter, twitterTmpl) {

            var TwitterSearchResultsViewModel = function(options) {

                var self = this;

                // KEEP A REFERENCE TO THE VIEW'S ROOT ELEMENT
                this.element = options.element;

                this.element.html(twitterTmpl);

                this.searchResults = ko.observableArray([]);

                // EXECUTE TWITTER SEARCH
                this.search = function(query) {

                    Twitter.search(query, this.resultsLoaded);
                };

                // RENDER THE TWITTER SEARCH RESULTS VIEW
                this.resultsLoaded = function(results) {

                    self.searchResults(results);
                };
            };

            return TwitterSearchResultsViewModel;
});
srchr/js/srchr/search_results/twitter/views/twitter.tmpl
<div class="results" data-bind="foreach: searchResults">
    <div class="result clearfix">
        <div class="profile_image"><img data-bind="attr: { src: user.profile_image_url } "></div>
        <div class="info">
        <div class="username"><strong data-bind="text: user.name"></strong></div>
        <div class="text" data-bind="text: text"></div>
        </div>
    </div>
</div>
srchr/js/srchr/search_results/answers/answers.js
define(['jquery',
        'knockout',

        'js/srchr/models/answers',

        'text!js/srchr/search_results/answers/views/answers.tmpl'],

        function($, ko, Answers, answersTmpl) {

            var AnswersSearchResultsViewModel = function(options) {

                var self = this;

                // KEEP A REFERENCE TO THE VIEW'S ROOT ELEMENT
                this.element = options.element;

                this.element.html(answersTmpl);

                this.searchResults = ko.observableArray([]);

                // EXECUTE YQL SEARCH
                this.search = function(query) {

                    Answers.search(query, this.resultsLoaded);
                };

                // RENDER THE YQL SEARCH RESULTS VIEW
                this.resultsLoaded = function(results) {

                    self.searchResults(results);
                };
            };

        return AnswersSearchResultsViewModel;
});
srchr/js/srchr/search_results/answers/views/answers.tmpl
<div class="results" data-bind="foreach: searchResults">
    <div class="result clearfix">
        <div class="profile_image"><img data-bind="attr: { src: UserPhotoURL }"></div>
        <div class="info">
            <div class="username"><strong data-bind="text: UserNick"></strong></div>
            <div class="text" data-bind="text: Content"></div>
            <div class="text" data-bind="text: ChosenAnswer"></div>
        </div>
    </div>
</div>

The models where we implement the calls to the remote services:

srchr/js/srchr/models/twitter.js
define(['jquery',
        'knockout'],

        function($, ko) {

            var Twitter = {

                urlRoot: '/node/search/tweets',

                search: function(query, callback) {

                    var url = this.urlRoot + '?q=' + encodeURIComponent(query);

                    $.ajax({
                        url: url,
                        type: 'get',
                        dataType: 'json',
                        success: function(data) {
                            callback(data.statuses);
                        }
                    });
                }
            };

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

       function($, ko) {

       var Answers = {

                urlRoot: 'http://query.yahooapis.com/v1/public/yql',

                search: function(query, callback) {

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

                    var url = this.urlRoot + '?q=' + encodeURIComponent(yql) + '&format=json';

                    $.ajax({
                            url: url,
                            type: 'get',
                            dataType: 'jsonp',
                            success: function(data) {

                                var query = data && data.query,
                                results = query && query.results,
                                Question = results && results.Question || [];

                                callback(Question);
                             }
                    });
                }
    };

    return Answers;
});
srchr/styles/styles.scss
.search_results_container {
    ...
    .twitter, .answers {
        margin-top: -6px;
        border-top: 1px solid #999;
        .search_results {
            .result {
                width: 100%;
                padding: 5px;
                border-bottom: 1px solid #aaa;
                 .profile_image {
                    float: left;
                    margin: 0 5px;
                 }
                 .info {
                    float: left;
                     .text {
                           max-width: 800px;
                     }
                 }
            }
        }
    }
}

How the app looks like so far:

Use Case #3: As an user, when I select a previous search from the history panel, the relevant tab opens and the search results show up on the results panel

The changes to the codebase:

srchr/js/srchr/history/history.js
define(['jquery',
        'knockout',

        'text!js/srchr/history/views/history.tmpl'],

        function($, ko,

            historyTmpl) {

        var HistoryViewModel = function(options) {

            ...

            this.search = function(search) {

                search.isNew = false;

                self.clientState.search(search);
            };
        };

        return HistoryViewModel;
});
srchr/js/srchr/history/views/history.tmpl
<ul class="searches" data-bind="foreach: searches">
    <li class="search" data-bind="click: $root.search">
        <span class="query" data-bind="text: query"></span>
        <span class="sep">|</span>
        <span class="service" data-bind="text: service"></span>
        <span class="remove" data-bind="click: $parent.remove">X</span>
    </li>
</ul>

Final thoughts

In this article we’ve shown what I hope is an innovative way to break down a large knockout.js application into a set of extendable and maintainable sub-components. We followed the same event-driven code flow as in previous articles using observable shared state between components instead of real events.

Let me know if you know of a framework for which you would like to see an implementation of the canonical srchr application and I’ll try to add an article to the series.

Thank you for reading this article.

comments powered by Disqus