I want to revamp this blog a bit, so I will attach an accordion to the posts: as far as I know/see people using jQuery are not in fond of objects and object oriented programming, so I’ll provide three examples for the very same task:
- a strictly procedural solution in the veins of jQuery (long live traversing)
- a brute force oop one, which shall stand for what not to do with javascript
- a better oop implementation, where each item in the accordion is a self contained object, all managed by a master controller
So far what I’d like to achieve: if we have lots of posts on one page (for example because we’re on a tag page) then they all should collapse in a nicely animated manner; on click each item should fold/unfold, but the effect should not stack (instead of queues, use locks); it would be nice if could make the accordion show only one opened item (eg. the rest shall be closed), but I leave it as a plus. Also, when I say collapse, I don’t want slideUp or fade, I want collapse.
This is nothing hard, but once I had to help out on a project, where the frontend team used jQuery heavily and relied on a set of external plugins. In the end the client came up with ideas that we could not implement into the existing plugins, because they were too rigid; though a terribly unorganized project from the start, what really bugged me, was the quality of the jQuery plugins used – so whatever we do here, it should be flexible enough to accomodate requests, like attach multiple events on the same links, being able to get a list of opened accordion items, close ‘em all, or open ‘em all at once etc. – but still without overdoing things.
I’m not going to explain the DOM structure, it’s the same as here (we have posts, h3 a stands for links/switches and div.post-body for the collapsable content), just use firebug to look around if it’s not clear enough.
function makeAccordion(){
var posts = $("div.post");
posts.find("h3 a").each(function(i, link){
var link = $(link);
link.click(function(){
if (posts[i].locked) return false;
var flip = (posts[i].state = !posts[i].state),
postBody = $(posts[i]).find(".post-body"),
unlock = function(){ posts[i].locked = false; };
if (!posts[i].savedHeight) {
posts[i].savedHeight = postBody.height();
}
posts[i].locked = true;
if (flip) {
postBody.attr("style", "overflow:hidden");
postBody.animate({height:0}, 1000, unlock);
} else {
postBody.animate({height:posts[i].savedHeight}, 1000, unlock);
}
return false;
});
link.click();
});
}
This is a very barebones example, kept as simple as possible – apart from the overflow hidden setting (yes, I’m lazy) it should be fine and DRY. The good part, is that it’s around 30 lines only, but I’m not very satisfied with this 30 lines. Why?
- this approach concentrates on the DOM; this means, we have no real interfaces, not much control over the code and it would be very hard to add more complex features. I might as well use objects (no classes yet), but in the end I would fill the code with this keywords and probably would loose simplicity.
- Right now the click function is an anonymous function and is a “different space”, so I just can’t declare a variable there and store the state (opened/closed). That’s why I reuse jQuery’s returned list of extended objects (posts[i]), but in the end this made the code a bit hard to read.
- This whole approach is a bit like “fire and forget“; it’s not that I can not use simple objects to encapsulate the code, but back then when I had been dealing with that similar page, it seemed to me that while the concept of traversing and chaining was pretty obvious for the team, OOP concepts were not. Right now with the lack of proper interfaces it is pretty much impossible to communicate with the accordion tool.
In the next section I will wrap the whole thing up into one reusable accordion component, though as I said before, this one is going to be an ugly brute force example, not to be followed.
After creating a simple procedural version, we had to realize that it’s neither too nice nor very flexible, though very compact. If we want to create an OOP version which we can properly use for inheritance and whatnot, we must realize, that jQuery gives us no “Class emulation”, all we have, are simple wrapper objects. I don’t say that this is terrible, but I myself like the Prototype/MooTools way much better, so for this project I extended the jQuery core with some of the functions from MooTools.
I won’t explain this process, it’s pretty obvious, but as a result, the jQuery-like traversing and chaining requires some extra attention. Because of the so many anonymous functions we gotta be careful and use bind (I use ebind for events); on the downside we can’t write these functions mindlessly, but gotta put them into methods – while this might sound something bad, eventually it can increase readability a lot.
As an added bonus, I will add an onlyOne variable for making the accordion allow only item to be opened in the same time.
var PostAccordion = new Class({
onlyOne: false,
initialize: function(target, params) {
extend(this, params);
this.items = $("div.post", target);
var links = this.items.find("h3 a"), i;
for (i = 0; i < links.length; i++)
$(links[i]).click(ebind(this.onClick, this, i));
this.closeAll();
},
onClick: function(e, num) {
e.stop();
if (this.isLocked(num)) return;
this[this.isClosed(num) ? "open" : "close"](num);
},
close: function(num) {
if (this.items[num].closed) return;
var cont = this.getCollapsable(num);
this.lock(num);
if (!this.items[num].constrained) {
cont.attr("style", "overflow:hidden");
this.items[num].constrained = true;
}
if (!this.items[num].savedHeight) {
this.items[num].savedHeight = cont.height();
}
cont.animate({height:0}, 1000, bind(this.unlock, this, num));
this.items[num].closed = true;
},
closeAll: function(){
for (var i = 0; i < this.items.length; i++) this.close(i);
},
open: function(num) {
if (!this.items[num].closed) return;
if (this.onlyOne) this.closeAll();
var cont = this.getCollapsable(num);
this.lock(num);
cont.animate({height:this.items[num].savedHeight}, 1000,
bind(this.unlock, this, num));
this.items[num].closed = false;
},
getCollapsable: function(num){
return $(".post-body", this.items[num]);
},
isClosed: function(num){
return this.items[num].closed;
},
isLocked: function(num){
return this.items[num].locked;
},
lock: function(num){
this.items[num].locked = true;
},
unlock: function(num){
this.items[num].locked = false;
}
});
Now, oh my, this is 2.5x bigger than the previous one and it’s not very clear either. Now we have interfaces and can implement something like getAllStates, openAll or closeAll or custom events, yet we can clearly see that the word “num” is everywhere.
If something is repeated too many times, there’s a very good chance that we are not DRY (Don’t Repeat Yourself) here. The main problem with this code is that we have pretty similar buttons (to close/open a container), so we choose to make a controller that can be fed which item we want to control. This is an overlay code, not the representation of the switch itself (the link and its functionality). Basically whenever we call a method inside this object, we have to tell it, on which switch we want to operate.
Is there a better implementation? I hope so: see bellow for a cleaner approach.
This is the last section of the accordion tutorial showing a somewhat better balance between the previous two: this time I decided to have two classes, one for the link(s) (switches) and one for the controlling. Each link has it’s own class representation:
var Accordion = new Class({
onlyOne: true,
initialize: function(target, params){
extend(this, params || {});
this.items = $("div.post", target);
for (var i = 0; i < this.items.length; i++) {
this.items[i].accordItem = new this.AccordItem(this.items[i]);
if (this.onlyOne)
$(this.items[i].accordItem.link).click(bind(this.closeAll, this));
}
this.closeAll();
},
closeAll: function(){
for (var i = 0; i < this.items.length; i++)
this.items[i].accordItem.close();
},
AccordItem: new Class({
closed: false,
locked: false,
speed: 1000,
initialize: function(target, params){
extend(this, params || {});
this.item = target;
this.link = $($("h3 a", this.item)[0]);
this.body = $($(".post-body", this.item)[0]);
this.height = this.body.height();
this.body.attr("style", "overflow:hidden");
this.link.click(ebind(this.onClick, this));
},
onClick: function(e){
e.stop();
this[this.closed ? "open" : "close"]();
},
close: function(){
if (!this.closed) this._openClose(true);
},
open: function(){
if (this.closed) this._openClose(false);
},
_openClose: function(isClose){
if (this.locked) return;
this.locked = true;
this.closed = isClose;
var h = isClose ? 0 : this.height;
this.body.animate({height:h}, this.speed,
bind(function(){ this.locked = false; }, this));
}
})
});
The AccordItem is the link wrapper itself, it can open/close the related container and can also take care of locking, yet it still has an interface we can easily communicate through. See how readable its code is:
- closed: a property showing if the related container is closed or not
- locked: shows if the object is locked, thus it is waiting for an effect to be finished
- speed: constant of the effect’s speed
- onClick: the method that is fired on the click event
- open, close: methods to open/close the container
This AccordItem is used by the wrapping class: it is assigned to each and every link; it also assigns another method, closeAll if needed. The AccordItem should care about himself only, closing other containers/items should be managed by the Accordion class itself.
While this solution is still two times bigger than the original, the code became more readable and easier to extend. Choosing between the two should be the matter of the project and not personal preferences: there are time when the first one is perfectly enough and there are cases, when it is better to be a bit more cautious.
While in these examples I have not used inline comments, always be sure to comment the code; I don’t think, that javadoc-like strict documenting is always neccessary, but small explanations can really help, especially in an environment where different parts of the code is managed by different people/teams.