Building Backbone Plugins: Eliminate The Boilerplate In Backbone.js Apps (2014)

PART 1: BACKBONE VIEWS

Chapter 3: Complex And Divergent Views

The BBPlug.BaseView will allow an application to have a consistent method of rendering templates and data. There are some rather restricting limitations in it, though. If a view has any custom code that needs to run on the rendered HTML, for example, it can’t - not easily at least. Additionally, the requirement of providing a serializeData method will create some duplication in all of our views - which is what we were setting out to avoid in the first place.

Fortunately, these limitations on flexibility and capabilities are solvable at the same time that the code duplication can be significantly reduced.

onRender

In an application of any size beyond a simple ToDo app, a number of views will need to have some very specialized code run against the HTML that is rendered. For example, if a view is rendering a list of comments for a blog post, it may be desirable to show when the individual comments were added using the standard “(time) ago” format: “10 minutes ago”, “2 months ago”, “over a year ago”, etc. This format can be easily obtained using the moment.js plugin, but where would the call to this plugin go in the current BaseView implementation?

With the current implementation, the BaseView’s render method may have to be overridden. This isn’t too bad, I guess. I can call in to the BaseView’s render method through it’s prototype, allowing the standard render to take place and then running my code to format the dates correctly.

 1 CommentView = BBPlug.BaseView.extend({

 2   template: "...",

 3   serializeData: function(){...},

 4

 5   render: function(){

 6     // use the BaseView's prototype to do the default render

 7     BBPlug.BaseView.prototype.render.call(this);

 8

 9     // call moment.js' to format our dates correctly

10     var commentDate = this.model.get("date");

11     var timeAgo = moment(commentDate).fromNow();

12

13     // update this view's DOM with the correct format

14     this.$(".comment-date").text(timeAgo);

15   }

16 });

This is functional. It will correctly show the “(time) ago” format that the view needs. But it does so at the cost of an undue burden on the developer that is using this plugin. Every time this code is needed, the developer that is writing it has to remember to call the prototype’s render method. Now there may be some scenarios where this is necessary, but something as common as manipulating the view’s DOM elements after rendering is going to produce a lot of code duplication - boilerplate - and create a host of potential problems, like forgetting to call the prototype method.

To correct this, the BaseView can allow an onRender function to be specified on views that extend from it. If this method is provided, it will be called after the view has been rendered. This will allow a specific view to modify the DOM as needed, without having to override the render method.

 1 BBPlug.BaseView = Backbone.View.extend({

 2   render: function(){

 3     var data;

 4     if (this.serializeData){

 5       data = this.serializeData();

 6     };

 7

 8     var renderedHtml = _.template(this.template, data);

 9     this.$el.html(renderedHtml);

10

11     // Call the `onRender` method if it exists

12     if (this.onRender){

13       this.onRender();

14     }

15   }

16 });

Now I can modify the CommentView to use the onRender method instead of overriding the render method directly:

 1 CommentView = BBPlug.BaseView.extend({

 2   template: "...",

 3   serializeData: function(){...},

 4

 5   onRender: function(){

 6     // call moment.js' to format our dates correctly

 7     var commentDate = this.model.get("date");

 8     var timeAgo = moment(commentDate).fromNow();

 9

10     // update this view's DOM with the correct format

11     this.$(".comment-date").text(timeAgo);

12   }

13 });

This may only save one or two lines of code and one comment in the view, but when that one line is added up 20, 30, or 300 times in a very large application, it can make a significant difference.

Default serializeData Implementation

There are still some chunks of boilerplate code in the CommentView and previous view implementations from the last chapter. Each of the views, though it has extended from the BBPlug.BaseView, must provide a serializeData method if it needs data in the template. Most of the time the data that is needed will be one of two things: this.model.toJSON() or {items: this.collection.toJSON()}. Rather than repeating this code in each view that needs it, a default serializeData implementation can be provided.

 1 BBPlug.BaseView = Backbone.View.extend({

 2

 3   serializeData: function(){

 4     var data;

 5

 6     if (this.model){

 7       data = this.model.toJSON();

 8     }

 9

10     if (this.collection){

11       data = { items: this.collection.toJSON() };

12     }

13

14     return data;

15   },

16

17

18   render: function(){

19     // ...

20   }

21 });

By including this default implementation of serializeData, a view that needs either a model or a collection as its data source will not have to provide its own version of this method.

 1 // Model View

 2 // ----------

 3 MyView = BBPlug.BaseView.extend({

 4   template: "..."

 5 });

 6

 7 myView = new MyView({

 8   model: myModel

 9 });

10

11 myView.render();

12

13

14 // Collection View

15 // ---------------

16

17 MyCollectionView = BBPlug.BaseView.extend({

18   template: "..."

19 });

20

21 myCollectionView = new MyCollectionView({

22   collection: myCollection

23 });

24

25 myCollectionView.render();

Of course any view that needs any custom data can still provide an implementation, but it is not strictly necessary.

Extracting ModelView And CollectionView

There is one potential problem that this solution creates. If a view happens to have both a model and a collection set when the serializeData function is executed, then the collection will always win. If the view intends to render the model as the data source for the template and only needs the collection as a supporting object for some other purpose, then there’s a problem. The view has rendered the collection data instead of the model data. To fix this, the view would have to either override the serializeData method to only use the model, or provide the collection to the view in a different manner. While either of these options would work, they are not the most elegant solution.

