13

Several places in my Backbone application I'd like to have an instant search over a collection, but I'm having a hard time coming up with the best way to implement it.

Here's a quick implementation. http://jsfiddle.net/7YgeE/ Keep in mind my collection could contain upwards of 200 models.

var CollectionView = Backbone.View.extend({

  template: $('#template').html(),

  initialize: function() {

    this.collection = new Backbone.Collection([
      { first: 'John', last: 'Doe' },
      { first: 'Mary', last: 'Jane' },
      { first: 'Billy', last: 'Bob' },
      { first: 'Dexter', last: 'Morgan' },
      { first: 'Walter', last: 'White' },
      { first: 'Billy', last: 'Bobby' }
    ]);
    this.collection.on('add', this.addOne, this);

    this.render();
  },

  events: {
    'keyup .search': 'search',
  },

  // Returns array subset of models that match search.
  search: function(e) {

    var search = this.$('.search').val().toLowerCase();

    this.$('tbody').empty(); // is this creating ghost views?

    _.each(this.collection.filter(function(model) {
      return _.some(
        model.values(), 
        function(value) {
          return ~value.toLowerCase().indexOf(search);
        });
    }), $.proxy(this.addOne, this));
  },

  addOne: function(model) {

    var view = new RowView({ model: model });
    this.$('tbody').append(view.render().el);
  },

  render: function() {

    $('#insert').replaceWith(this.$el.html(this.template));
      this.collection.each(this.addOne, this);
  }
});

And a tiny view for each model...

var RowView = Backbone.View.extend({

  tagName: 'tr',

  events: {
    'click': 'click'
  },

  click: function () {
    // Set element to active 
    this.$el.addClass('selected').siblings().removeClass('selected');

    // Some detail view will listen for this.
    App.trigger('model:view', this.model);
  },

  render: function() {

    this.$el.html('<td>' + this.model.get('first') + '</td><td>' + this.model.get('last') + '</td>');
      return this;
  }
});

new CollectionView;

Question 1

On every keydown, I filter the collection, empty the tbody, and render the results, thereby creating a new view for every model. I've just created ghost views, yes? Would it be best to properly destroy each view? Or should I attempt to manage my RowViews... creating each one only once, and looping through them to only render the results? An array in my CollectionView perhaps? After emptying the tbody, would the RowViews still have their el or is that now null and need to be re-rendered?

Question 2, Model Selection

You'll notice I'm triggering a custom event in my RowView. I'd like to have a detail view somewhere to handle that event and display the entirety of my model. When I search my list, if my selected model remains in the search results, I want to keep that state and let it remain in my detail view. Once it is no longer in my results, I'll empty the detail view. So I'll certainly need to manage an array of views, right? I've considered a doubly linked structure where each view points to it's model, and each model to it's view... but if I'm to implement a singleton factory on my models in the future, I can't impose that on the model. :/

So what's the best way to manage these views?

savinger
  • 6,544
  • 9
  • 40
  • 57

2 Answers2

20

I got a little bit carried away while playing with your question.

First, I would create a dedicated collection to hold the filtered models and a "state model" to handle the search. For example,

var Filter = Backbone.Model.extend({
    defaults: {
        what: '', // the textual search
        where: 'all' // I added a scope to the search
    },
    initialize: function(opts) {
        // the source collection
        this.collection = opts.collection; 
        // the filtered models
        this.filtered = new Backbone.Collection(opts.collection.models); 
        //listening to changes on the filter
        this.on('change:what change:where', this.filter); 
    },

    //recalculate the state of the filtered list
    filter: function() {
        var what = this.get('what').trim(),
            where = this.get('where'),
            lookin = (where==='all') ? ['first', 'last'] : where,
            models;

        if (what==='') {
            models = this.collection.models;            
        } else {
            models = this.collection.filter(function(model) {
                return _.some(_.values(model.pick(lookin)), function(value) {
                    return ~value.toLowerCase().indexOf(what);
                });
            });
        }

        // let's reset the filtered collection with the appropriate models
        this.filtered.reset(models); 
    }
});

