Backbone.View: Swapping out two child views that share an element in the parent view
Asked Answered
M

3

7

Note: I understand that other libraries (e.g., Marionette) could greatly simplify view-based issues. However, let's assume that that is not an option here.

Let's say that we have a parent view for a given "record" (i.e., a model). That parent view has two subviews, one for displaying the record's attributes and one for editing them (assume that editing-in-place is not appropriate in this case). Up until now, whenever I needed to remove/display subviews, I literally called remove on the outgoing view and new on the incoming view, so I was destroying/creating them everytime. This was straightforward and easy to code/manage.

However, it seemed relevant to figure out if there were any workable alternatives to the (seemingly default) approach of removing/creating - especially because it's been asked a few times before, but never fully answered (e.g., Swap out views with Backbone?)

So I've been trying to figure out how I could have both subviews share an element in the parent view, preventing me from having to remove and new them everytime. When one needs to be active, it is rendered and the other one is "silenced" (i.e., doesn't respond to events). So they are continually swapped out until the parent view is removed, and then they are all removed together.

What I came up with can be found here: http://jsfiddle.net/nLcan/

Note: The swapping is performed in RecordView.editRecord and RecordView.onRecordCancel.

Although this appears to work just fine, I have a few concerns:

(1) Even if the event bindings are silenced on the "inactive" view, might there be a problem for two views to be set the same element? As long as only the "active" view is rendered, it doesn't seem like this should be an issue.

(2) When the two subviews have remove called (i.e., Backbone.View.remove) it calls this.$el.remove. Now, when the first subview is removed, this will actually remove the DOM element that they were both sharing. Accordingly, when remove is called on the second subview there is no DOM element to remove, and I wonder if that might make it difficult for that subview to clean itself up - especially if it had created a number of DOM elements itself that were then over-written when the first subview was rendered.... or if there are sub-sub-views involved... it seems like there might be a potential concern regarding memory leaks here.

Apologies, as I know this is a little convoluted. I'm RIGHT at the boundary of my knowledge base, so I don't fully understand all the potential issues involved here (hence the question). But I do hope someone out there has dealt with a similar issue and can offer an informed opinion on all this.

Anyhow, here is the (simplified example) code in full:

// parent view for displaying/editing a record. creates its own DOM element.
var RecordView = Backbone.View.extend({
    tagName : "div",
    className : "record",
    events : {
        "click button[name=edit]" : "editRecord",
        "click button[name=remove]" : "removeRecord",
    },
    initialize : function(settings){

        // create the two subviews. one for displaying the field(s) and
        // one for editing them. they both listen for our cleanup event
        // which causes them to remove themselves. the display view listens
        // for an event telling it to update its data.

        this.displayView = new RecordDisplayView(settings);
        this.displayView.listenTo(this,"cleanup",this.displayView.remove);
        this.displayView.listenTo(this,"onSetData",this.displayView.setData);

        this.editView = new RecordEditView(settings);
        this.editView.listenTo(this,"cleanup",this.editView.remove);

        // the editView will tell us when it's finished.

        this.listenTo(this.editView,"onRecordSave",this.onRecordSave);
        this.listenTo(this.editView,"onRecordCancel",this.onRecordCancel);

        this.setData(settings.data,false);
        this.isEditing = false;
        this.activeView = this.displayView;

        // we have two elements within our recordView, one for displaying the
        // the header of the record (i.e., info that doesn't change) and
        // one for displaying the subView. the subView element will be
        // bound to BOTH of our subviews.
        this.html = "<div class='header'></div><div class='sub'></div>";
    },
    render : function(){
        // for an explanation of why .empty() is called first, see: https://mcmap.net/q/1628050/-backbone-view-delegateevents-not-re-binding-events-to-subview
        this.$el.empty().html(this.html);
        this.$(".header").empty().html("<p>Record ID: "+this.data.id+"</p><p><button name='edit'>Edit</button><button name='remove'>Remove</button></p>");
        this.delegateEvents(); // allows for re-rendering
        this.renderSubView();        
        return this;
    },
    // the subviews SHARE the same element.
    renderSubView : function() {
        this.activeView.setElement(this.$(".sub")).render();
    },
    remove : function() {        
        this.stopListening(this.displayView);
        this.stopListening(this.editView);
        this.trigger("cleanup");
        this.displayView = null;
        this.editView = null;
        return Backbone.View.prototype.remove.call(this);
    },
    // notify will only be false upon construction call
    setData : function(data,notify) {
        this.data = data;
        if ( notify ) {
            this.trigger("onSetData",data);
        }
    },
    /* Triggered Events */
    editRecord : function(event) {
        if ( !this.isEditing ) {
            this.isEditing = true;
            this.activeView.silence(); // silence the old view (i.e., display)
            this.activeView = this.editView;
            this.renderSubView();
        }
        event.preventDefault();
    },
    removeRecord : function(event) {
        this.remove(); // triggers `remove` on both subviews
        event.preventDefault();
    },
    /* Triggered Events from editView */
    onRecordSave : function(data) {
        this.setData(data,true);
        this.onRecordCancel();
    },
    onRecordCancel : function() {
        this.isEditing = false;
        this.activeView.silence(); // silence the old view (i.e., edit)
        this.activeView = this.displayView;
        this.renderSubView();
    }    
});

