Routing with Knockout


Knockout does one thing very well: data-binding with the MVVM pattern. It allows you to decouple your application logic from the DOM, making it very maintainable. It is not an all-in-one framework and this isn't a bad thing; you're free to compose a solution for your needs. This post explains how to add basic routing to your Knockout apps with a small 3rd party library and some minimal code.

Routing allows users to "deep link" into specific states in your application, e.g. #/users and #/users/123. This requires code to react to changes of window.location.hash, capturing parameters and loading corresponding views. Side note: I'm not getting into pushState for now.

The component binding helps you load components and pass them parameters. Both the component name and the parameters can be observable, which makes loading dynamic views trivial.

Here's the HTML, notice the data-bind="component... part:


<div class="view-container" data-bind="component: { 
        name: controller.viewName, 
        params: controller.viewParams }, 
    css: {'is-transitioning': controller.isTransitioning}"></div>

<template id="list-view">
    <h2 data-bind="text: name"></h2>
    <div data-bind="foreach: items">
        <p>
            <a data-bind="attr: {href: '#/items/'+$data}, text: 'Item #'+$data"></a> 
        </p>
    </div>
</template>

<template id="details-view">
    <h2 data-bind="text: name"></h2>
    <a href="#/items">go to list</a>
</template>

<template id="not-found">
    <h2>404: View Not Found</h2>
    <a href="#/items">go to list</a>
</template>

In our application / ViewModel code, we declare our views and their corresponding routes as configuration for our "KnockoutController":


var ListView = function(params) {
    console.log("ctor ListView", params);
    this.name = "LIST";
    this.items = [123,456,789];
    this.dispose = function() {
        console.log("disposing list");
    }
};

var DetailsView = function(params) {
    console.log("ctor DetailsView", params);
    this.name = "DETAILS: ITEM #" + params.itemId;
    this.dispose = function() {
        console.log("disposing details");
    }
};

var MyApp = function() {
    this.controller = new KnockoutController({
        transitionDelayMs: 300,
        views: [{
            name: "list",
            componentConfig: {
                viewModel: ListView,
                template: {element: "list-view"}
            },
            routes: ["/items"]
        },{
            name: "details",
            componentConfig: {
                viewModel: DetailsView,
                template: {element: "details-view"}
            },
            routes: ["/items/:itemId"]
        },
        {
            name: "404",
            componentConfig: {
                template: {element: "not-found"}
            }
        }
        ],
        defaultView: {
            name: "list",
            params: {}
        }
    });
};

var app = new MyApp();
ko.applyBindings(app);

Routing itself can be quite complicated (default routes, re-directs, parameter parsing, etc.) so head on over to microjs and find a library that makes you happy. I chose Grapnel because it's relatively simple and only ~1 KB.

The following code for the KnockoutController does three things:


var KnockoutController = function(config) {
    var defaults = {
            transitionDelayMs: 0,
            views: []
        },
        settings = ko.utils.extend(defaults, config || {}),
        router = new Grapnel(),
        self = this,
        loadView = function(viewName, routeParams) {
            self.isTransitioning(true);
            setTimeout(function(){
                self.viewParams(routeParams);
                self.viewName(viewName);
                self.isTransitioning(false);
            }, settings.transitionDelayMs);
         };
    // props
    self.viewName = ko.observable(settings.defaultView.name);
    self.viewParams = ko.observable(settings.defaultView.params || {});
    self.isTransitioning = ko.observable(false);
    // initialization
    ko.utils.arrayForEach(settings.views, function(vc) {
        if(vc.name && vc.componentConfig){
            ko.components.register(vc.name, vc.componentConfig);
            if(vc.routes && vc.routes.length){
                ko.utils.arrayForEach(vc.routes, function(route){
                    router.get(route, function(req, ev) {
                        loadView(vc.name, req.params);
                    });
                });
            }
            if(vc.name === "404"){
                router.get("/*", function(req, ev) {
                    if(!ev.parent()){
                        loadView(vc.name, req.params);
                    }
                });
            }
        }
    });
};

View the live example here. This code has much room for improvement but the point is to show how easy it can be to add routing. Achievements unlocked:

[ Archive · Home ]