A better solution - one that creates more flexibility, more capabilities and less ambiguous use - would be to separate the ideas of a ModelView and a CollectionView. Each of these view types would provide the specific serializeData method that it needs, and open up the potential for other features to be added to a specific view type.

The extraction of these two view types does not negate the need for the BaseView, though. Instead, it solidifies the usefulness of this base view type. The ModelView and CollectionView can extend directly from it, keeping the core rendering logic in one place. But the BaseView will no longer need a default implementation of serializeData, so this method can be removed.

 1 BBPlug.BaseView = Backbone.View.extend({

 2   render: function(){

 3     var data;

 4     if (this.serializeData){

 5       data = this.serializeData();

 6     };

 7

 8     var renderedHtml = _.template(this.template, data);

 9     this.$el.html(renderedHtml);

10

11     // Call the `onRender` method if it exists

12     if (this.onRender){

13       this.onRender();

14     }

15   }

16 });

17

18 // Create a `ModelView` view type, to render a model in to the template

19 BBPlug.ModelView = BBPlug.BaseView.extend({

20

21   serializeData: function(){

22     var data;

23

24     if (this.model){

25       data = this.model.toJSON();

26     }

27

28     return data;

29   }

30

31 });

32

33 // Create a `CollectionView` view type, to render a collection in to the template

34 BBPlug.CollectionView = BBPlug.BaseView.extend({

35

36   serializeData: function(){

37     var data;

38

39     if (this.collection){

40       data = this.collection.toJSON();

41     }

42

43     return data;

44   }

45

46 });

In both of these new view types, the render method from the base view will be used, but the serializeData method from the specific view type will be used.

With these new view types in place, the blog post with comments example could be updated so that the blog post itself renders as a ModelView while the comments are rendered as a CollectionView.

 1 BlogPostView = BBPlug.ModelView.extend({

 2   template: "..."

 3 });

 4

 5 CommentsView = BBPlug.CollectionView.extend({

 6   template: "..."

 7 });

 8

 9 var blogPostView = new BlogPostView({

10   model: blogPost

11 });

12

13 var commentsView = new CommentsView({

14   collection: blogPost.comments

15 });

The view definitions for a blog post and the related comments view are now very small. They each only have one line of code, specifying the template that the view needs to use. The remaining code to serialize the data and render the template with that data has been abstracted in to various view types in the BBPlug framework. This is a significant reduction in the amount of code that was required even to render a “Hello World” example.

Lessons Learned

This chapter has provided a much more in-depth exploration of what can be included in a framework of View types that sit on top of Backbone. Starting with the basic BBPlug.BaseView, the need to cleanly separate a ModelView and CollectionView was identified and codified. Additional reasons for providing abstractions and reusable code were also identified, resulting in several new lessons that can be taken and applied to other view types as well as other object types in general.

Remove Boilerplate. Don’t Just Move It.

The initial implementation of BBPlug.BaseView required every view to implement a serializeData function. This was fine at first, when only a few distinct views were being used. But as new views were identified, the realization that each of these views fell in to the same patterns of what theserializeData implementation should look like set in. The BaseView had removed some boilerplate code, but in the process it had also just moved some of it. A simple default implementation of serializeData was created and the amount of code that each view was required to implement was reduced.

If the extraction of common functionality requires a specific function to be implemented, or specific data to be provided, ask whether or not the extracted code can have a default implementation. Does the BaseView’s render method truely require a template, for example? In this case yes. Rendering HTML without a template to render wouldn’t prove to be of much use. But when the question is asked about the serializeData method, the answer was yes, a default implementation could be used.

Specialization Comes From Generalization

In the process of creating the default serializeData method, two separate and specialized use cases became apparent for views: a model view, and a collection view. The initial difference between these two view types was only the way the serializeData method was implemented. TheModelView uses the view’s model for data, while the CollectionView used the view’s collection.

By creating specialized views from the generalized BaseView, though, more flexibility was achieved and more opportunities were opened up. In the future, there may be additional behavioral and implementation changes that are common only to views that render collections. As these specialized needs are discovered, they can be added directly to the CollectionView without affecting the BaseView or ModelView directly.

Don’t Require Calls To The Prototype’s Method

In many cases a simple implementation of a common function will prove to be insufficient. Specific implementations and use cases will have specific needs. In some cases it may be necessary to override a method that a base type implements, and then call the method on that base prototype before providing the specific implementation needs.

Whenever possible, though, this should be avoided. The burden of having to remember to call a prototype’s method can cause mistakes and bugs in systems. Instead, provide calls to optionally implemented methods such as the onRender method to allow custom code in specific view types. This method, when implemented in a view that extends from BaseView, allows a specific view to perform any necessary DOM manipulation or other functions just after the view has been rendered. It allows the code to be encapsulated within the view where it is needed, and it also keeps the implementor from having to remember to call a prototype method to facilitate the actual rendering.

Hide Calls To Prototype Methods When They Are Necessary

There will inevitably be situations where calls to a prototype method are necessary. In these situations, the prototype method calls should be hidden in the implementation of the type that is being extended. The developer that is extending from these types should not be responsible for making the prototype method call, unless they choose to explicitly override a prototype method.

This means that in a view implementation, code that needs to be executed on construction or initialization of the view should not implement an initialize method. It’s common practice for a view to have an initialize method to set up event handlers, etc. Implementing the initializemethod on a base class would force all views that need to use this method to call to the prototype’s initialize method. Instead, implement a constructor function in base views so that the need to call the prototype’s method can be hidden in the base view. This allows a specific view to implement an initialize method as needed, while still allows the base view type to run custom code on construction of a new view instance.