// child view of RecordView. displays the attribute. takes over an existing DOM element.
var RecordDisplayView = Backbone.View.extend({
    events : {
        // if steps are not taken to silence this view, this event will trigger when
        // the user clicks 'cancel' on the editView!
        "click button[name=cancel]" : "onCancel"
    },
    initialize : function(settings){
        this.setData(settings.data);
    },
    setData : function(data) {
        this.data = data;
    },
    render : function(){        
        this.$el.empty().html("<p><strong>Field:</strong> "+this.data.field+"</p>");
        return this;
    },
    remove : function() {        
        this.trigger("cleanup");
        this.data = null;
        return Backbone.View.prototype.remove.call(this);
    },
    // the view is still attached to a particular element in the DOM, however we do not
    // want it to respond to any events (i.e., it's sharing an element but that element has
    // been rendered to by another view, so we want to make this view invisible for the time
    // being).
    silence : function() {
        this.undelegateEvents();
    },
    /* Triggered Events */
    onCancel : function() {
        alert("I'M SPYING ON YOU! USER PRESSED CANCEL BUTTON!");
    }
});

// subView of RecordView. displays a form for editing the record's attributes. takes over an existing DOM element.
var RecordEditView = Backbone.View.extend({
    events : {
        "click button[name=save]" : "saveRecord",
        "click button[name=cancel]" : "cancelRecord"
    },
    initialize : function(settings){
        this.data = settings.data;        
    },
    render : function(){
        this.html = "<form><textarea name='field' rows='10'>"+this.data.field+"</textarea><p><button name='save'>Save</button><button name='cancel'>Cancel</button></p></form>";
        this.$el.empty().html(this.html);
        return this;        
    },
    remove : function() {        
        this.trigger("cleanup");
        this.data = null;
        return Backbone.View.prototype.remove.call(this);
    },
    silence : function() {
        this.undelegateEvents();
    },
    /* Triggered Events */
    saveRecord : function(event){
        this.data.field = this.$("form textarea[name=field]").val();        
        this.trigger("onRecordSave",this.data);
        event.preventDefault();
    },
    cancelRecord : function(event){
        event.preventDefault();
        this.trigger("onRecordCancel");
    }    
});

// presumably this RecordView instance would be in a list of some sort, along with a bunch of other RecordViews.
var v1 = new RecordView({
    data : {id:10,field:"Hi there. I'm some text!"}
});
$("#content").empty().html(v1.render().$el);
//$("#content").empty().html(v1.render().$el); (re-rendering would work fine)
Mormon answered 10/1, 2014 at 20:35 Comment(0)
M
2

Okay, so here's my solution:

Rather than treating backbone like a "blackbox", I just looked through its code for Backbone.View.remove and Backbone.View.setElement. They are both painfully simple and thus we can easily remove Backbone.View from the picture altogether, and just deal with jQuery. Once you do that, replicating this behaviour in jQuery alone and going through it seems to demonstrate that there are absolutely no issues whatsoever in this approach.

// equivalent of creating a parent view with a subview through backbone, assuming both
// creating new DOM elements
var parentView = $("<div></div>").attr("id","parent").html("<div class='sub'></div>");          

// equivalent to assigning two subviews to the same element in a parent view. no
// problems here.
var subView1 = parentView.find(".sub");
var subView2 = parentView.find(".sub");

