Search code examples
backbone.js

how to run a View's constructor if using browser back button


The gist of this question is that If I use Backbone.js code to clean up my views (i.e. to prevent zombie views), then I am unable to set the element of a view if I click the back button in the browser, and therefore the view is not getting rendered properly in situations where I click the back button.

Details

I have a PostsView that displays a list of posts. In the constructor I call setElement and then render each member of the list using multiple PostListViews

export class PostsView extends Backbone.View{
      constructor(options){
         this.setElement($('#main'), true)
         super()
         this.render()
       }
      addEachPost(model){
       new PostListView({model: model });
      }
      render(){
         this.collection.each(this.addEachPost, this);
      }

If I click one of the PostListViews, then the router loads that PostListView onto the page, and if I click the back button, it reloads the Posts view onto the page, but only if I haven't cleaned up the Posts view.For example, with this code below (that doesn't clean up views), I can hit the back button and then another link and so on and it continues to load everything fine, but it'll eventually create zombies because I'm not cleaning up the views

Router

 export class MyRouter extends Backbone.Router{

           constructor(options){
              '': 'renderList',
              'post/:id': 'showPost'
           }
           renderList(){
             new PostsView({collection: this.collection)};
           }
           showPost(id){
             //code ommitted
             new PostView({model: modle})
           }

 }

So, with the code below, I introduced some oft-used code (that I've seen in other Backbone applications) to prevent zombie views, but now if I do, when I hit the back button, the PostsView doesn't get rendered to the page

Router code to clean up zombie

changeView(view){
     if (this.currentView){
        this.currentView.close();
     }
     this.currentView = view;

}

in the router, I then load views by calling

         `this.changeView( new PostsView({collection: this.collection)});`

However, if I do this, I can load the PostsView initially, but if I click one of the memebers of the list and then click the back, the Posts view is not loading on the page. I noticed from inspecting the console that, in this situation (after clicking the back button), the el is not set to #main, which is why it's not getting onto the page. In the code where I DON't clean up the zombie views, the el is still set to #main if I hit the back button --so obviously it seems as if my code to clean up the zombie views does something to prevent #main being set if I hit the back button. So, by cleaning up the zombies, I've deleted the el and it's not getting set again because the constructor is not run when I hit the back button.

Question: is there some way to use the Router code that cleans up the zombie views, but at the same time to ensure that the constructor is run when I hit the back button in the browser? If the constructor for a view is run even when I hit the back button, then I believe it will set the Element (as it did on page load)


Solution

  • I can't see from your code that you call View.remove() but suppose it's inside this.currentView.close() - remove function (http://backbonejs.org/#View-remove) do the proper cleanup but also removes #main element from the page. So if you have more views sharing same (existing) element you can't call remove. You have to do the cleanup by calling undelegateEvents (for DOM cleanup), stopListening (for model events cleanup) and if the other view is not immediately rendered you can also call this.$el.html("") to empty it.

    I'd suggest to do the cleanup in overridden router's execute function (http://backbonejs.org/#Router-execute)

    EDIT:

    When creating view in backbone you have two options how to create its element:

    1) either use existing DOM element

    var MyView = Backbone.View.extend({
      // selector text..
      $el: "#main",
      render: function () {
        // visible immediatelly
        this.$el.html("<p>content</p>");
        return this;
      }
    });

    2) or have new element created by the view

    var MyView = Backbone.View.Extend({
      // div is default - you don't have to specify it...
      tagName: 'div',
      render: function () {
        // not visible - this.$el is not live DOM element
        this.$el.html("<p>content</p>");
        return this;
      }
    });
    
    // ..
    // outer mechanism
    var myView = new MyView();
    $("body").append(myView.render().$el);

    In second case, it is not live DOM element and must be appended to DOM by some mechanism outside the view itself (because view should know only about its element and you need to use some element above)

    When destroying view there is also several scenarios. If you call remove() on view, it does all the needed cleanup - it calls jQuery's remove() which unbind all DOM events (remove the element from DOM!) and also calls stopListening() on the view which unbinds all Model events (I refer here to latest Backbone 1.2.3).

    So you usually use remove() on #2 type views. If you have #1 type view you can also destroy it by remove() but you can't create another instance because the element no longer exists (unless you create new one by some outer mechanism which could bring mess to your code quickly..) Instead of remove you have to do the cleanup by calling view.undelegateEvents() for DOM events and view.stopListening() for killing bounded Model events. Now the element remains in DOM and you can create new view instance while the previous view RIP with no ghosts around.

    BTW: You can also have multiple views sharing the same element - one for rendering, other to handle (logically grouped) events - then you have to use the non remove cleanup if you wan't to close just one of them (or switch some behaviour temporarily).

    Regarding your case - it is combination of what I have just described:

    // ..
    // #2 type view
    var PostListItemView = Backbone.View.extend({
      tagName: "li",
      render: function () {
        this.$el.html('<a href="#posts/' + this.model.getId() + '">' + this.model.getName() + '</a>');
        return this;
      }
    });
    
    // ..
    // #1 type view
    var PostDetailView = Backbone.View.extend({
      $el: "#main",
      render: function () {
        this.$el.html(/* ... */);
        return this;
      },
      close: function () {
        this.undelegateEvents();
        this.stopListening();
      }
    });
    
    // ..
    // #1 type view
    var PostsListView = Backbone.View.extend({
      $el: "#main",
      render: function () {
        var $list = $("<ul></ul>");
        
        // you need to store reference to the item views
        this.postListLtemViews = this.collection.reduce(function (list, item) {
          var postListItemView = new PostListItemView({
            model: item
          }); 
          
          // 'outer mechanism' to insert #2 type view to DOM
          $list.append(postListItemView.render().$el);
          list.push(postListItemView);
          
          return list;
        }, []);
        
        this.$el.html($list);
        return this;
      },
      close: function () {
        _.each(this.postListLtemViews, function (postListItemView) {
          postListItemView.remove();
        });
        this.undelegateEvents();
        this.stopListening();
      }
    });
    
    // ..
    // Router
    var App = Backbone.Router.extend({
    
      routes: {
        "posts": "postsList",
        "posts/:id": "postDetail"
      },
    
      execute: function (callback, args, name) {
        // Cleanup
        if ( this.currentView ) {
          this.currentView.close();
        }
        
        // Apply route function
        if ( callback ) {
          callback.apply(this, args);
        }
      },
      
      postsList: function () {
        this.currentView = new PostsListView({
          collection: ...
        });
          
        this.currentView.render();
      },
    
      postDetail: function (id) {
        this.currentView = new PostDetailView({
          model: ...
        });
          
        this.currentView.render();
      }
    
    });