which would be instantiated as

var people = new Backbone.Collection([
    {first: 'John', last: 'Doe'},
    {first: 'Mary', last: 'Jane'},
    {first: 'Billy', last: 'Bob'},
    {first: 'Dexter', last: 'Morgan'},
    {first: 'Walter', last: 'White'},
    {first: 'Billy', last: 'Bobby'}
]);
var flt = new Filter({collection: people});

Then I would create separated views for the list and the input fields: easier to maintain and to move around

var BaseView = Backbone.View.extend({
    render:function() {
        var html, $oldel = this.$el, $newel;

        html = this.html();
        $newel=$(html);

        this.setElement($newel);
        $oldel.replaceWith($newel);

        return this;
    }
});
var CollectionView = BaseView.extend({
    initialize: function(opts) {
        // I like to pass the templates in the options
        this.template = opts.template;
        // listen to the filtered collection and rerender
        this.listenTo(this.collection, 'reset', this.render);
    },
    html: function() {
        return this.template({
            models: this.collection.toJSON()
        });
    }
});
var FormView = Backbone.View.extend({
    events: {
        // throttled to limit the updates
        'keyup input[name="what"]': _.throttle(function(e) {
             this.model.set('what', e.currentTarget.value);
        }, 200),

        'click input[name="where"]': function(e) {
            this.model.set('where', e.currentTarget.value);
        }
    }
});

BaseView allows to change the DOM in place, see Backbone, not "this.el" wrapping for details

The instances would look like

var inputView = new FormView({
    el: 'form',
    model: flt
});
var listView = new CollectionView({
    template: _.template($('#template-list').html()),
    collection: flt.filtered
});
$('#content').append(listView.render().el);

And a demo of the search at this stage http://jsfiddle.net/XxRD7/2/

Finally, I would modify CollectionView to graft the row views in my render function, something like

var ItemView = BaseView.extend({
    events: {
        'click': function() {
            console.log(this.model.get('first'));
        }
    }
});

var CollectionView = BaseView.extend({
    initialize: function(opts) {
        this.template = opts.template;
        this.listenTo(this.collection, 'reset', this.render);
    },
    html: function() {
        var models = this.collection.map(function (model) {
            return _.extend(model.toJSON(), {
                cid: model.cid
            });
        });
        return this.template({models: models});
    },
    render: function() {
        BaseView.prototype.render.call(this);

        var coll = this.collection;
        this.$('[data-cid]').each(function(ix, el) {
            new ItemView({
                el: el,
                model: coll.get($(el).data('cid'))
            });
        });

        return this;
    }
});

Another Fiddle http://jsfiddle.net/XxRD7/3/

Community
  • 1
  • 1
nikoshr
  • 32,926
  • 33
  • 91
  • 105
  • Thank you, this is enormously helpful. I really like what you've done with the filter. In my early attempts I had scope as well, but it was hard coded, and I somehow missed the `pick` function in the docs. Also, never knew about the `throttle` function, also very helpful. – savinger Aug 10 '13 at 17:09
  • I'm still wrapping my head around the way you have things rendered, and I'm not quite sold on the use of `setElement`. It seems to me inelegant to be rebinding events on every render. I've never seen this grafting technique, where you render the list items in the CollectionView and graft on the ItemViews... I'm not used to the ItemView not being responsible for rendering itself, and on the one hand seems like a separation of concerns that should't occur, but on the other hand is surprisingly straight forward as it's always easier to have the template iterate over our collections. – savinger Aug 10 '13 at 17:16
  • @savinger `setElement` is mostly for cosmetic reasons and for "self containedness" of the templates, this technique would be more useful if you had to rerender the rows, for example. This answer may help you understand my point of view http://stackoverflow.com/questions/12004534/backbonejs-rendering-problems/12006179#12006179 – nikoshr Aug 10 '13 at 17:23
  • @savinger And for the grafting technique, it is way faster when re-rendering than a subview render+append and it allows server side rendering without having to render client side on initial load. If that makes sense. – nikoshr Aug 10 '13 at 17:28
