Managing Nested Views - MANAGING THE DOM - Building Backbone Plugins: Eliminate The Boilerplate In Backbone.js Apps (2014)

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

PART 2: MANAGING THE DOM

Chapter 9: Managing Nested Views

Both managing the DOM and reducing boilerplate code through base view types are tremendously important. Each of these will provide a significant boost in your productivity and reduction in the amount of code that you have to write. When you combine your DOM management tools and base view types, though, that’s when the real magic begins to happen.

Take the combination of a ModelView and a Region, for example. A region is an object that manages the display of various views within a specified DOM element. A ModelView, on the other hand, allows you to render a template in to some HTML output. If you combine them, you will be able to render a template and have that rendered template display other views.

If this idea of rendering an HTML template and then populating additional HTML in to various parts of that template sounds familiar, you’re right! This is basic functionality that you will find in any modern web server. ASP.NET MVC calls this a Master Page. Ruby on Rails and ExpressJS call this a Layout, and other platforms may call it something else, still. The idea is the same, though, and building a Layout for your view framework is going to open a world of possibilities for larger, well-structured applications.

Nested Views: The Hard Way

It may seem like a good idea to nest views directly inside of each other - especially in very simple use cases.

To start, you only need to add a view’s $el to the DOM of your current view. This is a simple problem to solve. Using the onRender method of the ModelView, for example, you can inject the new view after rendering the parent view.

Set up a couple of templates, like this:

1 <script id="child-view" type="text/html">

2 <h2>I'm a child view!</h2>

3 </script>

4

5 <script id="parent-view" type="text/html">

6 <h1>I'm the parent view!</h1>

7 <div class="child-container"></div>

8 </script>

And a ModelView with the onRender method implemented will be able to handle inserting the child view template in to the parent:

1 var ChildView = BBPlug.ModelView.extend({

2 template: "#child-template"

3 });

4

5 var ParentView = BBPlug.ModelView.extend({

6 template: "#parent-view",

7

8 onRender: function(){

9 var child = new ChildView();

10 child.render();

11 this.$("#child-container").html(child.$el);

12 }

13 });

14

15 var parent = new Parent();

16 parent.render();

17 $("body").html(parent.$el);

This displays the parent view with the child view in the body of the HTML document, as expected. Easy enough, right? It certainly looks easy enough, but there are a host of problems that this type of view embedding can cause, including views that are difficult to swap out or change type, and potentially causing unwanted zombies.

If you are bent on solving these problems the hard way, you’ll need to do a few things for each of the problems.

Closing The Child View

The first thing you’ll need to do is close the child view. At the very least, you’ll need to do this when the parent view is closed. As discussed in the first part of this book, leaving a view unclosed causes a number of problems like zombie view references.

To close the child view, you’ll need to hold a reference to the child view and close it when the parent closes.

1 var Parent = BBPlug.ModelView.extend({

2 template: "#parent-template",

3

4 onRender: function(){

5 // store the child in the parent

6 this.child = new Child();

7 this.child.render();

8 this.$("#child-container").html(this.child.$el);

9 },

10

11 onClose: function(){

12 // close the child when the parent is closed

13 if (this.child){

14 this.child.close();

15 }

16 }

17 });

Now the child view will be closed when the parent closes. Next, you’ll need to handle the view type for your child.

Changing Child View Types

If you want to swap out the child view instance for a new view type, you’ll need a way to define which view type to display. You can do this a number of ways, such as if statements or switch statement, using data from the model or elsewhere.

1 var Parent = BBPlug.ModelView.extend({

2 template: "#parent-template",

3

4 onRender: function(){

5

6 // get the child view type

7 var ChildViewType;

8 switch (this.model.get("foo")) {

9 case "bar":

10 ChildViewType = BarChild;

11 break;

12 case "baz":

13 ChildViewType = BazChild;

14 break;

15 default:

16 ChildViewType = Child;

17 break;

18 }

19

20 this.child = new ChildViewType();

21 this.child.render();

22 this.$("#child-container").html(this.child.$el);

23 },

24

25 onClose: function(){

26 // close the child when the parent is closed

27 if (this.child){

28 this.child.close();

29 }

30 }

31 });

