Consider the following JS:
var ChildLayout = Marionette.Layout.extend({
template: "... let's not go here ...",
initialize: function() {
console.log('Child Layout Initialized'); // registers once
},
onRender: function() {
console.log('Rendered'); // registers 2 times
},
});
var ParentLayout = Marionette.Layout.extend({
template: "<div class='child-region'></div>",
regions: { childRegion: '.child-region' },
onRender: function() {
console.log('About to initialize ChildLayout'); // registers once
this.childRegion.show(new ChildLayout());
},
});
In the above, I use the ParentLayout to render the ChildLayout in one of its Regions. Notice that I do not pass any sort of model to the ChildLayout.
The show function, a property of Marionette, should logically initialize and then render the model once. A Marionette View should not re-render itself unless there is some change in its model, from what I understand.
In my application, the onRender of ChildLayout is triggering in my code several times, though its initialize only triggers once.
I cannot see what is causing Marionette to render the ChildLayout multiple times - this does not make sense.
Any insight?
edit
On inspecting the source code of Marionette.js, the show function clearly only renders the passed view once, right after initializing. So the re-renders could only occur from the Layout deciding autonomously to re-render. Interesting.
This turned out to be my own problem with a Zombie View, which I since handled.
Related
I am trying to make my very first search app.
After the app is built, every DOM is rendering as I expect and events work as well. When I dig deeper into it, I find a strange behavior, and after I did some search, I found it is because of zombie view events delegate issue.
Here is some part of my code:
var searchList = Backbone.View.extend({
events:{
'click #submit':function() {
this.render()
}
},
render() {
this.showList = new ShowList({el:$('.ADoM')});
}
});
When #submit is clicked, a new instance of ShowList will be created and '.ADoM' DOM will be rendering.
showList.js
var showList = Backbone.View.extend({
events: {
"click .testing": function(e) {
console.log(e.currentTarget);
},
},
initialize() {
this.$el.html(SearchListTemplate());
}
});
The '.testing' button event is bound with it.
So as what 'zombie' does, after multiple clicks on submit, then clicking the '.testing' button, console.log() will output multiple time.
I have followed the article here and tried to understand and fix my issue, and also tried to add this.remove() in showList.js as someone mentioned, but unfortunately it may because I was not able to place them in the proper place in my code, the issue is still unsolved.
That has nothing to do with ES6, this is basic JavaScript and DOM manipulation.
Do not share the same element in the page
You're creating new ShowList instances which are bound to the same element in the page. In Backbone, that's bad practice.
Each Backbone View instance has its own root element on which events are bound. When multiple views share that same element, events are triggered on each instance, and you can't call remove on the view since it will remove the DOM element from the page completely.
You should dump the child view root element within the element you wish to reuse.
this.$('.ADoM').html(this.showList.render().el);
Reusing the view
The render function should be idempotent.
var searchList = Backbone.View.extend({
events: {
// you can use a string to an existing view method
'click #submit': 'render'
},
initialize() {
// instanciate the view once
this.showList = new ShowList();
},
// This implementation always has the same result
render() {
this.$('.ADoM').html(this.showList.render().el);
// Backbone concention is to return 'this' in the render function.
return this;
}
});
Your other view could be simplified as well to reflect the changes from the parent view.
var showList = Backbone.View.extend({
events: {
"click .testing": 'onTestingClick',
},
// Don't render in the initialize, do it in the render function
render() {
this.$el.html(SearchListTemplate());
},
onTestingClick(e) {
console.log(e.currentTarget);
}
});
This is a super basic example on reusing a view instead of always creating a new one.
A little cleanup is necessary
When done with a view, you should call remove on it:
Removes a view and its el from the DOM, and calls stopListening to
remove any bound events that the view has listenTo'd.
For this to work, when registering callbacks on model or collection events, use listenTo over on or bind to avoid other memory leaks (or zombie views).
A good pattern for view having multiple child views is to keep a reference of each child views and call remove on each of them when the parent gets removed.
See how to avoid memory leaks when rendering list views. When dealing with a lot of views (big list or tree of views), there are ways to efficiently render with Backbone which involves DocumentFragment batching and deferring.
I've hit a head-scratcher with a Backbone.js. The example is on jsfiddle here. I believe the issue is here:
App.Layout = new Backbone.Layout({
// Attach the Layout to the main container.
collection: App.chapters,
el: "body",
initialize: function () {},
beforeRender: function () {
// Add a sub-view for each Chapter
this.collection.each(function (model) {
this.insertView(model.get('id'), new App.ChapterView({
"id": model.get('id')
}));
}, this);
},
views: {
// But if I set the sub-view specifically if works
// "one": new App.ChapterView({id: 'one' })
}
});
In summary, the router should simply activate or deactivate backbone.layoutmanager sub-views based on the path, e.g., /#chapter/one, /#chapter/two, etc.
If I explicitly set the sub-views in App.Layout (see line 49 in the fiddle), the routing works as expected.
However, if I try to add the views by iterating a collection of models in the beforeRender function (line 40; beforeRender is coming from backbone.layoutmanager), they don't appear to be available when the router tries to find the matching view by ID.
Once the page has render, however, the view can be activated with:
App.router.navigate('/chapter/two',{"trigger": true});
Which seems to indicate that the views are properly being added and should be findable by the router with:
App.Layout.getView(name);
No doubt I'm simply overlooking something, or am about to expose my ignorance of the Backbone library. :)
The issue is that you're navigating and rendering out-of-sync. I've updated your code here: http://jsfiddle.net/6h268r7j/55/
It works when you use the declarative approach because those are outside of the render flow, essentially statically added. As soon as you use beforeRender/render you are now in an asynchronous render flow and they won't be available in your router callbacks.
The fix was to simply render the application layout first and then trigger the routing:
App.Layout.render().then(function() {
Backbone.history.start();
});
From the Router, I create a Search View with the following code:
(The if is to only create a new View where one does not already exist, since the View never changes.)
search: function () {
if (!this.searchView) {
this.searchView = new SearchView();
}
$('#content').html(this.searchView.el);
this.headerView.selectMenuItem('search-menu');
},
In the View, I have an events hash, binding a click event to a search button:
events: {
"click .search-query": "search"
},
This results in the event only firing the first time the search button is used.
Removing the if from the search function solves the problem.
This however does not seem to be the correct way to approach this, since the View should not need to be recreated (reinitialized and re-rendered).
An attempted fix:
I attempted to add this.delegateEvents(); to the render function of the Search View as follows (while leaving the if in the search function), but it did not solve the problem:
render:function () {
console.log('rendering search...');
$(this.el).html(this.template());
this.delegateEvents();
return this;
},
Any suggestions would be appreciated.
Since you're not calling render() again, delegateEvents won't be called again. You can just call delegateEvents directly in your router. This is just an example; there are probably more elegant ways to do it.
delegateEvents basically rebinds the events to your view. This is useful if the html of your views gets removed from the dom. That looks like what is happening in your example.
search: function () {
if (!this.searchView) {
this.searchView = new SearchView();
}
$('#content').html(this.searchView.el);
this.searchView.delegateEvents();
this.headerView.selectMenuItem('search-menu');
}
This is more of a conceptual/architectural question than anything; the typical/popular approach to constructing and instantiating Backbone Views seems to be to only render the View AFTER successfully fetching necessary Model/Collection data from the server (in a success() or done() callback).
This is all well and good, but what if you have some sort of loading indicator or UI element within the View's template that needs to be displayed before/during the fetch? By not rendering the View until the call finishes, you effectively are unable to display such notifications to the user.
Conversely, if you render the View BEFORE making the fetch, you're now able to display such UI elements, but you now run the risk of displaying a mostly-empty template, since your Model/Collection data hasn't been retrieved yet, and this can look rather weird.
What if you need both things: UI notifications before/during the fetch, AND not to render a mostly-empty template pre-fetch? What might be some good approaches towards accomplishing this goal?
I use a separate loading view to handle the loading gif that listens for relevant events I trigger from the model/collection fetching results. The view responsible for rendering does so without being concerned about the loading gif. The loading gif essentially obscures the results which are "revealed" when the loading gif is removed:
var MyView = Backbone.View.extend({
initialize: function () {
this.listenTo(this.collection, 'reset', this.render);
},
render: function () {
// do whatever you need to here
}
});
var LoadingView = Backbone.View.extend({
el: '#loading',
initialize: function () {
this.listenTo(this.collection, 'fetchStarted', this.showLoader);
this.listenTo(this.collection, 'fetchFinished', this.hideLoader);
},
showLoader: function () {
var self = this;
if (this.waitHandle) {
clearTimeout(this.waitHandle);
delete this.waitHandle;
}
this.waitHandle = setTimeout(function () {
self.$el.removeClass('hide');
}, 300);
},
hideLoader: function () {
clearTimeout(this.waitHandle);
delete waitHandle;
this.$el.addClass('hide');
}
});
var MyCollection = Backbone.Collection.extend({
getResults: function () {
var self = this;
this.fetch({
beforeSend: function () {
self.trigger('fetchStarted');
},
complete: function () {
self.trigger('fetchFinished');
}
});
}
});
I use the timeouts in the loading view to delay showing the loading gif in case they aren't necessary.
I usually have an application-level view that will show a loader when it knows one of the app's main collections is being fetched. In face I usually do an ajax before filter to always pop the loader, and hide on completion application wide. It blocks the whole UI, but that's fine unless I'm doing a more complex dashboard-type app where there are clear modular concerns.
I wrote a blog post with a solution that could interest you (http://davidsulc.com/blog/2013/04/01/using-jquery-promises-to-render-backbone-views-after-fetching-data/). Basically, one approach to the problem:
Store the return value of the fetch call (which is a jQuery promise)
Render the loading view (with MyApp.myRegion.show(loadingView);)
If you want, you can already instantiate your view (that requires data) at this point
When the promise is fulfilled (i.e. the fetch is done), display your data-dependent view within the region (as above)
I have a backbone collection and when I remove a model from the collection, I want it to remove the item from a list in the view.
My collection is pretty basic
MyApp.Collections.CurrentEvents = Backbone.Collection.extend({
model: MyApp.Models.Event
});
and in my views I have
MyApp.Views.CurrentEventItem = Backbone.View.extend({
el: 'div.current_event',
initialize: function(){
event = this.model;
_.bindAll(this, "remove");
MyApp.CurrentEvents.bind('remove',this.remove); //the collection holding events
this.render();
},
// yeah, yeah, render stuff here
remove: function(){
console.log(this);
$(this.el).unbind();
$(this.el).remove();
}
});
when I remove the model from the collection, it triggers the remove function, but the view is still on the page.
In the console, I can see the model, but I believe the model should have an 'el', but it doesn't.
My container code is
MyApp.Views.CurrentEventsHolder = Backbone.View.extend({
el: 'div#currentHolder',
initialize: function(){
MyApp.CurrentEvents = new MyApp.Collections.CurrentEvents();
MyApp.CurrentEvents.bind('new', this.add);
},
add: function(){
var add_event = new MyApp.Views.CurrentEventItem(added_event);
$('div#currentHolder').append(add_event.el);
}
});
for some reason in the add method I can't seem to use the $(this.el) before the append, though I'm not sure if that is the problem.
PROBLEM: MyApp.CurrentEvents.bind('remove',this.remove);
This triggers the remove() function every time any model is deleted from the collection.
This means that anytime a model is deleted, all the CurrentEventItem view instances will be deleted.
Now, about the view still being on the page:
It must have something to do with the way you appended/added/html-ed the view in the page. Check your other views, maybe if you have a CurrentEventsContainer view of some sort, check your code from there because with your current code, it does delete the view, albeit, all of them though.
RECOMMENDED FIX:
change your bindings to:
this.model.bind('remove',this.remove);
and make sure that when you instantiate it, pass on the model so that each view will have a corresponding model to it like so:
//...
addAllItem: function(){
_.each(this.collection, this.addOneItem())
},
addOneItem: function(model){
var currentEventItem = new MyApp.Views.CurrentEventItem({model:model});
//...more code..
}
//...
This makes things a lot easier to manage in my opinion.
UPDATE (from your updated question)
The reason you aren't able to use this.el is because you haven't passed the right context into the add() function. You need to add _.bindAll(this,'add') in your initialize() function to pass the right context, therefore making your this correct, allowing you to use this.el within the add function. Also, change your code to this:
MyApp.CurrentEvents.bind('new', this.add, this); this passes the right context. Also, why not use add instead as an event?
Continuing what I said in the comments, the way you've implemented this right now will remove all the CurrentEventItem views from the page when any of them is removed from the collection. The reason for this is the following:
MyApp.CurrentEvents.bind('remove',this.remove);
What this essentially says is, "every time the remove event is triggered on the collection, call this.remove." So, every time you instantiate one of these views, you're also telling the collection to remove that view when the collection triggers a remove event. I've created a fiddle to show you the problem.
You're right that Backbone knows which model has been removed from a collection, but you're not taking advantage of that. You can do that like so:
removeFromView: function(model) {
// Check to make sure the model removed was this.model
if (model.cid === this.model.cid) {
$(this.el).unbind();
$(this.el).remove();
}
}
See how this minor adjustment changes the behavior? Check it out in action here.
If you follow this pattern, you should see the proper views being removed.