Knockout Projections – a plugin for efficient observable array transformations
knockout-projections is a new Knockout.js plugin that adds efficient “map” and “filter” features to observable arrays. This means you can:
- Write
myObservableArray.map(someMappingFunction)
to get a new, read-only array-valued observable containing the mapped version of each input item. - Write
myObservableArray.filter(someFilterFunction)
to get a new, read-only array-valued observable containing a subset of the input items.
When the underlying myObservableArray
changes, or whenever any observable accessed during the mapping/filtering changes, the output array will be recomputed efficiently, meaning that the map/filter callbacks will only be invoked for the affected items.
The point of all this: it can scale up to maintain live transforms of large arrays, with only a fixed cost (not O(N)
) to propagate typical changes through the graph of dependencies.
Trivial mapping example
To illustrate the mechanics in an obvious way, consider this underlying array:
var numbers = ko.observableArray([1, 2, 3]);
Now if you’ve referenced knockout-projections, you can write:
var squares = numbers.map(function(x) { return x*x; });
Initially, squares
will contain [1, 4, 9]
. It’s observable, so you can use squares.subscribe
to get notifications when it mutates, or you can bind it to a DOM element (e.g., foreach: squares
) in KO’s usual way.
Now if you transform the underlying array:
numbers.push(8);
… then squares
updates (to [1, 4, 9, 64]
) and only calls your mapping function for the new item, 8
.
Any transformation is permitted, e.g.:
numbers.reverse();
This has the effect of reversing squares
(to [64, 9, 4, 1]
), again without remapping anything.
If you remove item(s):
numbers.splice(1, 1);
… then squares
is updated (here, to [64, 1]
) without remapping anything.
In summary, anyObservableArray.map
is an efficient, observable equivalent to Array.prototype.map
. Also, it can be arbitrarily chained (and combined in chains with filter
) to produce graphs of transformations.
Trivial filtering example
The filter
feature works exactly as you’d expect, given the above. For example,
var evenSquares = squares.filter(function(x) { return x % 2 === ; });
Initially, evenSquares
will contain just [64]
. When you mutate the underlying array,
numbers.push(4); // evenSquares now contains [64, 16] numbers.push(5); // evenSquares doesn't change numbers.push(6); // evenSquares now contains [64, 16, 36]
Again, it responds to arbitrary transformations:
numbers.sort(); // evenSquares now contains [16, 36, 64]
A more realistic use case
Typically you won’t just be playing around will small collections of numbers. Most Knockout apps work with collections of model objects – sometimes very large collections.
Here’s a simple model object:
function Product(data) { this.id = data.id; this.name = ko.observable(data.name); this.price = ko.observable(data.price); this.isSelected = ko.observable(false); }
Many KO applications involve fetching a large collection of model objects and exposing that from a viewmodel:
function PageViewModel() { // Some data, perhaps loaded via an Ajax call this.products = ko.observableArray([ new Product({ id: 1, name: 'Klein Burger', price: 3.99 }), new Product({ id: 2, name: 'Mobius Fries', price: 1.75 }), new Product({ id: 3, name: 'Uncountable Chicken Chunks', price: 3.59 }), new Product({ id: 4, name: 'Mandelbrot Salad', price: 2.40 }), ... etc ... ]); } ko.applyBindings(new PageViewModel());
Now this might be bound to the UI:
<ul data-bind="foreach: products"> <li> <input type="checkbox" data-bind="checked: isSelected" /> <strong data-bind="text: name"></strong> (Price: £<span data-bind="text: price().toFixed(2)"></span>) </li> </ul>
This is all fine, and Knockout is already good at efficiently (i.e., incrementally) updating the UI when the products
array changes. But what if you want to track which subset of products is “selected”?
The traditional approach would be something like:
this.selectedProducts = ko.computed(function() { return this.products().filter(function(product) { return product.isSelected(); }); }, this);
This works (using Array.prototype.filter
, not the Knockout-projections filter
function). However, it’s inefficient. Every time the products
array changes, and every time any of their isSelected
properties changes, it re-evaluates the isSelected
property of every product. It has to do so, because it has no built-in understanding of what you’re doing, so it can’t be clever and incremetally update the earlier selectedProducts
array.
However, if you use Knockout-projections filter
function, e.g.:
this.selectedProducts = this.products.filter(function(product) { return product.isSelected(); });
… it’s both syntactically cleaner, and way faster for large arrays: it now updates the selectedProducts
array incrementally whenever either products
changes or any of the isSelected
values changes.
Similarly you might want to output the names of the selected items. So, you could chain on a new selectedNames
property, and perhaps furthermore chain on selectionSummary
:
this.selectedNames = this.selectedProducts.map(function(product) { return product.name(); }); this.selectionSummary = ko.computed(function() { return this.selectedNames().join(', ') || 'Nothing'; }, this);
Now when products
changes, or when an isSelected
or name
property changes, the effect will propagate out incrementally through the whole dependency graph with the minimum of callback-invoking, meaning a snappy user experience even with large data sets and low-end mobile devices.
Licence and origin
knockout-projections is open source under the Apache 2.0 license. It was built as a tiny component in a very large project I’m working on at Microsoft, and thanks to my boss (and his boss, and probably multiple people up the chain) we’ve decided to open-source it. Hopefully my team will be producing plentiful nuggets of OSS goodness for you as we go about our work
Just a clarification for the avoidance of any confusion, Knockout.js itself remains MIT licensed and is run by the KO community and the KO core team members (which includes me personally).