AngularJS Extend and Computed Property

This is an Intermediate level discussion on AngularJS Extend, Computed Property in general, and a fairly complicated binding bug that needs some new learning.

 

I came from a KnockoutJS world.  In the KnockoutJS world which I'm familiar with, there is the concept of a Computed Property.

KnockoutJS Computed Property

Because native Javascript objects does not raise OnPropertyChanged events, KnockoutJS wraps properties with an observable wrapper.
A class would look like this:

function PersonViewModel(){
  var self = this;
  
  self.firstName = ko.observable("");
  self.lastName = ko.observable("");
  
  self.fullName = ko.computed(function(){
    return [
      ko.unwrap(self.firstName), 
      ko.unwrap(self.lastName)
    ].join(" ");
  });
}

The developer takes care of the properties firstName and lastName, and KnockoutJS understands when firstName or lastName changes, fullName needs to be re-evaluated.

AngularJS binds Native Javascript Object

In AngularJS, there is no direct concept of a Computed Property, instead, AngularJS watches various binding expressions and automatically re-evaluate expressions.  Since AngularJS binds to native Javascript directly, it employs a dirty checking mechanism.

A native person might look like this:

var person = {
  firstName: "",
  lastName: ""
};

And a View would look like this:

<div>
  First Name: {{ person.firstName }}
  Last Name: {{ person.lastName }}
  Full Name: {{ person.firstName + ' ' + person.lastName }}
</div>

Defining Class for AngularJS

But very quickly, one would find that this gets very unwieldy, and you end up duplicating expressions everywhere in your View.  Duplicated code is never good.  So we need to tidy up the definition of the model a bit more.

function Person(){
  var self = this;
  
  self.firstName = "";
  self.lastName = "";
  
  self.fullName = function(){
    return [self.firstName,self.lastName].join(" ");
  };
}

AngularJS Extend

Such definitions are nice and good, but you will need a way to populate these when you pull your data back from a REST service as JSON.  Luckily, AngularJS provides both angular.extend as well as angular.merge helper methods to get you going quickly. 

If you are familar with jQuery, this is nearly identical to jQuery.extend, it copies the properties from the later objects onto the first one.

var jsonJohn = { firstName: "John", lastName: "Liu" };
var personJohn = angular.extend( new Person(), jsonJohn );

// personJohn should have firstName, lastName 
// and fullName() == 'John Liu'

The updated View is then:

<div>
  First Name: {{ person.firstName }}
  Last Name: {{ person.lastName }}
  Full Name: {{ person.fullName() }}
</div>

Extending Arrays

When you have a collection of people, the code needs to look like this:

var people = [];
var jsonPeople = [
  { firstName: "John", lastName: "Liu" },
  { firstName: "Mark", lastName: "Liu" }
];
people = angular.extend( people, jsonPeople );
<div data-ng-repeat="person in people">
  First Name: {{ person.firstName }}
  Last Name: {{ person.lastName }}
  Full Name: {{ person.fullName() }}
</div>

This looks good initially, except there are a few problems:

  • angular.extend does not know anything about Person class, and fullName is not available
var people = [
  new Person(),
  new Person()
];
var jsonPeople = [
  { firstName: "John", lastName: "Liu" },
  { firstName: "Mark", lastName: "Liu" }
];
people = angular.extend( people, jsonPeople );
  • angular.extend will override people[0] with jsonPeople[0] (not null).  So the result still does not define fullName()

So you need something like this:

var people = [
  new Person() // always show one row by default even if blank
];
var jsonPeople = [
  { firstName: "John", lastName: "Liu" },
  { firstName: "Mark", lastName: "Liu" }
];
var l = jsonPeople.length > people.length ? jsonPeople.length : people.length;
for (var i=0; i < l; i++){
  people[i] = angular.extend( new Person(), people[i], jsonPeople[i] );
  // copy jsonPeople[i] (if not null) onto people[i]
  // copy people[i] onto new Person()
  // store it back on people[i]
}

A subtle bug in copying with extend

The Computed Property doesn't work because the way angular.extend copies references, people[i].fullName is copied to (new Person()).fullName, which means that the (new Person()).fullName is calculating from the previous people[i].fullName, because it uses the 'self' reference.

(let me know if I need to draw a picture)

There are two ways to fix this:

1. We can fix how we extend existing records.

//...
for (var i=0; i < l; i++){
  people[i] = angular.extend( people[i] || new Person(), jsonPeople[i] );
  // OR  people[i] and new Person()
  // so that if there is an existing Person object, it is kept
}

2. We can fix how we define the Person class.  I think this is the better method.

function Person(){
  var self = this;
  
  self.firstName = "";
  self.lastName = "";
  
  Person.prototype.fullName = function(){
    // re-connect 'self' to the current object
    var self = this;
    return [self.firstName,self.lastName].join(" ");
  };
}

This bumps the fullName method to the prototype/supertype, so it is no longer an immediate property defined on the Person object.

Summary

  • Notes on doing computed property in AngularJS
  • How to define a Class for AngularJS
  • How to handle extend and extend an array
  • How to move computed methods to the prototype
  • Plunker http://plnkr.co/edit/HcuxnT3EAVrtz1PyWwXb check out the two Count1 and Count2 computed properties and how they work differently after you clicked 'update'