9 Tips for a clean BackboneJS App



http://rvl.io/adamterlson/




@adamterlson

@adamterlson


Current: JS dev at Best Buy
Former: JS dev at Thomson Reuters


JS <3




Why this presentation?


  • Working with large teams 
  • Developers unfamiliar with JavaScript
  • Developers unfamiliar with Backbone
  • Migrated legacy app into new "SPA"
  • No unit tests




"Opportunities"






#1 : Organization Inside & Out


Outside is Obvious


Backbone Boilerplate:
.
├── app
│   ├── app.js
│   ├── config.js
│   ├── main.js
│   ├── router.js
│   └── styles
│   |   └── index.css
├── vendor
│   ├── h5bp
│   │   └── css
│   │   |   ├── main.css
│   │   |   └── normalize.css
│   └── jam
│   |   ├── backbone
│   |   ├── backbone.layoutmanager
│   |   ├── jquery
│   |   ├── lodash
│   |   ├── underscore
│   |   ├── require.config.js
│   |   └── require.js
│   └── js
│   |   ├── libs
│   |   │   ├── almond.js
│   |   │   └── require.js
├── favicon.ico
├── grunt.js
├── package.json
├── index.html
└── test
    ├── jasmine
    └── qunit
    
No one answer to file structure -- Consistency is most important

What about Inside?

All sections need not apply.

var myExampleView = Backbone.View.extend({
    // Properties
        
    // Backbone
        
    // Bootstrap
        
    // Rendering
        
    // Backbone Events
        
    // UI Events
        
    // Methods
}); 
    



Nothing is stopping this:

myBusinessLogic: function () {
    if (this.myCollection.length && this.someValue) { // Where did these come from?!
        // ...
    }
},

//... 50 lines later

anotherFunction: function () {
    this.someValue = 10;
    this.myCollection = new Backbone.Collection();  // Oh, there it is!
},

// Properties

  • Self-documentation
  • Default values
  • Exclude native backbone objects/overrides
    • el
    • model
    • collection
    • options
    • id
    • className
    • tagName
    • attributes

// Properties


var myExampleView = Backbone.View.extend({
    // Properties
    
    pageSize: 10,
    activePage: 0,
    pages: null,
    
    additionalCollection: null,
    additionalModel: null
});
Warning: Use {} and [] wisely.