Your parent view can render any of the pre-defined child views, at this point. The logic to get the view type is getting a little jumbled up with the logic to render and display the view, though. And it still hasn’t covered the use case of closing the existing child view and re-rendering a new child view type.

Child Container? Re-rendering? BOILERPLATE!

What would it take to close the current child view and render a new one? What about changing the child view container? Now multiply the lines of code in this solution by the number of children you need to embed in to the parent. This would involve loops, additional logic and multiple storage slots for the child view instances.

Even with good method extraction, the Parent view type ends up with a lot of boilerplate code. And what do you do with boilerplate code?

Defining A Layout, Use-Case-First

Like the other view types at the beginning of this book, the boilerplate for nesting child views within parent views can easily be extracted and encapsulated. The logic and process of managing children can be separated from the logic and process of determining which child view type to use and where in the parent view to display it. Furthermore, most of the logic that you need to store the child view reference, manage swapping views in and out, and closing the child views, is already encapsulated in the Region object that you built earlier.

Combining these existing tools with a little more logic can help you to quickly and easily create a new view type to handle view nesting. But before you add regions to handle the nesting, you need to create a clean method of defining which views will go where.

Requirements For Child View Type And Location

Rather than starting with a Layout view definition, start with the use case of the layout, as if the Layout type already existed. This will let you design how the API and configuration of the Layout looks before implementing it. Having the design in place will make it easier to create the Layout type.

Given the problems that were noted earlier, the configuration of the Layout will need to handle a few things:

1. Easily define DOM locations for child views

2. Allow child view types to be determined programmatically

3. Easily swap view instances in / out of these locations

4. Allow view swapping from within or from outside of the parent view

With the basic requirements list outlines, it’s time to think about the configuration of the layout to support these needs. Keeping the configuration consistent with other parts of Backbone and your BBPlug view configuration is important. Using object literals will provide this consistency and provide the needed flexibility in the requirements.

Named DOM Elements: Regions

There are times when a Layout will be in complete control over all the views that it contains. There are times, though, when a higher level object or workflow handling mechanism will need to be in control over the specific views displayed within the Layout. You already have an object type that can handle these needs: Regions. As long as the Region instance for the specified DOM element is available as part of the Layout’s API, you can handle both requirements. To do that, you will want to name a region instance and provide a selector for it.

1 var MyAppLayout = BBPlug.Layout.extend({

2 regions: {

3 child1: "#child-container",

4 child2: "div.something-here",

5 child3: ".that-one-there"

6 }

7 });

Naming the configuration option regions - plural of “region” - implies that multiple regions can be defined. The key in the regions configuration items can be used to generate a new attribute on the Layout instance. Each of these new attributes will be an instance of a Region, assigned to the DOM selector.

Using The Layout’s Regions

Using the regions from the Layout will work the same as any other region - you call the show method, passing a view instance to it.

1 var layout = new MyAppLayout();

2 layout.render();

3

4 layout.child1.show(new ChildView());

5 layout.child2.show(new AnotherView());

6 layout.child3.show(new SubLayout());

To ensure the specified DOM element exists for the region, you will need to render the Layout instance first, and then show the children.

Requirements Met, Unlimited Nesting Enabled

All of the requirements for a Layout are met with this API.

1. You can easily define new regions within a layout by adding a new line to the regions configuration item

2. Regions take any valid Backbone.View instance, letting you run any code you need to determine which view instance to display in a region

3. Since regions are used, there is only one method that you need to call to swap a new view in and an old view out

4. The defined regions become part of the public API for the Layout instance, allowing 3rd parties to get involved in determining which view to display

