Anthony Chu Contact Me

Using Knockout View Models in an AngularJS App

Wednesday, November 19, 2014

Angular 1.3 added an ngModelOptions directive that brings some flexibility in the way ngModel does databinding. One interesting option is getterSetter, which tells Angular that the model is actually a getter/setter function.

Before I got into Angular over a year ago, my frontend framework of choice was Knockout. Knockout’s view models consist of observables, observable arrays, and computeds, which are all implemented as getter/setters. So when I saw that Angular added support for binding to getters and setters, I knew I had to try to see if I can use Knockout view models in an Angular app.

So can you use a knockout view model – without modification – in an angular app? It turns out the answer is yes! I’m not sure if it has any real-world uses, but it was definitely an interesting exercise.

I began with a demo from Knockout’s site. It’s a shopping cart that is built with observables, observable arrays, and computeds.

Here are the Cart and CartLine view models from the Knockout app that we will reuse in the Angular app…

var CartLine = function() {
    var self = this;
    self.category = ko.observable();
    self.product = ko.observable();
    self.quantity = ko.observable(1);
    self.subtotal = ko.computed(function() {
        return self.product() ? self.product().price * parseInt("0" + self.quantity(), 10) : 0;
    });

    // Whenever the category changes, reset the product selection
    self.category.subscribe(function() {
        self.product(undefined);
    });
};

var Cart = function() {
    // Stores an array of lines, and from these, can work out the grandTotal
    var self = this;
    self.lines = ko.observableArray([new CartLine()]); // Put one line in by default
    self.grandTotal = ko.computed(function() {
        var total = 0;
        $.each(self.lines(), function() { total += this.subtotal() })
        return total;
    });

    // Operations
    self.addLine = function() { self.lines.push(new CartLine()) };
    self.removeLine = function(line) { self.lines.remove(line) };
    self.save = function() {
        var dataToSave = $.map(self.lines(), function(line) {
            return line.product() ? {
                productName: line.product().name,
                quantity: line.quantity()
            } : undefined
        });
        alert("Could now send this to server: " + JSON.stringify(dataToSave));
    };
};

To use it in Angular, we need to first create a module and a controller. The controller couldn’t be simpler, we just need to new up the Cart view model and include the same list of products used by the Knockout demo app…

angular.module('cart', [])
    .controller('CartController', CartController);

function CartController() {
    this.cart = new Cart();
    this.sampleProductCategories = sampleProductCategories;
}

Here is the original shopping cart with Knockout databinding…

<tbody data-bind='foreach: lines'>
    <tr>
        <td>
            <select data-bind='options: sampleProductCategories, optionsText: "name", optionsCaption: "Select...", value: category'> </select>
        </td>
        <td data-bind="with: category">
            <select data-bind='options: products, optionsText: "name", optionsCaption: "Select...", value: $parent.product'> </select>
        </td>
        <td class='price' data-bind='with: product'>
            <span data-bind='text: formatCurrency(price)'> </span>
        </td>
        <td class='quantity'>
            <input data-bind='visible: product, value: quantity, valueUpdate: "afterkeydown"' />
        </td>
        <td class='price'>
            <span data-bind='visible: product, text: formatCurrency(subtotal())'> </span>
        </td>
        <td>
            <a href='#' data-bind='click: $parent.removeLine'>Remove</a>
        </td>
    </tr>
</tbody>

And here’s what it looks like converted to Angular.

<tbody>
    <tr ng-repeat="line in vm.cart.lines()">
        <td>
            <select ng-options="category.name for category in vm.sampleProductCategories"
                    ng-model="line.category" ng-model-options="{ getterSetter: true }">
                <option value="">Select...</option>
            </select>
        </td>
        <td>
            <select ng-options="product.name for product in line.category().products"
                    ng-model="line.product" ng-model-options="{ getterSetter: true }"
                    ng-show="line.category()">
                <option value="">Select...</option>
            </select>
        </td>
        <td class='price'>
            <span ng-bind='line.product().price | currency'></span>
        </td>
        <td class='quantity'>
            <input ng-show="line.product()" ng-model="line.quantity" ng-model-options="{ getterSetter: true }" />
        </td>
        <td class='price'>
            <span ng-show="line.product()" ng-bind="line.subtotal() | currency"></span>
        </td>
        <td>
            <a href='#' ng-click="vm.cart.removeLine(line)">Remove</a>
        </td>
    </tr>
</tbody>

The conversions are straight forward.

Knockout binding Angular equivalent
foreach ng-repeat
options ng-options
value ng-model with getterSetter:true
visible ng-show
formatCurrency (custom fn) currency filter (built-in)
click ng-click

The new Angular app works exactly like the original Knockout app. It’d be interesting to see a more complex view model that makes REST API calls, for example. There would definitely need to be some extra work to make sure the Angular digest cycle is triggered properly. But I think if the original view models followed the Single Responsibility Principle and took data services as dependencies, that might actually be doable. Maybe I’ll give it a try next time.

The source code can be found here: https://github.com/anthonychu/knockout-in-angular

And here is a link to the working demo.