Monday, September 23, 2013

Knockout.js - Extenders

One of the great things in Knockout is that you can enhance the functionalities of your observables by using extenders. A good example is when you want to force the observable's value to be an integer between a minimum and maximum value. Validating an e-mail address or an url, or just logging the value changes of the observable are also great examples.
To apply an extender to the observable, you have to use the observable's extend function, which takes an object as an argument. The object's properties will be the extenders that Knockout will look for in the ko.extenders object. If they are found, each of them will be applied to the observable.
Let's see an example:
var myIntegerObservable =
        ko.observable(0).extend({
                integer: {min: 0, max: 10}
        });
The code above means, that I want to apply an extender called integer to the observable, and this extender will get the object with the min and max properties. To create it, I have to set the ko.extenders.integer property to a function which returns an observable. Knockout will pass two parameters to this function. The first is the observable on which the extender is applied and the second is the parameter given to the observable's extend function. In our case it's the object with the min and max properties. An extender which does nothing, looks like this:
ko.extenders.dummyExtender = function(target, options) {
        return target;
};
Now let's see a real-life example, namely the integer extender, which I mentioned above:

 <html>  
      <head>  
           <script src="http://code.jquery.com/jquery-1.10.1.min.js"></script>  
           <script src="http://ajax.aspnetcdn.com/ajax/knockout/knockout-2.2.1.js"></script>  
           <script>  
                ko.extenders.integer = function(target, options) {  
                     var result = ko.computed({  
                          read: target,  
                          write: function(newValue) {  
                               var currentValue     = target()  
                               ,     newValueAsInt     = 0  
                               ;  
                               if (!isNaN(newValue)) {  
                                    try {  
                                         newValueAsInt = parseInt(newValue);  
                                    } catch (e) {  
                                         //do nothing, 0 is a good value in this case.  
                                    }  
                               }  
                               if (typeof options.min === "number" && newValueAsInt < options.min) {  
                                    newValueAsInt = options.min;  
                      } else if (typeof options.max === "number" && newValueAsInt > options.max) {  
                           newValueAsInt = options.max;  
                      }  
                      //only write if it changed  
                      if (newValueAsInt !== currentValue) {  
                        target(newValueAsInt);  
                      }  
                          }  
                     });       
                     //initialize with current value to make sure it is rounded appropriately  
                  result(target());  
                  result.incr = function incrNumeric(viewModel, event) {  
                       event.stopPropagation();  
                       var res = result();  
                       try {  
                            res = parseInt(res);  
                            result(res + 1);  
                       } catch (e) {  
                            result(0);  
                       }  
                  };  
                  result.decr = function decrNumeric(viewModel, event) {  
                       event.stopPropagation();  
                       var res = result();  
                       try {  
                            res = parseInt(res);  
                            result(res - 1);  
                       } catch (e) {  
                            result(0);  
                       }  
                  };  
                  //return the new computed observable  
                  return result;  
                };  
                $(document).ready(function() {  
                     ko.applyBindings({  
                          myNumber: ko.observable(0).extend({integer: {min: 0, max: 10}})  
                     });  
                });  
           </script>  
      </head>  
      <body>  
           <div data-bind="text: myNumber"></div>  
           <button data-bind="click: myNumber.decr">-</button>  
           <input data-bind="value: myNumber" />  
           <button data-bind="click: myNumber.incr">+</button>  
      </body>  
 </html>  

The integer extender above returns a writable computed - I stole this idea from knockoutjs.com - with a custom write method. The read method just returns what the original observable would return. The custom write method parses its input as an integer, forces this integer to be in the interval given by the options object's min and max properties. In addition it adds an incrementor and a decrementor function to the observable (incr and decr properties). This is totally valid, since functions are first-class citizens in Javascript. If you don't know much about functional programming, I recommend to read my post about functional Javascript.

In my previous post I mentioned, that if a computed's value depends on more than one observables and these observables values are updated (and changed) one after the other, then the evaluator function of the computed will be called multiple times. To avoid this, you can use the throttle extender, which delays the re-evaluation by a given number of milliseconds. If you do something similar to the following example, the evaluation will happen only once, even if multiple referred observables change:
ko.computed(function() {
        //fetching the value of lots of observables...
}).extend({throttle: 1});
Creating your own extenders is very useful, and helps you to organize your code better. The next post will be about an other way of extending the framework by writing your own custom bindings.

No comments:

Post a Comment

Share It