Also note the implication of the third example, above (in the child3 region). A layout instance is being nested within the parent layout. You can display any valid Backbone.View type in a Region. This means that another Layout can be shown within the region of a parent layout. The result is an infinite amount of nesting for views - a very powerful idea that allows applications to be broken down in to very fine-grain pieces.

Build The Layout Type

With the requirements met from an API perspective, it’s time to create an implementation that will work the way you expect.

Start with a basic view definition, of course.

1 BBPlug.Layout = BBPlug.ModelView.extend({

2

3 });

Beyond the built in rendering, the regions configuration needs to be processed. You might be tempted to use the onRender function for this, but that would cause problems when developers want to extend from your Layout. This would force use of the onRender method to include a call back to the prototype’s onRender. Instead, you’ll want to override the render function and make the call back to the original render function, within the Layout code. This will make it easier for others to extend from the Layout, later.

Your new render function will call the original to make sure everything is rendered, and then it will call a method to parse the region definitions.

1 BBPlug.Layout = BBPlug.ModelView.extend({

2 render: function(){

3 // call the original

4 var result = BBPlug.ModelView.prototype.render.call(this);

5

6 // call to process the regions

7 this.configureRegions();

8

9 return result;

10 }

11 });

By taking the hit yourself, and overriding the render function, you are freeing up the onRender method for those that extend from your Layout.

Now you’ll need to add the configureRegions method. This one will process the regions configuration and create

Processing The Regions Configuration

The configureRegions method will need to loop through all of the named regions. Each of these will turn in to an instance of a Region, as an attribute on the Layout instance.

1 configureRegions: function(){

2 // get the definitions

3 var regionDefinitions = this.regions || {};

4 // loop through them

5 _.each(regionDefinitions, function(selector, name){

6 this[name] = new Region({el: selector});

7 }, this);

8 }

This code is compact, but it does a lot for you.

The first line grabs either the regions definition or an empty object literal. If there are no regions defined, the _.each method would throw an error. Adding the || {} ensures there is always an object literal to use, preventing the error.

Once inside the loop over each key/value pair, a new Region instance is created. The region instance has it’s el assigned to the selector variable, which is the value half of the key/value pair.

The resulting Region instance is assigned to an attribute on the layout instance, using the name (key from the key/value pair) as the attribute.

But there’s a problem, here. The selector that the region is handed will run against the entire DOM. It isn’t scoped to only the Layout instance.

Scoping The Region Selector

It might not seem like a bad idea to let the Region’s selector be scoped to the full DOM, off-hand. If you’re only using CSS IDs for selection, this won’t be a problem at all. But when you get in to larger applications with deep and complex structures, there’s a good chance that you won’t be using IDs for everything. In fact, there’s a good chance that you’ll repeat HTML and CSS class selectors across views. If that happens, you could easily select DOM elements that are outside of the Layout’s $el. This is a very bad idea. Your Layout (and Backbone.View instances, in general) should be restricted to the $el and only the $el. Allowing it to select anything outside of this will cause problems, long run.

The selector for the Regions, then, needs to be scoped to the Layout’s $el. To do this, look back at how the Region’s el option works. The el option that you pass in is stored for later use. When the Region first shows a view, it calls the ensureEl method which turns the el in to a proper jQuery selected object.

Given this, there are two options for solving the problem:

1. Pass an already jQuery selected object as the el

2. Override the ensureEl function on a custom region type

The second option seems fair, off-hand. But if you decide at a later time that you want to allow custom Region types to be used, this gets to be a little more dubious. Instead of having a single Region type that has the over-ridden ensureEl method, you now have to override it on every Region type used. This can cause problems with custom Region types that have special implementations of ensureEl.

So, you are left with the first option, though it is not without fault, either. Normally, it is not a good idea to take advantage of internal behavior and code from other objects. This type of coupling creates horrible headaches and problems for future developers that don’t know the same things as you. Since the Layout object is part of the same framework as the Region, though, an exception to this can be made.

