Comparing with Backbone
Sqimitive has been inspired by Backbone.js (bb:) and was written as a solution to most annoying problems after working with Backbone for months in a production project. It avoids certain less used (read: project-specific) features while conceptually bringing the rest under one roof. In particular, Sqimitive:
- Removes somewhat artificial separation for Model, Collection and View classes providing a single unified “primitive” (the “sqimitive”). This way Views can use options (opt – attributes with on-change notifications, change_OPT), Underscore.js helpers (util), object nesting (chld – just like Collections), jQuery/Zepto bindings (
this.el
, jQuery.elEvents) and every other feature, shared by all, not imposed on any. - Greatly expands on the idea of events (evt), making inheritance as sensible as possible given JavaScript’s idea of OOP and adding automatic event management for connected objects (e.g. elements of a collection – _childEvents).
- Removes server-side communication layer (bb:Model-sync) while keeping routines to smart-update object states (assignChildren(), assignResp()). Instead of overriding parts of the library (as it’s often done with Backbone) you implement them yourself just once given your unique project requirements.
While different, Sqimitive will appear very familiar to Backbone users and being paradigm-free, it will even let you work the way you used with Backbone or another framework – in aspects you wish. You can create your own Model, Collection and View classes to follow MVC, use traditional __super__
-style inheritance, write your specific sync()
routines and so on.
This section highlights most notable gaps present in Backbone and other frameworks. Strictly subjective!
OOP that makes sense
In Backbone and other JavaScript frameworks inheritance (overriding of parent properties and methods) is painful.
With properties, if it’s a complex object that you would rather extend (append to) rather than replace entirely – as it is the case with bb:Model-attributes, bb:Model-defaults, bb:View-events (DOM bindings) and others – you have to add them in the constructor because if you define such a property in the child class you will entirely overwrite the inherited value. Moreover, if at some point the base class that did not have that property decided to declare it – all its subclasses overwrite it without notice… Unless you remember to update them, which is an unnecessary cognitive burden.
With methods, you have to hardcode reference to the base class – that’s not to mention the entire construct which is already ugly: My.Class.__super__.baseMethod.apply(this, arguments)
. So if you later rename the base class or move this subclass under another parent, or rename the method, or just make a typo – the entire inheritance chain for this method is broken. You may or may not get an error, too.
This is the price for trying to push classic OOP into JavaScript prototype model.
Sqimitive solves both problems by using _mergeProps for listing which properties should be merged together on extension. For methods, there is a fantastic event-driven inheritance (evt) that makes JavaScript OO coding a breeze. Any method can be turned into an event without altering the base class, it can be temporary (once()) and works for “traditional” events too, like change notifications.
At the same time, the __super__
way of doing this is still available if you are feeling a bit masochistic.
Below is just one simple example. Look for more details at the mentioned sections.
var MyBaseClass = Sqimitive.jQuery.extend({
complexProp: {
base: 123,
},
init: function () {
alert('I init()!')
},
})
// _mergeProps is a static property.
MyBaseClass._mergeProps.push('complexProp')
var MySubclass = MyBaseClass.extend({
complexProp: {
sub: 'foo',
},
events: {
init: function () {
alert('Now I init() too!')
},
},
})
var obj = new MySubclass
// 1. alert: I init()!
// 2. alert: Now I init() too!
alert(obj.complexProp.base)
// alert: 123
alert(obj.complexProp.sub)
// alert: foo
Built-in child nesting
Backbone offers no nested views out of the box. There are plugins such as Babysitter (from Marionette.js, https://github.com/marionettejs/backbone.babysitter) but they don’t provide a complete solution.
In Sqimitive, nested views are native. When used, they are automatically managed: elements and event listeners removed (autoOff()) when the corresponding view is removed, DOM events rebound (jQuery.elEvents) and root element reinserted (jQuery.attach()) when root element is moved (e.g. on render()), it is possible to filter (util) nested views using Underscore methods (just like filtering contents of bb:Collections in Backbone), receive notifications about newly nested/unnested/changed views, maintain sort order (Ordered, _repos) and so on.
No more need for delegateEvents()
, off(null, null, this)
or return this
(when overriding render()
thanks to evtpf).
See more details and examples in the Views overview section (vw).
Change-driven behaviour
In Backbone and other frameworks, you have to track changes to your data and view states. Backbone eases this task by providing standard events like change:attr
event for Models. However, this approach must be brought further to let us take full advantage of it.
In Sqimitive, you get a special object property – Base._opt (“options”) – that is a pool of trackable states, similar to Backbone’s bb:Model-attributes. Items in this pool are read with get() and written with set(). When a new and different value is written a bunch of events occurs; within their listeners you can cancel or normalize the change (normalize_OPT()) or perform an action (change_OPT() and change()). Parent sqimitives can optionally forward their children events to themselves (_childEvents).
This can be used to create event observation spots too. Suppose that you have an object that’s loading some data from the server. Since it’s asynchronous you don’t know when (or if) it will finish. Usually you would either create a callback property and call it or use a custom event (like jQuery and Backbone do) and trigger it once loading is done. In Sqimitive, you simply introduce some _opt.loading
, set it to false
initially and once done – change to true
. Observers listen to the change_loading
event having the full set of event handling routines at their disposal (on, once, fuse, off, autoOff, logEvents, etc.) instead of introducing isLoading()
, abortLoading()
, onLoading()
, etc.
Thanks to the new option, this starts working the other way too: an external object can set('loading', true)
to trigger data refresh on the object. Better still – the loading object itself can set this option to trigger its own loading routine – naturally, avoiding code duplication.
Moreover, since options are not accessed directly you can always listen for their access with get
or add custom behaviour with set
, disregarding the fact they are originally methods (get(), set()). Lack of direct access to opt’ions improves stability of the code base.
Given this mechanism you can significantly lower your render()
and custom event rates while being much more aware of what has triggered the change at the same time, performing incremental (lightweight) updates. And since Models and Views are the same thing in Sqimitive these principles work universally for any kind of object you might have.
var MyView = Sqimitive.jQuery.extend({
_opt: {
loading: false,
people: [], // array of names.
},
// We will keep jqXHR object here to be able to abort the request.
_loadingXHR: null,
events: {
init: function () {
// Wait 300 msec and then start fetching server data.
_.delay(_.bind(this.set, this, 'loading', true), 300)
},
// Loading was either cancelled or started.
change_loading: function (value) {
// Update class name of our DOM element.
this.el.toggleClass('loading', value)
// Abort old request, if any.
this._loadingXHR && this._loadingXHR.abort()
if (value) {
// Fetch the data, pass to _load().
this._loadingXHR = $.getJSON('api/get/some', _.bind(this._load, this))
}
},
// _opt.people changed - update names.
change_people: '_updatePeople',
render: function () {
// Retrieve all _opt to pass them to the template.
var vars = this.get()
// Overwrite with new HTML.
this.el.html(_.template($('#MyViewTemplate').text(), vars))
// Add peoples' names.
this._updatePeople()
},
},
_updatePeople: function () {
var el = this.$('.people').empty()
_.each(this.get('people'), function (name) {
$('<em>').text(name).appendTo(el)
})
},
_load: function (resp) {
// Do something with resp, then finally:
this.set('loading', false)
},
})
Events do occur on events – no cheating
If you have used Backbone’s add
, remove
and change
events then you probably know that they do not occur… always. For example, if you reset()
a Collection it won’t fire add
for new Models, remove
for gone or change
for existing Models that were updated.
This smells like an optimization – after all, firing hundreds of events when resetting a huge Collection might be slow. However, such cases are very rare and yet the existence of reset()
makes it problematic to track the updates because often you will end up with your own routine bound to reset
as an event that will figure the difference between old Collection contents and new one.
This also happens with attribute normalization on Models that for some reason doesn’t occur when assigning an API response.
Sqimitive fires events when things change, period. In case you do have a performance-heavy object you can always implement your own, less “noisy” and more optimized update routines. However, 90% of the time you will rely on nest, unnested, change and others to keep you notified.
Cloned instance properties
In JavaScript, when you create a prototype with properties having non-scalar values (like objects or arrays) what you actually create are shared instance properties. If at run-time you modify such a property (but not reassign it) this operation will affect all objects where that property was defined, unless its value was overwritten with a new object.
Backbone inherits this behaviour which most of the time leads to very confusing results (from the human’s perspective). Consider this snippet (http://jsfiddle.net/Proger/vwqk67h8/):
var MyView = Backbone.View.extend({foo: []})
var first = new MyView
var second = new MyView
first.foo.push(123)
alert(second.foo[0])
// alert: 123
The only way around is to assign such properties in the constructor, which you should first override using the crazy My.Class.__super__.baseMethod.apply(this, arguments)
construct. This gives your code -50 points in the ability to save kittens on this planet!
Sqimitive eliminates this problem by automatically deep cloning (deepClone) all complex values upon new object instantiation. Of course, sometimes this is not desired – for this you can always assign them in the constructor just like before or list them in _shareProps to prevent automatic cloning.
Backbonization
The following tricks can be used to make Sqimitive’s API appear more like Backbone’s. It will not make it 100% identical so don’t use it in production as is – it’s intended to give you some pointers.
General
In Backbone, _cid is named bb:Model-cid, jQuery.el is bb:View-$el with the bb:View-el counterpart (native DOM node), fire() is named bb:Events-trigger(), bb:Events-listenTo() and bb:Events-stopListening() are autoOff() flavours, bb:Model-toJSON() is basically get(), bb:Model-attributes is the same as Base._opt, bb:Model-defaults are declaration-time _opt
.
var BackboneBase = Sqimitive.Base.extend('BackboneBase', {
cid: null, // alias to _cid; do not write to.
events: {
'-init': function () {
this.cid = this._cid
},
init: function () {
this._childClass = this._childClass || this.model // used below.
},
},
trigger: function (event, arg_1) {
return this.fire(event, _.rest(arguments))
},
listenTo: function (sqim, event, func) {
this.autoOff(sqim, _.object([[event, func]]))
return this
},
stopListening: function (sqim, event, func) {
if (!arguments.length) {
return this.autoOff()
} else if (func || (sqim && event)) {
throw new Error('Unsupported stopListening() call.')
} else {
return this.off(event || sqim)
}
},
})
Collection
Collections have bb:Collection-model (a class reference) which simply specifies _childClass, bb:Collection-reset() is alike to assignChildren (Collection
’s bb:Collection-set() is entirely different though), bb:Collection-push(), bb:Collection-pop(), bb:Collection-shift(), bb:Collection-unshift() are shortcuts for nest() and the company of util (Sqimitive by default is not Ordered), bb:Model-clone() is another shortcut for non-_owning lists. Models are keyed by their bb:Model-id attribute value (or bb:Model-idAttribute) which are like _defaultKey().
var BackboneCollection = BackboneBase.extend('BackboneCollection', {
model: null, // alias to _childClass; do not write to.
el: null,
idAttribute: 'id',
events: {
'=assignChildren': function (sup, resp, options) {
options = _.extend(options || {}, {eqFunc: this.idAttribute})
return sup(this, [resp, options])
},
},
_defaultKey: function (model) {
return model.get(this.idAttribute)
},
reset: function (models, options) {
if (!arguments.length) {
this.each(this.unlist, this)
} else {
this.assignChildren(models, {
eqFunc: this.idAttribute || 'id',
keepMissing: !('remove' in options) || !options.remove,
})
}
return this
},
push: function (model) {
this.nest(model)
},
unshift: function (model) {
this.nest(model)
},
pop: function () {
var model = this.last()
model && model.remove()
return model
},
shift: function () {
var model = this.first()
model && model.remove()
return model
},
toJSON: function () {
return this.invoke('get')
},
clone: function () {
if (this._owning) {
throw new Error('clone() will clear the original collection.')
}
var copy = new this.constructor(this.get())
this.each(function (child, key) { copy.nest(key, child) })
return copy
},
})
Model
Models’ bb:Model-escape() and bb:Model-has() are shortcuts for get(). bb:Model-id option (“attribute”) is readable/writable as get('id')
/set('id', ...)
and also readable directly as obj.id
(write attempts won’t be caught nor will they change the “real” id
).
var BackboneModel = BackboneBase.extend('BackboneModel', {
attributes: {}, // alias to _opt; do not write to.
defaults: {}, // alias to declaration-time _opt; do not write to.
id: null, // alias to get('id'); do not write to.
_opt: {
id: null,
},
events: {
'-init': function () {
this.defaults = Sqimitive.Core.deepClone(this._opt)
},
init: function () {
this.attributes = this._opt
},
change_id: function (id) { this.id = id },
},
toJSON: function () {
return this.get()
},
escape: function (opt) {
return _.escape(this.get(opt))
},
has: function (opt) {
return this.get(opt) != null // undefined or null.
},
})
View
Views have bb:View-setElement(), bb:View-delegateEvents() and bb:View-undelegateEvents() that are similar to jQuery.attach().
var BackboneView = BackboneBase.extend('BackboneView', {
el: null, // el must remain a wrapped jQuery node in Sqimitive.
$el: null, // alias to el; do not write to.
events: {
init: function () {
this.$el = this.el
},
},
setElement: function (el) {
this.undelegateEvents() // unnecessary since attach() does this already.
this.el = this.$el = el
return this.attach() // only binds events if el has no parent.
},
delegateEvents: function (events) {
this.elEvents = events
return this.attach() // only binds events if el has no parent.
},
undelegateEvents: function () {
this.el.off('.sqim-' + this._cid)
},
})
Babysitting Models
One of the most common problems when developing a complex client-side app is keeping track of multiple Models or of a Collection connected to a particular View. When a new Model appears you need to create and display a new nested View; when it’s removed – its View should go away; when Model attributes change the View should be updated (and this case is often but not always handled by that Model’s specific View).
On top of that, when parent View acquires another Collection it should properly detach itself from the previously assigned Collection, attach to the new object and repopulate itself.
Things get even more complex with asynchronous operations – sometimes the user gets ahead of his network packets and you don’t want the user interface to tangle up.
Sqimitive addresses all of these challenges:
- Attach/detach event handlers to the related Collection with autoOff() or
off(collection)
(off). - Keep that Collection as an option (Base._opt) so once it changes you receive
change_collection(newCol, oldCol)
event (change_OPT). - Make sure only proper Collections are assigned by checking the value-to-be-set in
normalize_collection(newCol)
(normalize_OPT). - Listen to new Models with on on Base.nestEx (
on('+nestEx')
) and to gone Models on unnested. - Listen to changed Models via _childEvents: set it to
['change']
and listen toon('.change')
. - Automatically manage nested Views’ connections to DOM with
attachPath
(jQuery._opt) and jQuery.attach(). Quite often there’s no need for render() at all.
Sample code below demonstrates this in practice. It’s a good idea to make a class handling this basic logic and reuse it throughout your project.
var ParentView = Sqimitive.jQuery.extend({
_childClass: ChildView,
_opt: {
collection: null, // Collection.Foo.
},
normalize_collection: function (newCol) {
if (!(newCol instanceof Collection.Foo)) {
throw new Error('Bad collection type.')
} else {
return newCol
}
},
change_collection: function (newCol, oldCol) {
// Unbind self from the old collection, if any.
oldCol && oldCol.off(this)
// Clear existing nested Views, if any (this form only works if _owning).
this.invoke('remove')
// Set up the link to the new collection.
if (newCol) {
this.autoOff(newCol, {
nestEx: function (options) {
options.changed && this._modelAdded(options.child)
},
unnested: '_modelRemoved',
// Requires that the collection declares _childEvents: ['change'].
'.change': '_modelChanged',
})
// Populate with the existing models.
newCol.each(this._modelAdded, this)
}
},
_modelAdded: function (model) {
// This is the place where a new View gets created, nested and linked to
// the model. First argument to nest() is view's parent key by which it
// can be retrieved later. Model's ID is usually unique so use it if you
// have one. If this key already existed then that View will be removed
// and replaced with the new View.
var view = this.nest(model.get('id'), new this._childClass({
model: model,
attachPath: '.models',
}))
// Append view.el to this.el.find('.models') and bind its DOM event
// listeners. This won't render() the View but it might listen to
// 'attach' and call render() automatically.
view.attach()
// ...If it doesn't auto-render - no big deal:
view.render()
},
_modelRemoved: function (model) {
var view = this.nested(model.get('id'))
if (view) {
// Removes view's DOM element from this.el and unnests view from this
// children. This essentially unbinds view's DOM listeners and its
// Sqimitive listeners on the parent (this).
view.remove()
} else {
console.warn('Removed a Model with no nested View.')
}
},
_modelChanged: function (model) {
// Update something when model's options change...
},
})
The above code can be used like this:
var col = new Collection.Foo
col.nest(new Model.Foo({id: 1}))
new ParentView({collection: col, attachPath: 'body'})
// Gets created with one nested ChildView (existing Model in col).
// ParentView's el is appended to <body>.
col.nest(new Model.Foo({id: 2}))
// New ChildView nested. Now col.length and ParentView's length are 2.
col.nested(2).remove()
// Removed the just nested View after retrieving it by its key ("id" here).
col.nested(1).set('smth', 'foo')
// The '.change' event got fired on ParentView.