4

The Collection associated with your CollectionView must be consistent with what you are rendering, or you'll run into problems. You should not have to empty your tbody manually. You should update the collection, and listen to events emitted by the collection in the CollectionView and use that to update the view. In your search method, you should only update your Collection and not your CollectionView. This is one way you can implement it in the CollectionView initialize method:


initialize: function() {
  //...

  this.listenTo(this.collection, "reset", this.render);
  this.listenTo(this.collection, "add", this.addOne);
}

And in your search method, you can just reset your collection and the view will render automatically:


search: function() {
  this.collection.reset(filteredModels);
}

where filteredModels is an array of the models that match the search query. Note that once you reset your collection with filtered models, you'll lose access to the other models that were originally there before the search. You should have a reference to a master collection that contains all of your models regardless of the search. This "master collection" is not associated with your view per se, but you could use the filter on this master collection and update the view's collection with the filtered models.

As for your second question, you should not have a reference to the view from the model. The model should be completely independent from the View - only the view should reference the model.

Your addOne method could be refactored like this for better performance (always use $el to attach subviews):


var view = new RowView({ model: model });
this.$el.find('tbody').append(view.render().el);
hesson
  • 1,812
  • 4
  • 23
  • 35
  • Thanks for the response. First question... What's the difference between `this.listenTo(this.collection, "reset", this.render)` and `this.collection.on("reset", this.render, this)` ? – savinger Aug 10 '13 at 01:23
  • Second question. I like what you said about a master collection and CollectionView collection... but you didn't address the subviews. Is it ok to create new `RowView`s with every `addOne`? – savinger Aug 10 '13 at 01:26
  • 2
    @savinger They essentially accomplish the same thing - they listen to events. However, `this.listenTo` associates the listening with the view while `this.collection.on` associates the listening with the collection. This doesn't seem like much of a difference but keep in mind if you use `this.collection.on`, the collection will continue to listen even if you remove your view which can cause memory leaks and slow down your application considerably. On the other hand, if you use `this.listenTo`, it will not listen to the event after you remove the view. – hesson Aug 10 '13 at 01:29
  • Ah, I see. Thanks. [Found this stackoverflow question that covers the difference as well.](http://stackoverflow.com/questions/16823746/backbone-js-listento-vs-on) – savinger Aug 10 '13 at 01:30
  • Using `addOne` is good because you can use this method to add new subviews to your collection without re-rendering your entire collection where appropriate. You can also use your RowView to listen to an event when your model is removed and remove the view accordingly. – hesson Aug 10 '13 at 01:32
  • But do you see the issue where RowViews are lost? Consider an initial render, and then fetch on collection... It's going to addOne for every model, again! What's the proper way to clear the container? What happens to those cleared RowViews? – savinger Aug 10 '13 at 01:36
  • this.$('selector') is the same as this.$el.find('selector') – savinger Aug 10 '13 at 01:42
  • @savinger When you fetch a collection, it will not fire reset unless you pass the reset option like this: `collection.fetch({reset:true});`. So when you fetch the collection for the first time, do not reset it, so it will only be rendered once using `addOne`. Only fire reset when you do search and other similar operations. – hesson Aug 10 '13 at 01:45
  • Ok. So reset is fired in our search function, all RowViews are gone and recreated. Any memory leaks? Inefficiencies? – savinger Aug 10 '13 at 01:54
  • 1
    No there won't be memory leaks. However, you might want to consider using `set` instead of `reset`. While reset clears the existing models, and renders the new models, set will do a "smart merge" which "updates" the view and removes only the models that need to be removed - this may be more efficient. – hesson Aug 10 '13 at 01:58
  • 1
    as a side note, if you know you are going to call `this.$('something')` multiple time then may be it is better to cache that selector right? like `var $foo = this.$('foo');` see this: http://jsperf.com/find-vs-cached-dom – j03w Aug 10 '13 at 02:28
  • @j03w yep, for sure, and I usually do, this was just quickly done for the sake of the question. – savinger Aug 10 '13 at 02:37