Supply a jQuery selected el to the Region instance, using the Layout’s this.$(selector) method. This method scopes the selector to the view’s $el. jQuery is also smart enough to not re-select an object that is already a jQuery selected object.

1 configureRegions: function(){

2 // get the definitions

3 var regionDefinitions = this.regions || {};

4

5 // loop through them

6 _.each(regionDefinitions, function(selector, name){

7

8 // pre-select the DOM element

9 var el = this.$(selector);

10

11 // create the region, assign to the layout

12 this[name] = new Region({el: el});

13 }, this);

14 }

The end result, then, is that you have pre-selected the DOM element for the Region.

Closing The Regions With The View

Being able to display a nested view is great. But there’s more to it than just showing the view. If you want to avoid additional zombie views and other problems, you will need to close the nested views as some point. The Regions that you’re using to show the view can handle this, but they need to be told to close the child view.

Like the render method, where you added behavior to set up the child regions, you need to override the close method and add behavior to close the regions (and sub-sequently, child views).

1 BBPlug.Layout = BBPlug.ModelView.extend({

2 // ... existing methods here

3

4 close: function(){

5 // close the Layout

6 BBPlug.Layout.prototype.close.call(this);

7

8 // close the regions

9 _.each(this.regions, function(selector, name){

10 // grab the region by name, and close it

11 var region = this[name];

12 if (region && _.isFunction(region.close)){

13 region.close();

14 }

15 }, this);

16

17 }

18 });

The new close method will close all of the regions by looping through the regions configuration, grabbing a reference to the region by name and calling the close method on it. This works because the code to set up the regions uses the name to add attributes of the specified name. The only additional logic is to check and make sure the name returns an object with a close method. Without this check, closing a Layout that had not been rendered would result in errors since the regions would not exist on the Layout instance.

The Layout itself is closed before the child regions in order to remove the entire Layout from the DOM before closing the actual view instances. If you closed the Layout after the regions, you run a change of poor performance and odd visual problems. This would cause each region to remove it’s child view from the DOM, one at a time. Each removal from the DOM could trigger a reflow and repaint of the DOM, resulting in artifacts and oddities in the DOM itself. By closing the Layout first, this is avoided because the layout will remove all of the child DOM elements at the same time.

information

Repaint, Reflow And Performance

For more information on Repaint and Reflow with the DOM, and performance on the web in general, check out Nicholas Zakas’ High Performance JavaScript book.

Re-Rendering The Layout With Regions

The final challenge in a Layout, at this point, is being able to re-render the Layout itself. Re-rendering the Layout and showing new view instances generally works properly. But it can lead to problems of code in the child views not being cleaned up properly. Fortunately, this is a simple problem to solve. You already have the code to close the regions. It just exists inside of the close method at the moment.

Extract the code to close the regions in to a closeRegions method, and have that called from the close method to make sure it still cleans things up.

1 BBPlug.Layout = BBPlug.ModelView.extend({

2 // ... existing methods here

3

4 close: function(){

5 // close the Layout

6 BBPlug.Layout.prototype.close.call(this);

7 // close the regions

8 this.closeRegions();

9 },

10

11 closeRegions: function(){

12 _.each(this.regions, function(selector, name){

13 // grab the region by name, and close it

14 var region = this[name];

15 if (region && _.isFunction(region.close)){

16 region.close();

17 }

18 }, this);

19 }

20 });

Now you can modify the render method to close the regions before doing the real rendering.

1 BBPlug.Layout = BBPlug.ModelView.extend({

2 render: function(){

3 // close the old regions, if any exist

4 this.closeRegions();

5

6 // call the original

7 var result = BBPlug.ModelView.prototype.render.call(this);

8

9 // call to process the regions

10 this.configureRegions();

11

12 return result;

13 },

14

15 // ... existing methods here

16 });

If this is the first time that the view has been rendered, the closeRegions method will just loop through the names and not do anything. On subsequent renders, though, the regions will be closed appropriately. This method is already smart enough to handle both situations for you.

Lessons Learned

