Adam Terlson
Software Engineer
http://rvl.io/adamterlson/
@adamterlson
.
├── 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
var myExampleView = Backbone.View.extend({
// Properties
// Backbone
// Bootstrap
// Rendering
// Backbone Events
// UI Events
// Methods
});
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!
},
el
model
collection
options
var myExampleView = Backbone.View.extend({
// Properties
pageSize: 10,
activePage: 0,
pages: null,
additionalCollection: null,
additionalModel: null
});
urlRoot/url
, idAttribute
, defaults
(make this a function by the way), initialize, save/fetch/sync/destroy/etc...
model
, urlRoot/url
, initialize...
template
(though this isn't technically an override), events
, initialize
, id
, className
, tagName...
// 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
urlRoot: '/api/exmaple',
idAttribute: 'ExampleId',
defaults: function () {
return {
foo: '',
bar: { baz: 'baz' },
listofstuff: []
};
},
initialize: function () { }
.toJSON()
when passing data to templates// Rendering
render: function () {
this.$el.html(this.template({ person: this.model.toJSON() }));
},
renderSpotA: function () {
this.$('#SpotA').html(...);
},
renderSpotB: function () {
this.$('#SpotB').html(...);
}
on<Object><Event>
// Backbone Events
onModelSync: function () {
this.somePresentationLogic();
this.someMorePresentationLogic();
}
// UI Events
onItemDeleteClick: function (e) {
e.preventDefault();
var itemId = $(e.target).data('itemId');
this.deleteItemById(itemId);
}
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 - 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();
}
Testvar myView = new MyView({ el: $spot }); myView.bootstrap();
// or, if you return `this`
var myView = new MyView({ el: $spot }).bootstrap();
test("...", function() {
// Arrange
var myView = new MyView(); // Calls initialize
// Act
var result = myView.functionToTest();
// Assert
strictEqual(result, 1);
});
// Do not do this
presentationLogicAndClickEvent: function (e) {
// ...
this.model.set({ foo: this.$('#foo').val() }); // Mock val?!
// ...
}
// Do not do this
presentationLogicAndClickEvent: function (e) {
// ...
this.$('#foo').val('new value');
// ...
this.render(); // Bye bye
}
render()
around
Silence (Delay < 0.9.10) events with { silent: true }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 }
// 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
// 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();
}
router
├── view 1
│ ├── template
│ └── model
└── view 2
├── template
├── collection
│ └── model
└── first child view
├── template
├── model
└── second child view
└── template
// 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);
}
_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();
}
// 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);
}
el
, Not The Live DOMvar 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);
this
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.
var instance = new MyView().render();
this.$('#MyViewSpot').html(instance.$el);
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.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
}
});
if (selector === '') {
this.$el.on(eventName, method);
} else {
this.$el.on(eventName, selector, method);
}
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);
Sync
(and derivative) overrides
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...
});
test('...', function () {
// Arrange
// Object creation
// Act
// Execute method being tested
// Assert
// *All* of your assertions
});
test('...', function () {
// Arrange
var foo = new bar();
// Act
var result = foo.someLogic();
// Assert
strictEqual(result, 'some value');
});
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
});
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);
});
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));
});
By Adam Terlson
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.