// they both reference the same element (outside DOM still), so both would have data
// of 'idx' = 2. there are no problems with this.
subView1.data("idx",1);
subView2.data("idx",2);

// add parentView to the DOM, which adds the element that subView1 and 2 reference.
$("#content").append(parentView);           

// equivalent to rendering one subview in backbone and using setElement to swap.
// again, no problems with any of this. you can see that the setElement calls
// happening again and again would be redundant.

subView1 = parentView.find(".sub");
var activeSubView = subView1;           
activeSubView.html("subView1: " + subView1.data("idx")); // subView1: 2

subView2 = parentView.find(".sub");
activeSubView = subView2;
activeSubView.html("subView2: " + subView2.data("idx")); // subView2: 2

// when you `remove`, all it does is remove the element from the DOM and empty out
// its jQuery data ("idx") and unbind all the events. nothing is "destroyed". you
// still have a reference to it, so it won't be gc'd. the only difference between
// `remove` and `detach` is that `detach` keeps the jQuery data and events. there
// is no need to `remove` the subViews explicitly, as they are children of the
// parent and so when the parent is removed from the DOM, they come with it.

//subView1.remove();
//subView2.remove();
parentView.remove();

// all of the HTML inside the parentView and subView elements still exists. their events are
// gone and their jQuery data is gone.

console.log(subView1.html()); // "subView2: 2"
console.log(parentView.html()); // "<div class="sub">subView2: 2</div>"

console.log(subView1.data("idx")); // undefined
console.log(subView2.data("idx")); // undefined

// this will ensure that they are cleaned up.
parentView = subView1 = subView2 = null;
Mormon answered 10/1, 2014 at 20:35 Comment(0)
F
0

Generally yes, it's OK to have multiple Views share the same element, but things can get confusing and it's not necessarily productive to do so.

It sounds like your main concern here is the issue of the page scroll position moving as you swap from edit mode into view mode or vice versa. If you consider using a bit of CSS you can simply have both elements "rendered" but then change the class on the parent element to hide/show the subview elements.

<div class="item is-editing">
  <div class="edit-view">...</div>
  <div class="read-view">...</div>
</div>
Faustina answered 10/1, 2014 at 20:35 Comment(1)
"but things can get confusing" Indeed, hence the question (and bounty!) I should have left out the page scroll bit as, although it was the catalyst for the question, it's orthogonal to the key issues - i.e., concerns regarding the code presented (as highlighted in the bounty). I have updated the question with that aspect removed.Mormon
T
0

Not exactly the same as your problem, but my solution to this was:

  1. create an object of form views within the parent view

    formViewRecord: {},     
    
  2. pass in the sub-view each time it is initialised and add it's id to the object:

    viewManagement: function(view) {
    
      var that = this, clone;
    
        this.formViewRecord[view.cid] = view;
    
  3. clone the object, except for the current view

    clone = _.omit(_.clone(that.formViewRecord), view.cid);  // take out the current view
    
  4. loop through the object and (a) clone the node and reinsert, as remove removes it; then remove

        _.each(clone, function(element, index, array) {
            var e = element.el
                , dupNode = e.cloneNode(false)  // shallow clone only
                , parentDiv = e.parentNode
                ;
            parentDiv.insertBefore(dupNode, e);  // need to insert a shallow copy of the dom node
            element.remove();  // remove() literally removes the view. 
            delete that.formViewRecord[element.cid];                
        });
    },
    
  5. trigger in the parent view

    this.listenTo(this.dispatcher, 'viewManagement', this.viewManagement, this);
    // this.dispatcher = _.extend({}, Backbone.Events);
    
  6. trigger event in sub-view

    initialize: function(options) {
    
      var that = this;
          this.dispatcher = options.dispatcher;
          this.dispatcher.trigger('viewManagement', this);
    
Tick answered 10/1, 2014 at 20:35 Comment(3)
Hi Joe. Sorry, but I'm not exactly sure what that code is a solution to. You mention that your problem was different than my problem... perhaps you could describe what your problem was in more detail, and how that code addressed it (at a conceptual level).Mormon
hanging sub-views off the same parent class element and transitioning alternative views on and off that dom node.Tick
So you're just talking about adding/removing subviews, not swapping subviews that share an element in the parent view.Mormon

© 2022 - 2024 — McMap. All rights reserved.