Extenders
The last "basic" feature to cover is extenders (don't worry, there is still plenty of advanced stuff to cover). Extenders offer a way to modify individual observables. Two common uses of extenders are as follows:
- Adding properties or functions to the observable
- Adding a wrapper around the observable to modify writes or reads
Simple extenders
Adding an extender is as simple as adding a new function to the ko.extenders
object with the name you want to use. This function receives the observable being extended (called the target) as the first argument, and any configuration passed to the extender is received as the second argument, as shown in the following code:
ko.extenders.recordChanges = function(target, options) { target.previousValues = ko.observableArray(); target.subscribe(function(oldValue) { target.previousValues.push(oldValue); }, null, 'beforeChange'); return target; };
This extender will create a new previousValues
property on the observable. This new property is as an observable array and old values are pushed to it as the original observable is changed (the current value is already in the observable of course).
The reason the extender has to return the target is because the result of the extender is the new observable. The need for this is apparent when looking at how the extender is called:
var amount = ko.observable(0).extend({ recordChanges: true});
The true
value sent to recordChanges
is received by the extender as the options
parameter. This value can be any JavaScript value, including objects and functions.
You can also add multiple extenders to an observable in the same call. The object sent to the extend
method will call an observable for every property it contains:
var amount = ko.observable(0).extend({ recordChanges: true,anotherExtender: { intOption: 1});
As the extend
method is called on the observable, usually during its initial creation, the result of the extend
call is what is actually stored. If the target is not returned, the amount
variable would not be the intended observable.
To access the extended value, you would use amount.previousValues()
from JavaScript, or amount.previousValues
if accessing it from a binding. Note the lack of parentheses after amount; because previousValues
is a property of the observable, not a property of the observable's value, it is accessed directly. This might not be immediately obvious, but it should make sense as long as you remember that the observable and the value the observable contains are two different JavaScript objects.
An example of this extender is in the cp1-extend
branch.
Extenders with options
The previous example does not pass any options to the recordChanges
extender, it just uses true
because the property requires a value to be a valid JavaScript. If you want a configuration for your extender, you can pass it as this value, and a complex configuration can be achieved by using another object as the value.
If we wanted to supply a list of values that are not to be recorded, we could modify the extender to use the options as an array:
ko.extenders.recordChanges = function(target, options) { target.previousValues = ko.observableArray(); target.subscribe(function(oldValue) { if (!(options.ignore && options.ignore.indexOf(oldValue) !== -1)) target.previousValues.push(oldValue) }, null, 'beforeChange'); return target; };
Then we could call the extender with an array:
var history = ko.observable(0).extend({ recordChanges: { ignore: [0, null] } });
Now our history
observable won't record values for 0
or null
.
Extenders that replace the target
Another common use for extenders is to wrap the observable with a computed observable that modifies reads or writes, in which case, it would return the new observable instead of the original target.
Let's take our recordChanges
extender a step further and actually block writes that are in our ignore
array (never mind that an extender named recordChanges
should never do something like this in the real world!):
ko.extenders.recordChanges = function(target, options) { var ignore = options.ignore instanceof Array ? options.ignore : []; //Make sure this value is available var result = ko.computed({ read: target, write: function(newValue) { if (ignore.indexOf(newValue) === -1) { result.previousValues.push(target()); target(newValue); } else { target.notifySubscribers(target()); } } }).extend({ notify: 'always'}); result.previousValues = ko.observableArray(); //Return the computed observable return result; };
That's a lot of changes, so let's unpack them.
First, to make ignore
easier to reference, I've set a new variable that will either be the options.ignore
property or an empty array. Defaulting to an empty array lets us skip the null check later, which makes the code a little easier to read. Second, I created a writable computed observable. The read
function just routes to the target observable, but the write
function will only write to the target if the ignore
option doesn't contain the new value. Otherwise, it will notify the target subscribers of the old value. This is necessary because if a UI binding on the observable initiated the change, it needs the illegal change to be reverted. The UI element would already have updated and the easiest way to change it back is through the standard binding notification mechanism that is already listening for changes.
The last change is the notify: always
extender that's on the result
. This is one of Knockout's default extenders. Normally, an observable will only report changes to subscribers when the value has been modified. To get the observable to reject changes, it needs to be able to notify subscribers of its current unchanged value. The notify extender forces the observable to always report changes, even when they are the same.
Finally, the extender returns the new computed observable instead of the target, so that anyone trying to write a value does so against the computed.
The cp1-extendreplace
branch has an example of this binding. Notice that trying to enter values into the input box that are included in the ignored options (0
or an empty string) are immediately reverted.