On Github hjdivad / talk-intro-array-computed
David J. Hamilton
@hjdivad
github.com/hjdivad
A computed property is a dynamic value computed from its dependent properties.
When a dependent property is changed, the computed property is completely invalidated.
var Person = Ember.Object.extend({
loudName: function () {
return this.get('name').toUpperCase();
}.property('name')
});
var david = Person.create({ name: 'David' });
david.get('loudName') //=> "DAVID"
david.set('name', 'David J. Hamilton')
// Re-run `loudName`
david.get('loudName') //=> "DAVID J. HAMILTON"
function makeLoud(str) { return str.toUpperCase(); }
var obj = Ember.Object.extend({
names: ["Marlborough", "Eugene", "Vendôme", "Villars"],
// This won't work
loudNames: function () {
return this.get('names').map(makeLoud);
}.property('names')
}).create();
obj.get('loudNames')
//=> ["MARLBOROUGH", "EUGENE", "VENDÔME", "VILLARS"]
obj.get('names').pushObject("Berwick");
// Where's our buddy Berwick?
obj.get('loudNames')
//=> ["MARLBOROUGH", "EUGENE", "VENDÔME", "VILLARS"]
loudNames: function () {
/*
`loudNames` is invalidated when `names` is set to a new array,
but not when it is mutated (eg when appending new items)
*/
}.property('names')
Solution: `[]`
function makeLoud(str) { return str.toUpperCase(); }
var obj = Ember.Object.extend({
names: ["Marlborough", "Eugene", "Vendôme", "Villars"],
loudNames: function () {
return this.get('names').map(makeLoud);
}.property('names.[]')
}).create();
obj.get('loudNames')
//=> ["MARLBOROUGH", "EUGENE", "VENDÔME", "VILLARS"]
obj.get('names').pushObject("Berwick");
obj.get('loudNames')
//=> ["MARLBOROUGH", "EUGENE", "VENDÔME", "VILLARS", "BERWICK"]
Use `@each.<propertyName>`
var Person = Ember.Object.extend({
name: ''
});
function p(name) { return Person.create({ name: name }); }
var obj = Ember.Object.extend({
people: [ p("Marlborough"), p("Eugene"),
p("Vendôme"), p("Villars")],
loudNames: function () {
return this.get('people').mapBy('name').map(makeLoud);
}.property('people.@each.name')
}).create();
obj.get('loudNames')
//=> ["MARLBOROUGH", "EUGENE", "VENDÔME", "VILLARS"]
obj.get('people').objectAt(1).set('name', 'Overkirk');
obj.get('loudNames')
//=> ["MARLBOROUGH", "OVERKIRK", "VENDÔME", "VILLARS"]
Solution: `Ember.arrayComputed`
`Ember.computed` includes several array computed macros for common cases
var map = Ember.computed.map;
var obj = Ember.Object.extend({
people: [ p("Marlborough"), p("Eugene"),
p("Vendôme"), p("Villars")],
loudNames: map('people.@each.name', function (person) {
return person.get('name').toUpperCase();
})
}).create();
obj.get('loudNames')
//=> ["MARLBOROUGH", "EUGENE", "VENDÔME", "VILLARS"]
obj.get('people').objectAt(1).set('name', 'Overkirk');
obj.get('people').pushObject(Person.create({
name: 'Berwick'
}));
// This time our function was only run for two objects
// instead of the entire array
obj.get('loudNames')
//=> ["MARLBOROUGH", "OVERKIRK", "VENDÔME", "VILLARS", "BERWICK"]
loudNames: function () {
return this.get('people').mapBy('name').map(makeLoud);
}.property('people.@each.name')
loudNames: map('people.@each.name', function (person) {
return person.get('name').toUpperCase();
})
The function you supply in an array computed property is like the body of a loop.
This is why ember is able to do the work for the new and modified items, rather than looping over all of them.
`Ember.computed.map` is a common enough case that there's a macro in ember for it
There are other macros for common cases
Ember.arrayComputed('dependentKey1', /* depKey2, depKey3, …, */ {
initialize: function (array, changeMeta, instanceMeta) {
// initialize instanceMeta if you need a scratchpad
return array;
},
addedItem: function (array, item, changeMeta, instanceMeta) {
// do something to `array` when an item is added
return array;
},
removedItem: function (array, item, changeMeta, instanceMeta) {
// do something to `array` when an item is removed
return array;
}
});
{
initialize: function (array, changeMeta, instanceMeta) {
return array;
}
}
`array` is the initial value, an empty array.
`changeMeta` contains metadata about the CP. For `initialize` there is only:
`instanceMeta` is a scratchpad for your array computed property.
It is unique to the property on the object to which the CP is attached.
{
addedItem: function (array, item, changeMeta, instanceMeta) {
return array;
}
}
`array` is the current value of the array computed property.
`item` is an item that has been added to a dependent array.
`changeMeta` contains metadata about the CP. For `addedItem` this includes:
`changeMeta contains metadata about the CP. For `addedItem` this includes:
`instanceMeta` is the same scratchpad passed to `initialize`.
{
removedItem: function (array, item, changeMeta, instanceMeta) {
return array;
}
}
var obj = Ember.Object.extend({
myArrayCP: Ember.arrayComputed('upstream.@each.property', { /* … */ })
});
obj.get('upstream').objectAt(2).set('property', 'newValue');
Modified items are treated as a remove and immediate re-add.
{
removedItem: function (array, item, changeMeta, instanceMeta) {
return array;
}
}
During removes resulting from modifications, `changedMeta` also contains `previousValues`, a POJO with the old values.
This can help you eg both remove and add an item from a sorted array in O(lg n).
function map(dependentKey, callback) {
var options = {
addedItem: function(array, item, changeMeta, instanceMeta) {
var mapped = callback.call(this, item);
array.insertAt(changeMeta.index, mapped);
return array;
},
removedItem: function(array, item, changeMeta, instanceMeta) {
array.removeAt(changeMeta.index, 1);
return array;
}
};
return arrayComputed(dependentKey, options);
};
arrayComputed('someArray', 'someString', {
addedItem: function () { /* … */ },
removedItem: function () { /* … */ }
});
When a non-array dependency is changed, the array computed property is completely invalidated.
Other than managing array observers, and providing the one-at-a-time callbacks, array computed properties behave like any other computed property.
var App.PeopleController = Ember.ArrayController.extend({
itemController: 'person',
myArrayCP: Ember.arrayComputed('???', { /* … */ })
});
var App.PeopleController = Ember.ArrayController.extend({
itemController: 'person',
// If we refer to `model` directly, our items won't be using
// the person item controller
myArrayCP: Ember.arrayComputed('model', { /* … */ })
});
var App.PeopleController = Ember.ArrayController.extend({
itemController: 'person',
self: function () {
return this;
}.property(),
myArrayCP: Ember.arrayComputed('self', { /* … */ })
});
var App.PeopleController = Ember.ArrayController.extend({
itemController: 'person',
myArrayCP: Ember.arrayComputed('@this', { /* … */ })
});
Ember.Object.extend({
flags: ['includeThis', 'modifyThat'],
upstreamArray: [],
myArrayCP: Ember.arrayComputed('upstreamArray', '???', { /* … */ })
});
Other than managing array observers, and providing the one-at-a-time callbacks, array computed properties behave like any other computed property.
Ember.Object.extend({
flags: ['includeThis', 'modifyThat'],
upstreamArray: [],
myArrayCP: Ember.arrayComputed('upstreamArray', 'flags.[]', { /* … */ })
});
When `flags` is mutated, the array computed property is completely invalidated.
When `upstreamArray` is mutated, the one-at-a-time callbacks (`addedItem`, `removedItem`) are invoked.
You always want at least one dependent key with one-at-a-time semantics.
If you don't have this, just use a regular CP.
Array computed properties are like a live-updating `reduce` where the produced value happens to be an array.
Can we have the same one-at-a-time semantics for non-array values?
Ember.reduceComputed('dependentKey1', /* depKey2, depKey3, …, */ {
initialValue: function () {
return Ember.Set.create();
},
initialize: initFn,
addedItem: addedItemFn,
removedItem: removedItemFn
});
Ember.reduceComputed('dependentKey1', /* depKey2, depKey3, …, */ {
// simple values don't need a function
initialValue: 0, // or "string", &c.
initialize: initFn,
addedItem: addedItemFn,
removedItem: removedItemFn
});
function uniq() {
var args = Array.prototype.slice.call(arguments);
args.push({
initialValue: function () { /* … */ },
initialize: function () { /* … */ },
addedItem: function () { /* … */ },
removedItem: function () { /* … */ },
});
return reduceComputed.apply(null, args);
};
initialValue: function () {
return Ember.Set.create();
},
initialize: function(set, changeMeta, instanceMeta) {
instanceMeta.itemCounts = {};
}
addedItem: function(set, item, changeMeta, instanceMeta) {
var guid = guidFor(item);
if (!instanceMeta.itemCounts[guid]) {
instanceMeta.itemCounts[guid] = 1;
set.addObject(item);
} else {
++instanceMeta.itemCounts[guid];
}
return set;
}
removedItem: function(set, item, changeMeta, instanceMeta) {
var guid = guidFor(item),
itemCounts = instanceMeta.itemCounts;
if (--itemCounts[guid] === 0) {
set.removeObject(item);
}
return set;
}
David J. Hamilton
@hjdivad
github.com/hjdivad