// Backbone

  • Backbone native, type-specific overrides
    • Model: urlRoot/url, idAttribute, defaults (make this a function by the way), initialize, save/fetch/sync/destroy/etc...
    • Collection: model, urlRoot/url, initialize...
    • View: template (though this isn't technically an override), events, initialize, id, className, tagName...
  • Display logic when appropriate

// Backbone (View)


// Backbone

template: _.template(exampleTemplate),

initialize: function () { },

events: {
    'mouseenter': 'onViewMouseEnter', // This binds to $el directly
    'submit form': 'onFormSubmit',
    'click .switch': 'onSwitchClick',
    'change input[name=property]': 'onPropertyInputChange'
}

//Backbone (Model)


// Backbone

urlRoot: '/api/exmaple',
idAttribute: 'ExampleId',

defaults: function () {
    return {
        foo: '',
        bar: { baz: 'baz' },
        listofstuff: []
    };
},

initialize: function () {  }

// Bootstrap






More on this later

// Rendering

  • It's okay to have more than one!
  • One of two locations for touching the DOM in your view
  • Use .toJSON() when passing data to templates
  • Data should be pre-formatted wherever possible

Check out DustJS

// Rendering

// Rendering

render: function () {
    this.$el.html(this.template({ person: this.model.toJSON() }));
},

renderSpotA: function () {
    this.$('#SpotA').html(...);
},

renderSpotB: function () {
    this.$('#SpotB').html(...);
}
    

//Backbone Events

  • Named: on<Object><Event>
  • Never executed directly
  • No presentation logic
// Backbone Events

onModelSync: function () {
    this.somePresentationLogic();
    this.someMorePresentationLogic();
}

//UI Events

  • The other acceptable location outside of render for touching the DOM
  • Same rules apply as Backbone events
// UI Events

onItemDeleteClick: function (e) {
    e.preventDefault();
    var itemId = $(e.target).data('itemId');
    this.deleteItemById(itemId);
}

// Methods





... they're functions




#2 : Consider "Bootstrap"






Goal

Find a place to do initial model hydration prior to first render.


Common (Bad) Solutions

Initialize gets executed by every unit test
Render is executed later to refresh the UI when the state changes.




Solution

  1. Split initialize from 'pre-render' startup
  2. Make the parent execute it 

Initialize

  • For initializing values, setting options, and backbone event binding only  (do not call render or make server calls)

initialize: function (options) {
    this.pageSize = options.pageSize || this.pageSize;
    this.additionalCollection = new Backbone.Collection();
       
    this.listenTo(this.model, 'sync', this.onModelSync)
        .listenTo(this.model, 'destroy', this.onModelDestroy)
        .listenTo(this.additionalCollection, 'add', this.onAdditionalCollectionAdd);
}

I don't like this.options--It's not explicit.

Bootstrap

  • Executed by the consumer to "kick off" your view
  • Server and rendering calls only
  • Orchestrator of when initial rendering happens
// Bootstrap - Hydration with spinner
bootstrap: function () {
    this.renderFrame(); //shows spinner(s)
    
    this.primaryModel.fetch().then(this.renderPrimary); 
    this.secondaryModel.fetch().then(this.renderSecondary);
    
    return this;
}
// Bootstrap - Hydration before any render
bootstrap: function () {
    $.when(
        this.primaryModel.fetch(),
        this.secondaryModel.fetch()
    ).then(_.bind(this.render, this));
    
    return this;
}
// Bootstrap - No hydration
bootstrap: function () {
    return this.render();
}

Usage

App
var myView = new MyView({ el: $spot });
myView.bootstrap();

// or, if you return `this`
var myView = new MyView({ el: $spot }).bootstrap();
Test
test("...", function() {
    // Arrange
    var myView = new MyView(); // Calls initialize

    // Act
    var result = myView.functionToTest();

    // Assert
    strictEqual(result, 1);
});

Benefits

  • Initialize free of AJAX calls/rendering
  • Fewer mocks leads to cleaner unit tests
  • Bootstrap after view creation




#3 : Write Backbone, not jQuery

Why not jQuery?


// Do not do this
presentationLogicAndClickEvent: function (e) {
    // ...
    
    this.model.set({ foo: this.$('#foo').val() }); // Mock val?!
    
    // ...
}
Unit Testing becomes a nightmare

Why not jQuery?


// Do not do this
presentationLogicAndClickEvent: function (e) {
    // ...
    
    this.$('#foo').val('new value');
    
    // ...
    
    this.render(); // Bye bye
}
DOM changes are temporary with render() around

Render is your friend

initialize: function () {
    this.listenTo(this.model, 'change', this.onModelChange);
},
onModelChange: function () {
    this.render();
},//...
presentationLogic: function (value) {
    this.model.set({ foo: value }); // trigger change, calls render
}
Silence (Delay < 0.9.10) events with { silent: true }

...Most of the time


Over reliance on render *can* impact performance.

Consider targeted rendering or subviews.





jQuery: Render, UI Events


Backbone: Everywhere else




#4 : Avoid Unnecessary Subviews




    Signs to Separate

    1. Segment independently operates upon its own model
    2. Segment can live on its own in a different context
    3. Your view is huge

    One way to subview

    // Backbone
    
    template: _.template(frameTemplate),
    
    // Rendering
    
    render: function () {
        this.$el.html(this.template());
        this.renderItemList();
    },
    
    renderItemList: function () {
        var $ul = $('<ul/>');
        this.itemsCollection.each(function (item) {
            var itemView = new ItemView({ tagName: 'li', model: item }).bootstrap();
            $ul.append(itemView.$el);
        });
    
        this.$('#itemList').html($ul);
    }
    
    Construct subviews in render or initialize

    Multi Template/Render

    // Backbone
    
    templates: {
        frame: _.template(frameTemplate),
        order: _.template(orderTemplate),
        user: _.template(userTemplate)
    },
    
    initialize: function () {
        this.orderModel = new orderModel();
        this.userModel = new userModel();
        
        this.listenTo(this.orderModel, 'change', this.onOrderModelChange); 
        this.listenTo(this.userModel, 'change', this.onUserModelChange); 
    },
    
    // Bootstrap
    
    bootstrap: function () {
        $.when(
            this.orderModel.fetch(), 
            this.userModel.fetch()
        ).then(this.render);
        return this;
    },
    
    // Rendering
    
    render: function () {
        this.$el.html(this.templates.frame());
        this.renderOrder();
        this.renderUser();
        return this;
    },
    
    renderOrder: function () {
        this.$('#order').html(this.templates.order({ 
            order: this.orderModel.toJSON() 
        }));
    },
    
    renderUserInformation: function () {
        this.$('#user').html(this.templates.user({ 
            user: this.userModel.toJSON() 
        }));
    }
    
    // Backbone Events
    
    onOrderModelChange: function () {
        this.renderOrder();
    },
    
    onUserModelChange: function () {
        this.renderUser();
    }
    




    #5 : Unidirectional Knowledge

    Hierarchy


    router
    ├── view 1
    │   ├── template
    │   └── model
    └── view 2
        ├── template
        ├── collection
        │   └── model
        └── first child view
            ├── template
            ├── model
            └── second child view
                └── template
    


    Shared models make great "messengers"
    Cross-sibling communication via global events

    Via Promises (option 1)

    Return a promise from a function (e.g. bootstrap)
    // In parent view
    renderModalForm: function () {
        var modalForm = new ModalFormView();
        modalForm.bootstrap().then(this.onModalFormDone);
    },
    
    // In child view
    _dfd: null,
    bootstrap: function () {
        this._dfd = $.Deferred();
        this.render();
        return this._dfd.promise();
    },
    something: function () {
        this._dfd.resolve(someObj);
    }
    

    Via Promises (option 2)

    Make your view itself implement the promise API
    _dfd: null,
    
    constructor: function () {
        this._dfd = $.Deferred();
        this._dfd.promise(this); // Make this view a promise!
        this.always(_.bind(this.remove, this)); // Cleanup
        
        Backbone.View.prototype.constructor.apply(this, arguments);
    }
    
    something: function () {
        this._dfd.resolve();
    }    

    Via Events

    // In parent view
    renderModalForm: function () {
        var modalForm = new ModalFormView({ model: this.model });
        this.listenTo(modalForm, 'saved', this.onModalFormSaved);
        modalForm.bootstrap();
    },
    
    // In child view
    something: function () {
        this.trigger('saved', this.model);
    }
    




    #6 : Views Know el, Not The Live DOM

    What's in the DIV?

    var MyView = Backbone.View.extend({
        template: _.template('<div id="foo">initial text</div>'),
        
        initialize: function () {
            this.render();
        },
        
        render: function () {
            this.$el.html(this.template());
            $('#foo').html('changed text');
        }
    });
    
    
    var instance = new MyView();
    $('#somespot').append(instance.$el);
    




    Three steps to isolate your view from its context




    Step 1: All selectors should be prefaced with this




    this.$() ==> this.$el.find()

    Timing


    this.$() will always work (assuming it exists within $el)
    $() will only work after $el is appended to the DOM



    Context

    this.$() also prevents views from hijacking other parts of the page




    Step 2: View's Creator decides placement

    Good options


    Option 1:
    
        var instance = new MyView({ el: this.$('#MyViewSpot') }).render();
        

    Warning: Giving an el which is already attached will paint immediately.

    Warning: remove() will remove el, which isn't always desired.


         Option 2:      
    
        var instance = new MyView().render();
        this.$('#MyViewSpot').html(instance.$el);
        

    Bad options


    var view = Backbone.View.extend({ 
        el: $('#MyViewSpot')
    });
    Views shouldn't know about the DOM, remember?

     
    var view = new Backbone.View();
    view.setElement($someElement);
    
    This is ~50% slower.
    But setElement is useful outside of initial construction.




    Step 3: Use delegated eventing

    Backbone events are delegated for you

    var view = Backbone.View.extend({ 
        events: {
            'mouseenter': 'onViewMouseEnter', // bound directly on $el
            'click #foo': 'onFooClick', // delegated on $el
            'submit form': 'onFormSubmit', // delegated on $el
            'scroll .some-div': 'onSomeDivScroll' // will fail, scroll doesn't bubble
        }
    });
    

    Eventually executes (in Backbone source):  
    if (selector === '') {
      this.$el.on(eventName, method);
    } else {
      this.$el.on(eventName, selector, method);
    }
    

    What's in the DIV?

    var MyView = Backbone.View.extend({
        template: _.template('<div id="foo">initial text</div>'),
        
        initialize: function () {
            this.render();
        },
        
        render: function () {
            this.$el.html(this.template());
            $('#foo').html('changed text');
        }
    });
    
    var instance = new MyView();
    $('#somespot').append(instance.$el);
    




    'initial text'

    use this.$('#foo')




    #7 : Don't cram everything in your view


    View

    • Presentation/templating
    • UI Events
    • Model/Collection creation/orchestration
    • Model/Collection event binding
    • Application eventing
    • Presentation logic







    Models should only know about data!


    Collections know about models.

    Model/Collection

    • Validation
    • Sync (and derivative) overrides
    • Sever-side data consumption
    • Nested models or collections
    • Parse/data manipulation
    • Utility methods
    • Event bindings/Custom events
     



    No concept of UI or views!

    Router

    • Routes
    • Loading appropriate view(s)
    • URLs



    "It's too hard to unit 

    test JS code"




    "Opportunities"




    #8 : Arrange, Act, Assert

    One Test to Rule Them All

    
    test('...', function () {
       expect(100000000000000);
       var foo = bar;
       equal(foo.myFunctionToTest(), 'somevalue');
       
       foo.someLogic();
       equal(foo.property, 'newvalue');
       
       var baz = foo.createBaz();
       ok(baz.property);
       
       // and on and on and on...
    });
    

    Split tests up into three distinct parts

    • Arrange: Construction, setting, fake objects, stub methods
    • Act: Execute the method you are testing 
    • Assert:  Assertions
    test('...', function () {
        // Arrange
        // Object creation
        
        // Act
        // Execute method being tested
        
        // Assert
        // *All* of your assertions
    });




    If you cannot fit your test into these parts in this order, write a second test.

    In action (basic)

    
    test('...', function () {
        // Arrange
        var foo = new bar();
        
        // Act
        var result = foo.someLogic();
        
        // Assert
        strictEqual(result, 'some value');
    });
    




    #9 : Write small unit tests

    Unit testing with jQuery sucks

    
    test('...', function () {
        var page = $('<div><a href="#"></a></div>');
        var model = new MyModel({ id: 10 });
        var view = new MyView({ el: page, model: model });
        page.find('a').click();
        ok(view.model.get('disabled'));
        model.set('something', false);
        equal(view.foo, 'bar');
        //etc
    });


    If you've followed my advice, this 
    will be entirely unnecessary.

    Isolate your unit with mocks







    Use  SinonJS or another mocking library.

    QUnit & Sinon Test Example

    var MyView = Backbone.View.extend({
        initialize: function () { },
        render: function () { },
        someFunction: function () { 
            return this.model.fetch().done(this.render);
        }
    });
    
    module("...", {
        setup: function () {
            this.sandbox = sinon.sandbox.create();
        },
        teardown: function () {
            this.sandbox.restore();
        }
    });
    
    test("Positive Test", function() {
        // Arrange
        var dfd = $.Deferred();
        var promise = dfd.promise();
        
        var model = new Backbone.Model();
        var instance = new MyView({ model: model });
        this.sandbox.stub(instance, 'render');
        this.sandbox.stub(model, 'fetch').returns(promise);
        
        dfd.resolve();
        
        // Act
        var result = instance.someFunction();
        
        // Assert
        strictEqual(result, promise);
        ok(instance.render.called);
    });
    
    test("Negative Test", function() {
        // Arrange
        var dfd = $.Deferred();
        var promise = dfd.promise();
        
        var model = new Backbone.Model();
        var instance = new MyView({ model: model });
        this.sandbox.stub(instance, 'render');
        this.sandbox.stub(model, 'fetch').returns(promise);
        
        dfd.reject();
        
        // Act
        var result = instance.someFunction();
        
        // Assert
        strictEqual(result, promise);
        ok(!instance.render.called);
    });

    QUnit & Sinon Test Example: Initialize

    var MyView = Backbone.View.extend({
        initialize: function (options) {
            this.foo = options.foo;
            this.additionalCollection = new Backbone.Collection();
      
            this.model.on('sync', this.onModelSync);
            
            this.someFunction();
        },
        someFunction: function () { },
        onModelSync: function () { }
    });
    
    module("...", {
        setup: function () {
            this.sandbox = sinon.sandbox.create();
        },
        teardown: function () {
            this.sandbox.restore();
        }
    });
    
    test("...", function() {
        // Arrange
        var someFunctionStub = this.sandbox.stub(MyView.prototype, 'someFunction');
        var options = { 
            foo: 'somevalue',
            model: new Backbone.Model()
        };
        this.sandbox.stub(options.model, 'on');
        
        // Act
        var instance = new MyView(options);
        
        // Assert
        ok(someFunctionStub.calledOnce);
        ok(instance.additionalCollection instanceof Backbone.Collection);
        strictEqual(instance.foo, options.foo);
        ok(options.model.on.calledWith('sync', instance.onModelSync));
    });

    Think Beyond Code Coverage

    Unit tests help developers to not do evil things

    • Reusability
    • Limited scope of knowledge
    • Stop the nested anonymous functions!

    Results

    • Organization
    • Maintainability
    • Presentation logic completely abstracted from the UI
    • Test coverage & ease of writing tests
    • Faster development






    Thank you






    Adam Terlson
    adam@adamterlson.com
    @adamterlson

    Obligatory link slide


    9 Tips for a clean BackboneJS App

    By Adam Terlson

    9 Tips for a clean BackboneJS App

    This presentation seeks to eliminate that spaghettified, unmaintainable, untestable mess that JavaScript is so famous for producing and turn it into something beautiful. Developed after working on large teams with devs unfamiliar with JavaScript (not to mention Backbone) and yet tasked to create with it, I will walk through the 9 tips that I've found in practice will make your BackboneJS implementation easily maintained, easier to read, run better, and will allow you to effortlessly write those ever-so-important unit tests. Heavy on code samples, best practices, anti patterns, gotchyas, and random insights, this presentation is meant to serve as a reference point for creating a beautiful Backbone implementation for apps of any size and reduce the learning curve of BackboneJS.

    • 4,667