A few months ago I ripped through a number of MVVM JS frameworks while starting a new project and found Knockout.js to be exactly what I was looking for. I found their introductory tutorials to be an excellent place to start but also a bit of a poor reference since they have to be completed step by step. Below are my notes on the 5 knockout tutorials for eas(ier) reference.
Tutorial 1 – Introduction to Knockout/MVVM
View
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
<!-- This is a *view* - HTML markup that defines the appearance of your UI -->
//Data Binding (data-bind)
//This attribute is how KO lets you declartively assosciate your viewmodels with DOM elements.
//One of the most simple types of binding is text-binding:
First name: <strong data-bind="text: firstName">todo</strong>
Last name: <strong data-bind="text: lastName">todo</strong>
Full name: <strong data-bind="text: fullName"></strong>
//Many other types of binding are available such as value binding:
First name: <input type="text" data-bind="value: firstName" />
Last name: <input type="text" data-bind="value: lastName" />
//Or click binding:
<button data-bind="click: capitalizeLastName">Go caps</button> |
View Model
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
// This is a simple *viewmodel* - JavaScript that defines the data and behavior of your UI
function AppViewModel() {
this.species = "Human"; // declares a static string as a variable
//Observables (ko.observable)
//Changing the value in the above input boxes will change the viewmodel but the
//UI isn't changed since the viewmodel properties are just JS strings. Observables
//are properties that will notify KO when their value changes.
this.firstName = ko.observable("Bert");
this.lastName = ko.observable("Bertington");
//Computed Values (ko.computed)
//Often you'll want access to observed values in other properties. For example
//to combine two strings, or add a number values together.
this.fullName = ko.computed(function() { //using a callback function
return this.firstName() + " " + this.lastName();
}, this);
//Behaviours
//Note: when accessing observed values, it must be called as a function.
this.capitalizeLastName = function() {
var currentVal = this.lastName(); // Read the current value
this.lastName(currentVal.toUpperCase()); // Write a new value
};
}
// Activates knockout.js and applies the bindings
ko.applyBindings(new AppViewModel()); |
2 -Lists and Collections
View
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
<h2>Your seat reservations</h2>
//Repeating Elements (foreach)
//Observable arrays and the foreach binding are used to create repeating
//UI elements. Other control flow bindings are if, ifnot, and with
<table>
<thead><tr>
<th>Passenger name</th><th>Meal</th><th>Surcharge</th><th></th>
</tr></thead>
<tbody data-bind="foreach: seats">
<tr>
//Since meal is a ko.observable it must be invoked as a function
//to obtain it's current value before reading its properties
<td data-bind="text: name"></td>
<td data-bind="text: meal().mealName"></td>
<td data-bind="text: meal().price"></td>
</tr>
</tbody>
//Repeating Data
//options and optionsText are bindings for controlling dropdown lists
<tbody data-bind="foreach: seats">
<tr>
<td><input data-bind="value: name" /></td>
<td><select data-bind="options: $root.availableMeals, value: meal, optionsText: 'mealName'"></select></td>
//a new formattedPrice observed value is needed to format the amount
<td data-bind="text: formattedPrice"></td>
//Removing Data
//"$ root." causes KO to look for removeSeat handler on top-level model
//instead of SeatReservation instance it is bound to
<td><a href="#" data-bind="click: $root.removeSeat">Remove</a></td>
</tr>
</tbody>
</table>
//Visibility (visible)
//This binding controls the visibility of an element using CSS display
//Arbitrary JS expressions can be used inside declerative bindings
<h3 data-bind="visible: totalSurcharge() > 0">
Total surcharge: $<span data-bind="text: totalSurcharge().toFixed(2)"></span>
</h3>
//Adding a new element through the defined function does not refresh the dom
<button data-bind="click: addSeat">Reserve another seat</button>
//Displaying more information can be done easily
<h2>Your seat reservations (<span data-bind="text: seats().length"></span>)</h2> |
View Model
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
// Class to represent a row in the seat reservations grid
// This is a simple constructor to store the properties
function SeatReservation(name, initialMeal) {
var self = this;
self.name = name;
self.meal = ko.observable(initialMeal);
//To create a computed property on the seat reservation class
//ko.computed will be used
self.formattedPrice = ko.computed(function() {
var price = self.meal().price;
return price ? "$" + price.toFixed(2) : "None";
});
}
// Overall viewmodel for this screen, along with initial state
function ReservationsViewModel() {
var self = this;
// Non-editable catalog data - would come from the server
self.availableMeals = [
{ mealName: "Standard (sandwich)", price: 0 },
{ mealName: "Premium (lobster)", price: 34.95 },
{ mealName: "Ultimate (whole zebra)", price: 290 }
];
// Editable data - these are instances of seat reservation class
// Note: this is a ko.observableArray
self.seats = ko.observableArray([
new SeatReservation("Steve", self.availableMeals[0]),
new SeatReservation("Bert", self.availableMeals[0])
]);
//surcharge
//note seats and meal are both observables and have to be invoked
//as functions
self.totalSurcharge = ko.computed(function() {
var total = 0;
for (var i = 0; i < self.seats().length; i++)
total += self.seats()[i].meal().price;
return total;
});
// Operations
self.addSeat = function() {
self.seats.push(new SeatReservation("", self.availableMeals[0]));
self.removeSeat = function(seat) { self.seats.remove(seat) }
}
}
ko.applyBindings(new ReservationsViewModel()); |
3- Single Page Applications
View
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
/*For hash-based navigation, the visitor's position in a virtual navigation space is stored in the URL hash, which is the part of the URL after a 'hash' symbol (e.g., /my/app/#category=shoes&page=4). Whenever the URL hash changes, the browser doesn't issue an HTTP request to fetch a new page; instead it merely adds the new URL to its back/forward history list and exposes the updated URL hash to scripts running in the page. The script notices the new URL hash and dynamically updates the UI to display the corresponding item (e.g., page 4 of the "shoes" category).
This makes it possible to support back/forward button navigation in a single page application (e.g., pressing 'back' moves to the previous URL hash), and effectively makes virtual locations bookmarkable and shareable.
pushState is an HTML5 API that offers a different way to change the current URL, and thereby insert new back/forward history entries, without triggering a page load. This differs from hash-based navigation in that you're not limited to updating the hash fragment — you can update the entire URL.*/
<!-- Modern applicaitons use hash-based or pushState navigation to
support back/forward actions as well as bookmarking. Many libraries
exist for this, sammy.js is used in this tutorial-->
<script src="/scripts/lib/sammy.js" type="text/javascript"></script>
<!-- The technique here is to add an extra layer of indirection.
Originally goToFolder and goToMail functions triggered Ajax requests
and updated the viewmodel. Now these functions will only trigger client
side navigation.
Sammy will detect the navigation and then make the requests and update
the state. This means that if navigation is triggered by the user without
using the UI, the app will still function correctly.-->
<!-- Folders
//CSS binding is used to display active folder
//goToFolder is invoked when a folder is clocked
-->
<ul class="folders" data-bind="foreach: folders">
<li data-bind="text: $data,
css: { selected: $data == $root.chosenFolderId() },
click: $root.goToFolder"></li>
</ul>
<!-- Mails grid
Binding Context (with)
"with" creates a binding context, binding elements inside it
everything inside the table will be bound to chosenFolderData
to chosenFolderData. prefix is not required -->
<table class="mails" data-bind="with: chosenFolderData">
<thead><tr><th>From</th><th>To</th><th>Subject</th><th>Date</th></tr></thead>
<tbody data-bind="foreach: mails">
<tr data-bind="click: $root.goToMail">
<td data-bind="text: from"></td>
<td data-bind="text: to"></td>
<td data-bind="text: subject"></td>
<td data-bind="text: date"></td>
</tr>
</tbody>
</table>
<!-- Chosen mail -->
<div class="viewMail" data-bind="with: chosenMailData">
<div class="mailInfo">
<h1 data-bind="text: subject"></h1>
<p><label>From</label>: <span data-bind="text: from"></span></p>
<p><label>To</label>: <span data-bind="text: to"></span></p>
<p><label>Date</label>: <span data-bind="text: date"></span></p>
</div>
//html binding allows linebreaks and html markup to be displayed
<p class="message" data-bind="html: messageContent" />
</div> |
View Model
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
function WebmailViewModel() {
// Data
var self = this;
self.folders = ['Inbox', 'Archive', 'Sent', 'Spam'];
self.chosenFolderId = ko.observable();
//contains data for chosen folder
self.chosenFolderData = ko.observable();
//contains data for chosen mail item
self.chosenMailData = ko.observable();
// Behaviours - removed for url routing
// self.goToFolder = function(folder) {
// self.chosenFolderId(folder);
// Stop showing a mail
// self.chosenMailData(null);
//Ajax request for data
// $.get('/mail', { folder: folder }, self.chosenFolderData);
// };
// self.goToMail = function(mail) {
// self.chosenFolderId(mail.folder);
// self.chosenFolderData(null); // Stop showing a folder
// $.get("/mail", { mailId: mail.id }, self.chosenMailData);
// };
//functions only trigger navigation
self.goToFolder = function(folder) { location.hash = folder };
self.goToMail = function(mail) { location.hash = mail.folder + '/' + mail.id };
// Client-side routes
// Implements the same logic as the previous goTo functions
Sammy(function() {
this.get('#:folder', function() {
self.chosenFolderId(this.params.folder);
self.chosenMailData(null);
$.get("/mail", { folder: this.params.folder }, self.chosenFolderData);
});
this.get('#:folder/:mailId', function() {
self.chosenFolderId(this.params.folder);
self.chosenFolderData(null);
$.get("/mail", { mailId: this.params.mailId }, self.chosenMailData);
});
//go to inbox by default - runRoute: loads the inbox
this.get('', function() { this.app.runRoute('get', '#Inbox') });
}).run();
// Show inbox by default - removed because it ignored routes
//self.goToFolder('Inbox');
};
ko.applyBindings(new WebmailViewModel()); |
See also: http://jsfiddle.net/rniemeyer/PctJz/
4- Custom Bindings
View
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
//in KO bindings are intermediaries: viewmodel changes dom, dom events change viewmodel properties.
//custom bindings should be encapsulated for re-use
//Define a custom binding by assigning a new property to the ko.bindingHandlers object. Your property can expose two callback functions:
//init - called when the binding first happens (useful to set initial state or register event handlers)
//update -called whenever the associated data updates (so you can update the DOM to match)
<h3 data-bind="text: question"></h3>
<p>Please distribute <b data-bind="text: pointsBudget"></b> points between the following options.</p>
<table>
<thead><tr><th>Option</th><th>Importance</th></tr></thead>
<tbody data-bind="foreach: answers">
<tr>
<td data-bind="text: answerText"></td>
<!--<td><select data-bind="options: [1,2,3,4,5], value: points"></select></td> // Replaced by stars-->
<td data-bind="starRating: points"></td>
</tr>
</tbody>
</table>
<h3 data-bind="fadeVisible: pointsUsed() > pointsBudget">You've used too many points! Please remove some.</h3>
<p>You've got <b data-bind="text: pointsBudget - pointsUsed()"></b> points left to use.</p>
<button data-bind="jqButton:{enable: pointsUsed() <= pointsBudget}, click: save">Finished</button> |
View Model
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 |
// Reusable bindings - ideally kept in a separate file
//New Custom Binding for Visibility
ko.bindingHandlers.fadeVisible = {
init: function(element, valueAccessor) {
// Start visible/invisible according to initial value
// This prevents the element from initially fading on pageload
var shouldDisplay = valueAccessor();
$(element).toggle(shouldDisplay);
},
//update handler is given bound element + func to return assosciated data
update: function(element, valueAccessor) {
// On update, fade in/out
var shouldDisplay = valueAccessor();
shouldDisplay ? $(element).fadeIn() : $(element).fadeOut();
}
};
//Custom binding for jQuery UI button
ko.bindingHandlers.jqButton = {
init: function(element) {
$(element).button(); // Turns the element into a jQuery UI button
},
//By default jQuery UI buttns don't respond to HTML disabled state
update: function(element, valueAccessor) {
var currentValue = valueAccessor();
// Here we just update the "disabled" state, but you could update other properties too
$(element).button("option", "disabled", currentValue.enable === false);
}
};
//Custom Binding for Rating Stars
ko.bindingHandlers.starRating = {
init: function(element, valueAccessor) {
$(element).addClass("starRating");
for (var i = 0; i < 5; i++)
$("<span>").appendTo(element);
// Pure jQuery code for handling hover state and obtaining value
$("span", element).each(function(index) {
$(this).hover(
function() { $(this).prevAll().add(this).addClass("hoverChosen") },
function() { $(this).prevAll().add(this).removeClass("hoverChosen") }
).click(function() {
var observable = valueAccessor(); // Get the associated observable
observable(index+1); // Write the new rating to it
});
});
},
update: function(element, valueAccessor) {
// Give the first x stars the "chosen" class, where x <= rating
var observable = valueAccessor();
$("span", element).each(function(index) {
$(this).toggleClass("chosen", index < observable());
});
}
};
function Answer(text) { this.answerText = text; this.points = ko.observable(1); }
function SurveyViewModel(question, pointsBudget, answers) {
this.question = question;
this.pointsBudget = pointsBudget;
this.answers = $.map(answers, function(text) { return new Answer(text) });
this.save = function() { alert('To do') };
this.pointsUsed = ko.computed(function() {
var total = 0;
for (var i = 0; i < this.answers.length; i++)
total += this.answers[i].points();
return total;
}, this);
}
ko.applyBindings(new SurveyViewModel("Which factors affect your technology choices?", 10, [
"Functionality, compatibility, pricing - all that boring stuff",
"How often it is mentioned on Hacker News",
"Number of gradients/dropshadows on project homepage",
"Totally believable testimonials on project homepage"
])); |
See also: http://www.knockmeout.net/2011/07/another-look-at-custom-bindings-for.html
5 – Loading and saving Data
View
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
<h3>Tasks</h3>
<form data-bind="submit: addTask">
Add task: <input data-bind="value: newTaskText" placeholder="What needs to be done?" />
<button type="submit">Add</button>
</form>
<ul data-bind="foreach: tasks, visible: tasks().length > 0">
<li>
<input type="checkbox" data-bind="checked: isDone" />
<input data-bind="value: title, disable: isDone" />
<a href="#" data-bind="click: $parent.removeTask">Delete</a>
</li>
</ul>
You have <b data-bind="text: incompleteTasks().length"> </b> incomplete task(s)
<span data-bind="visible: incompleteTasks().length == 0"> - it's beer time!</span>
//JSON requests can be made back to the server, POSTing directly is easier
<form action="/tasks/saveform" method="post">
<input type="text" name="tasks" data-bind="value: ko.toJSON(tasks)"></input>
<button type="submit">Save</button>
</form>
//Sending model data using created save function
<button data-bind="click: save">Save</button> |
View Model
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
function Task(data) {
this.title = ko.observable(data.title);
this.isDone = ko.observable(data.isDone);
}
function TaskListViewModel() {
// Data
var self = this;
self.tasks = ko.observableArray([]);
self.newTaskText = ko.observable();
self.incompleteTasks = ko.computed(function() {
//return ko.utils.arrayFilter(self.tasks(), function(task) { return !task.isDone() });
//Extra filter needs to be added to not count destroyed tasks
return ko.utils.arrayFilter(self.tasks(), function(task) { return !task.isDone() && !task._destroy });
});
// Operations
self.addTask = function() {
self.tasks.push(new Task({ title: this.newTaskText() }));
self.newTaskText("");
};
//Standard removal
//self.removeTask = function(task) { self.tasks.remove(task) };
//Destroy removal
//Adds '_destroy' property to the task instance with a true value
//foreach binding is aware of this and will no longer display the item
self.removeTask = function(task) { self.tasks.destroy(task) };
// Load initial state from server, convert it to Task instances, then populate self.tasks
// $.getJSON gets remote JSON, $.map constructs a task instance from each entry
// Note: no need to re-bind after getting data, updating the view model is enough
$.getJSON("/tasks", function(allData) {
var mappedTasks = $.map(allData, function(item) { return new Task(item) });
self.tasks(mappedTasks);
});
// New save function for saving via a JSON request
// success hndler alerts server response
self.save = function() {
$.ajax("/tasks", {
data: ko.toJSON({ tasks: self.tasks }),
type: "post", contentType: "application/json",
success: function(result) { alert(result) }
});
};
}
ko.applyBindings(new TaskListViewModel()); |













