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)