Knockout was inspired by the Model-View-ViewModel (MVVM) pattern that's popular with Silverlight and WPF developers. It is intended to help keep UI fabric (HTML etc) and data separated and to make is it easier to build rich user interfaces through a declarative data binding system. At its core is the View Model, or a representation of the data that your page or "View" will use. Knockout's real strength lies in its ability to track changes in the view model's values, and apply those changes to any dependencies as well as automatically propagate those changes to the UI.
OK, great - but what's all that in plain English? Hmmm. Probably easiest to explain through example.
The sample application will allow a user to select a category from the Northwind database and view all products associated with that category. They will be able to select individual products and view more details for that chosen product, including the number of units in stock. They will be able to buy a unit, and the total units in stock will reduce accordingly. All of this will happen in one page, with no full postbacks. The site will use jQuery, although Knockout does not require it. I have used WebMatrix 2 RC, which means that I was able to obtain both jQuery and Knockout from the new Nuget integration:
The layout page code contains references to both libraries, an optional RenderSection call for additional scripts and some minimal styling:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <title>@Page.Title</title> <style> body{font-size: 85%; font-family:"Segoe UI", Arial, Helvetica;} #products{float:left; width:300px;} #details{float:left;width:400px;} .row{clear:both;} .label{float:left;width:120px;padding-right:5px;text-align:right; font-weight:bold;} h3{padding-left:120px;} </style> <script src="~/Scripts/jquery-1.7.2.min.js"></script> <script src="~/Scripts/knockout-2.1.0.js"></script> @RenderSection("script", required: false) </head> <body> @RenderBody() </body> </html>
One other Razor 2 thing to notice is the new cleaner Url resolution mechanism that means you do not need to use the Href helper anymore. Razor knows that ~/ represents the root of the site. The layout page is specified in a _PageStart.cshtml file in the root of the site.
The data for the application will be provided as JSON using the JSON helper. Three sets of data are required according to the functional requirement outlined above: a list of categories (GetCategories.cshtml), a list of products by category (GetProductsByCategory.cshtml) and details of a specific product (GetProductById.cshtml). The 3 separate files are placed in a folder called JsonService:
GetCategories.cshtml @{ var db = Database.Open("Northwind"); var sql = "SELECT CategoryId, CategoryName FROM Categories"; var data = db.Query(sql); Response.ContentType = "application/json"; Json.Write(data, Response.Output); }
GetProductsByCategory.cshtml @{ if (UrlData[0].IsEmpty() || !UrlData[0].IsInt()) { Response.SetStatus(HttpStatusCode.BadRequest); Response.End(); } var db = Database.Open("Northwind"); var sql = "SELECT ProductId, ProductName From Products Where CategoryId = @0"; var data = db.Query(sql, UrlData[0]); if(data.Count() == 0){ Response.SetStatus(HttpStatusCode.BadRequest); Response.End(); } Response.ContentType = "application/json"; Json.Write(data, Response.Output); }
GetProductById.cshtml @{ if (UrlData[0].IsEmpty() || !UrlData[0].IsInt()) { Response.SetStatus(HttpStatusCode.BadRequest); Response.End(); } var db = Database.Open("Northwind"); var sql = @"SELECT ProductId, ProductName, ContactTitle, ContactName, CompanyName, QuantityPerUnit, UnitPrice, UnitsInStock FROM Products INNER JOIN Suppliers ON Products.SupplierID = Suppliers.SupplierID WHERE ProductId = @0"; var data = db.Query(sql, UrlData[0]); if(data.Count() == 0){ Response.SetStatus(HttpStatusCode.BadRequest); Response.End(); } Response.ContentType = "application/json"; Json.Write(data, Response.Output); }
A _PageStart.cshtml file is added to the JsonService folder, setting the layout page to null to prevent the JSON being polluted inadvertently with HTML. The second and third files perform some validation on UrlData to ensure that it is present and of the right data type before attempting to us the value to obtain data from the database. If there are any problems with the value, an HTTP status code of 400 is returned to the client, otherwise data is returned in JSON format using the JSON helper.
Good. That's all the plumbing sorted. The next thing to do is to create the view model, which represents all of the data that the UI will need. Just to recap the requirement, this will consist of the following:
- A collection of categories
- A collection of products
- Details of a specific product
The view model is created using JavaScript, so it needs to go inside <script> tags. This will be injected into the RenderSection call in the layout page. So the first few lines of Default.cshtml look like this:
@{ Page.Title = "Knockout Sample"; } @section script{ <script type="text/javascript"> var viewModel; $(function() { viewModel = { productId: ko.observable(), productName: ko.observable(), contactTitle: ko.observable(), contactName: ko.observable(), companyName: ko.observable(), quantityPerUnit: ko.observable(), unitPrice: ko.observable(), unitsInStock: ko.observable(), categories: ko.observableArray([]), products: ko.observableArray([]) };
A variable for the view model is declared outside of the jQuery ready function that fires when all of the page has loaded. Within that function, the view model is defined. The first 8 properties relate to an individual product and are defined as ko.observable(). The parenthesis at the end of observable tell you that these are actually functions. They issue notifications to any observers when their values change. This is known as the Observer pattern. The two collections are defined as ko.observableArray(), which is the array equivalent of ko.observable. None of the view model's properties are initialised with a value. If you want to provide default values, you would supply them as arguments to the appropriate ko.observable method:
viewModel = {
productId: ko.observable(1),
productName: ko.observable("Ham")
One of the application requirements is to provide a means for the user to buy a unit of their chosen product, and for the remaining number of units available to reflect the fact that one has gone. The viewModel object that has been declared needs a method to manage that:
viewModel.buy = function(){ if(this.unitsInStock() > 0){ this.unitsInStock(this.unitsInStock() - 1); } else { alert('Out of stock!'); } };
As has been said before, observables are functions. When you want to access the value of one, you call the function with no arguments. If you want to set the value, you pass the new value into the function as an argument. The buy function first checks to see if the unitsInStock observable value is currently greater than zero. If it isn't an Out Of Stock message is displayed in an alert. Otherwise, the current value is decreased by one.
One more line of code is required to register the view model with Knockout:
ko.applyBindings(viewModel);
The next block of code gets data in JSON format from the services that were created earlier:
$.getJSON('/JsonService/GetCategories', function(data) { viewModel.categories(data); }); $('#categories').change(function() { $.getJSON('/JsonService/GetProductsByCategory/' + $(this).val(), function(data) { viewModel.products(data); }); }); $('tr').live('hover', function() { $(this).css('cursor', 'pointer'); }).live('click', function() { $.getJSON('/JsonService/GetProductById/' + $(this).find('td:first').text(), function(data) { var product = data[0]; viewModel.productName(product.ProductName); viewModel.contactTitle(product.ContactTitle); viewModel.contactName(product.ContactName); viewModel.companyName(product.CompanyName); viewModel.unitPrice('£' + product.UnitPrice.toFixed(2)); viewModel.quantityPerUnit(product.QuantityPerUnit); viewModel.unitsInStock(product.UnitsInStock); }); }); }); </script> }
The first getJSON call fetches the categories when the page loads. Then the viewModel.categories property is populated with the data. This will be bound to a dropdown list. The next getJSON call obtains products for a selected category. It fires when the dropdown list selection changes, and passes the list's selected value as a parameter. The resulting data populates the viewModel.products property. This is bound to a table for display. The final block of jQuery code makes each table row selectable and turns the mouse pointer to a hand. When a row is clicked on, the product's ID is obtained from the first cell in the row and used as a parameter in the getJSON call. The resulting data is bound manually to the individual properties in the viewModel object.
Finally, all this data needs to be displayed. I said earlier that Knockout offers a "declarative data binding" system. Now is the time to see what that actually means. Here's how the categories are bound to a dropdown list:
<select id="categories" data-bind="options: viewModel.categories, optionsText: 'CategoryName', optionsValue: 'CategoryID', optionsCaption: 'Choose...'"></select>
This has been broken over several lines for display on a web page, but it doesn't need to be. The key to the "declarative data binding" system is the HTML5 "data-bind" attribute. The "options" binding is used specifically for dropdown lists. The source data to be bound is specified, along with which part of the data should be bound to the text of an <option> element and which part should be bound to the "value" attribute. I have also used the optionsCaption to specify a default selection. Using this system, Knockout knows to bind the correct data to an HTML control, but it also knows that the bound data should change in response to changes in the underlying view model.
Once a category has been chosen, its associated products are retrieved and they need to be displayed somewhere. The next section of code shows how a <table> element is prepared for them:
<table> <tbody data-bind="foreach: viewModel.products"> <tr> <td><span data-bind="text:ProductID"></span></td> <td><span data-bind="text:ProductName"></span></td> </tr> </tbody> </table>
This uses the "foreach" binding, which tells Knockout to duplicate a section of markup for each element in the observable array. Within the table cells, a span's text property is bound to the ProductID and the ProductName properties of each element in the array using the "text" binding.
Finally, once a product has been selected from this table, it's details need to be displayed:
<div id="details"> <h3>Details</h3> <div class="row"> <span class="label">Product:</span> <span data-bind="text: productName"></span> </div> <div class="row"> <span class="label">Supplier:</span> <span data-bind="text: companyName"></span> </div> <div class="row"> <span class="label">Contact:</span> <span data-bind="text: contactName"></span> </div> <div class="row"> <span class="label">Position:</span> <span data-bind="text: contactTitle"></span> </div> <div class="row"> <span class="label">Unit Price:</span> <span data-bind="text: unitPrice"></span> </div> <div class="row"> <span class="label">Quantity Per Unit:</span> <span data-bind="text: quantityPerUnit"></span> </div> <div class="row"> <span class="label">Units In Stock:</span> <span data-bind="text: unitsInStock"></span> </div> <div class="row"> <span class="label"> </span> <span><button data-bind="click: buy">Buy One</button></span> </div> </div>
Each of the view model's properties are bound to spans using the text binding, but the final element - the button - is bound to the viewModel.buy method using the "click" binding. When the button is clicked, the viewModel.buy method is run and the amount displayed in Units In Stock is decremented by one as a result of the underlying change in the viewModel value.
You can test this for yourself by downloading the sample application from GitHub. It is written in Web Pages 2, but will run if you have not installed that version yet. Simply unzip the folder and right-click, then choose Open as a Web Site With WebMatrix.
There are other ways to achieve the same effect, but they often involve keeping track of events and targeting specific DOM elements with jQuery or similar to update values. Knockout takes care of that for you, as well as providing a really easy templating system and databinding syntax.