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