Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Implement javascript instance store by returning existing instance from constructor

I am trying to implement my version of the "Instance Store" in Backbone.js as described by Soundcloud in their recent blog post:

http://backstage.soundcloud.com/2012/06/building-the-next-soundcloud/

Relevant Excerpt:

To solve this, we use a construct we call the instance store. This store is an object which is implicitly accessed and modified each time a constructor for a model is called. When a model is constructed for the first time, it injects itself into the store, using its id as a unique key. If the same model constructor is called with the same id, then the original instance is returned.

var s1 = new Sound({id: 123}),
    s2 = new Sound({id: 123});

s1 === s2; // true, these are the exact same object.

This works because of a surprisingly little-known feature of Javascript. If a constructor returns an object, then that is the value assigned. Therefore, if we return a reference to the instance created earlier, we get the desired behaviour. Behind the scenes, the constructor is basically doing this:

var store = {};

function Sound(attributes) {
    var id = attributes.id;

    // check if this model has already been created
    if (store[id]) {
        // if yes, return that
        return store[id];
    }
    // otherwise, store this instance
    store[id] = this;
}

I implemented my version of this by overriding the Backbone.Model class to create my own constructor.

var MyModel = Backbone.Model.extend({
    constructor: function (attributes, options) {
        var id = attributes ? attributes.id : undefined;

        if (this.store[id]) {
            return this.store[id];
        }

        Backbone.Model.prototype.constructor.apply(this, arguments);

        if (id) {
            this.store[id] = this;
        }
    }
});

var MyOtherModel = MyModel.extend({
    store: {},

    //other model stuff
});

This was working just fine, but something must have changed and now it's stopped working, and I'm unsure why. Newly created instances are stored in the store object with no issue - each class that extends the MyModel class has its own empty store to avoid collisions of instances of a different type with the same id. The correct instance is also retrieved with no issue when the constructor is called with an existing id, however when they are returned from the constructor the return value is ignored. My understanding from the spec is that constructors can return an object - but not a primitive - and the returned object will be assigned to the lefthand side of the assignment statement when the constructor is called with the new operator. This isn't happening, even though the constructor returns an object, the empty object created by the new operator is used.

Some debugging info. Not sure how helpful this info will be. This is "this" in the MyModel constructor for an object being instantiated for the first time.

child
    _callbacks: Object
    _escapedAttributes: Object
    _previousAttributes: Object
    _setting: false
    attributes: Object
        id: "4fd6140032a6e522f10009ac"
        manufacturer_id: "4f4135ae32a6e52a53000001"
        name: "Tide"
        uniqueName: "tide"
    __proto__: Object
    cid: "c50"
    collection: child
    id: "4fd6140032a6e522f10009ac"
    __proto__: ctor
        constructor: function (){ parent.apply(this, arguments); }
        defaults: Object
        store: Object
        url: function () {
        urlRoot: function () {
        __proto__: ctor

And this is "this" in the MyModel constructor when it's an object being returned from the instance store:

child
    _callbacks: Object
    _escapedAttributes: Object
    _previousAttributes: Object
    _setting: false
    attributes: Object
        _validate: function (attrs, options) {
        bind: function (events, callback, context) {
        change: function (options) {
        changedAttributes: function (diff) {
        clear: function (options) {
        clone: function () {
        constructor: function (){ parent.apply(this, arguments); }
        defaults: Object
        destroy: function (options) {
        escape: function (attr) {
        fetch: function (options) {
        get: function (attr) {
        has: function (attr) {
        hasChanged: function (attr) {
        idAttribute: "id"
        initialize: function (){}
        isNew: function () {
        isValid: function () {
        manufacturer_id: 0
        name: ""
        off: function (events, callback, context) {
        on: function (events, callback, context) {
        parse: function (resp, xhr) {
        previous: function (attr) {
        previousAttributes: function () {
        save: function (key, value, options) {
        set: function (key, value, options) {
        store: Object
        toJSON: function () {
        trigger: function (events) {
        unbind: function (events, callback, context) {
        unset: function (attr, options) {
        url: function () {
        urlRoot: function () {
        __proto__: Object
        cid: "c141"
     __proto__: ctor
        constructor: function (){ parent.apply(this, arguments); }
        defaults: Object
        store: Object
        url: function () {
        urlRoot: function () {
        __proto__: ctor

What I note is that the attributes object in the second one has all the methods of a backbone object included in there, which they shouldn't be. It also has no id, again I'm not sure why. Hopefully this provides some insight. Thanks.

like image 420
Kareem Avatar asked Jun 21 '12 19:06

Kareem


2 Answers

I wouldn't use extend for this, I think having a separate "factory" is the right idea. It will allow you to extend your models without fears of side effects.

From the annotated source backbone does some weird stuff with extend, I haven't quite wrapped my head around it. (Also check out inherits) So lets skip it for now and stick with your working solution.

I've modified your method to generate factory models, you should be able to use them like normal models (eg, set them on a collection) except extending them wont work. They'll also handle updating your models with fresh data like the soundcloud example does.

var makeStoreable = function(model){
  var StoreModel = function(attr, opt){
    if(!attr || !attr.id){
      // The behavior you exhibit here is up to you
      throw new Error('Cool Models always have IDs!');
    }
    if(this.store[attr.id]){
      this.store[attr.id].set(attr, opt);
    }else{
      var newModel = new model(attr, opt);
      this.store[attr.id] = newModel;
    }
    return this.store[attr.id];
  };
  StoreModel.prototype.store = {};
  return StoreModel;
};

var CoolModel = Backbone.Model.extend({});

CoolModel = makeStoreable(CoolModel);

var a = new CoolModel({
    id: 4,
    coolFactor: 'LOW'
});

var b = new CoolModel({
    id:4,
    coolFactor: 'HIGH'
});

console.log(a===b); //true!
console.log(a.get('coolFactor') === 'HIGH'); //true!

And here's a fiddle to play around with.

Also, I'd welcome someone to come up with an in model solution keeping the "store" in the model instances's prototype. Also to prevent memory leaks we should probably create a references counting destroy method, either on the factory or the model itself.

like image 135
reconbot Avatar answered Nov 05 '22 08:11

reconbot


@wizard's approach seems pretty nice and clean. +1 to that.

The way it's implemented in SoundCloud is by overriding the Backbone.Model.extend method to create a class with our modified constructor and the store in a closure. The store was originally created in a closure to keep the class's interface clean, but after a while it was found to be useful for debugging to have a reference to each class's store, so it was also attached there.

We do have a reference count so that memory usage doesn't explode, and also give classes the ability to define a custom function which gives the unique value to identify it. Most of the time id is sufficient, but there are some corner cases where it doesn't quite work out.

I'd welcome someone to come up with an in model solution keeping the "store" in the model instances's prototype

You could do myInstance.constructor.store

like image 35
nickf Avatar answered Nov 05 '22 08:11

nickf