Nesting views in Backbone is important, when applications begin to grow. Having the ability replace large swaths of DOM content with feature-specific layouts, and nesting child views in to those layouts, will open a new world of possibilities in application design and modularity. But nesting views doesn’t come without it’s own challenges.

Design The API First

Looking at the implementation details first, and slogging through the code to make it work, is a good way to shoot yourself in the foot. When building the Layout, the API for configuring the regions was designed first. This allowed you to see the expected behavior of the Layout, from the API perspective, first. Once you had the API set the way you wanted, implementing the Layout was easier. You didn’t have to guess at the configuration or try to figure it out on the fly. You already knew that the configuration would work, and only had to implement something that could read the configuration in question.

Iterate And Increment

Not every API design is easy, and it is rare to get it right the first time. Even when you design the API first, you will run in to situations where the API doesn’t quite match the possibilities for the implementation details. Don’t be afraid of this - welcome it. Use it as a learning experience to understand API design. Iterate over the API design, with the implementation details. When one idea works in the API but not implementation, change the API. When a design works in implementation but creates a terrible API, change the API. Work through small, concise and incremental changes until you find a balance between the API and implementation.

Nesting Views, Infinitely

The implementation of a Layout demonstrates the first bits of view composition in an application framework. Now you can nest views inside of regions that are already nested in a view. Any valid Backbone.View can be used, meaning you can nest the view structure as deep as you need. Given the nature of how views are attached to the regions, you can easily orchestrate an application to compose the user interface on the fly.

Object Composition, View Composition

The implementation of the Layout demonstrates a key concept to building large scale applications of any kind: object composition. The user interface of an application can be composed from multiple views at runtime, and the Layout object itself is composed of multiple objects. You’ve combined the ModelView and Region objects with a little bit of glue to hold them together. The end result is a new type of object that opens a world of opportunity in the application’s user interface.

Method Composition

Methods are as easily composed as objects - sometimes easier. In the case of the Layout, you had code that was closing all of the child regions. Then, when the need to close the regions at a different point in the Layout’s lifecycle became apparent, this code was extracted in to it’s own method. The result was another method that could be used to compose larger behavior sets within the Layout object.

Self-Documenting Code

In addition to the benefit of behavioral composition within the Layout, extracting the method to close the child regions created a better sense of self-documenting code. That is, code that is expressive of the intent of the code, not the implementation of the code.

Human beings think in hierarchy and abstraction, even if they don’t realize it. Having a function named for the intent and behavior of what it does helps people create simple hierarchies that are meaningful. When you look at the .close method on the Layout now, you can clearly see that is closes the child regions because of the this.closeRegions() call. You can see the same call happening in the render method. Both of the close and render methods are easier to understand because of this one line of code replacing 3 or 4 lines of implementation.

The name closeRegions describes the behavior of the method without boring you or confusing you with the implementation details. If you need to know how it works, at some point, you can go look at that method without worrying about the rest of the implementation details from the closeor render method.

Rules Are Meant To Be Broken

I hold the rule of not digging in to an object’s internals very dearly. The “tell, don’t ask” principle, and the Law of Demeter both give us ample reason not to do this. It creates tightly coupled code and causes problems down the road.

But sometimes it’s ok to break the rules.

When you implemented the Layout, and you had to scope the DOM selectors for regions to the Layout’s $el, you had two choices. Both of the choices required knowledge of how the Region object worked. One of the choices would replace a function on the region. The other choice relied on knowing that Regions use jQuery to select their $el from the DOM. While neither of these choices were great, having to choose one of them is ok in this case, for two reasons:

1. It let you implement the feature and behavior that you needed, but more importantly,

2. It happened within the internals of two objects within the same over-all framework

Since the Layout, Region and ModelView are all in the same framework, having to dig in to the internals of an object can be overlooked. Just be careful not to do it too many times. And be on the look out for scenarios were this tight coupling does break down and cause problems. You may need to find another solution, in the end.