jQuery, the most popular javascript library out there, is great for DOM abstraction. It allows you to encapsulate functionality into your own plugins, which
is a great way to write reusable code. However, jQuery's rules for writing plugins are very loose, which leads to different plugin development practices - some of which are pretty poor.
With this article I want to provide a simple plugin development pattern that will work in many situations. If the functionality you would like to encapsulate is large and really complex, jQuery plugins are probably not what you should use in the first place.
You'd rather use something like BackboneJS or jQuery.Controller in this case.
If you can't or don't want to use Backbone, you might still get away with my solution ...
Starting off
;(function($, doc, win) {
"use strict";
// plugin code will come here
})(jQuery, document, window);
The semi-colon before the function invocation keeps the plugin from breaking if our plugin is concatenated with other scripts that are not closed properly.
"use strict"; puts our code into strict mode, which catches some common coding problems by throwing exceptions, prevents/throws errors when relatively "unsafe" actions are taken and disables Javascript features that are confusing or poorly thought out.
To read about this in detail, please check ECMAScript 5 Strict Mode, JSON, and More by John Resig.
Wrapping the jQuery object into the dollar sign via a closure avoids conflicts with other libraries that also use the dollar sign as an abbreviation.
window and document are passed through as local variables rather than as globals, because this speeds up the resolution process and can be more efficiently minified.
Invoking our plugin
;(function($, doc, win) {
"use strict";
function Widget(el, opts) {
this.$el = $(el);
this.opts = opts;
this.init();
}
Widget.prototype.init = function() {
};
$.fn.widget = function(opts) {
return this.each(function() {
new Widget(this, opts);
});
};
})(jQuery, document, window);
$('#mywidget').widget({optionA: 'a', optionB: 'b'});
We invoke our plugin on a jQuery object or jQuery set by simply calling our widget() method on it and pass it some options.
Never forget about "return this.each(function() { ... })" in order to not break the chain-ability of jQuery objects.
The main functionality of the plugin is encapsulated into a separate Widget class, which we instantiate for each member in our jQuery set. Now all functionality is encapsulated in these wrapper objects.
The constructor is designed to just keep track of the passed options and the DOM element that the widget was initialized on.
You could also keep track of more sub-elements here to avoid having to always .find() them (think of performance) as you need them:
;(function($, doc, win) {
"use strict";
function Widget(el, opts) {
this.$el = $(el);
this.opts = opts;
this.$header = this.$el.find('.header');
this.$body = this.$el.find('.body');
this.init();
}
// ...
})(jQuery, document, window);
Parsing options
When we invoked the plugin we passed it some options. Often you need default options that you want to extend. This is how we bring the two together in our object's init() method:
;(function($, doc, win) {
"use strict";
function Widget(el, opts) {
this.$el = $(el);
this.defaults = {
optionA: 'someOption',
optionB: 'someOtherOption'
};
var meta = this.$el.data('widget-plugin-opts');
this.opts = $.extend(this.defaults, opts, meta);
// ...
}
// ...
})(jQuery, document, window);
$('#mywidget').widget({optionA: 'a', optionB: 'b'});
I like keeping the default options within the constructor of the wrapper class and not outside of it. This provides the flexibility to just take the whole wrapping class and copy it to somewhere else where you might not even have jQuery available.
If the element has options for us saved within its data attributes, we also want to take them into account. This is handy for when you have plugins that auto-initialize themselves (which we will do later) so that you have no way
to pass options to them via their plugin invocation.
Here is an example:
<div class="widget js-widget" data-widget-plugin-opts="{"optionA":"someCoolOptionString"}">
Optional: Keeping a reference to our wrapper object in the element
It's a good idea to keep a reference to our plugin object on the DOM element, because that helps a lot with debugging using your browser's javascript console later.
I don't always do this because it may just be overkill for the situation at hand. But I want to show you how to do this regardless:
;(function($, doc, win) {
"use strict";
var name = 'js-widget';
function Widget(el, opts) {
this.$el = $(el);
this.defaults = {
optionA: 'someOption',
optionB: 'someOtherOption'
};
// let's use our name variable here as well for our meta options
var meta = this.$el.data(name + '-opts');
this.opts = $.extend(this.defaults, opts, meta);
this.$el.data(name, this);
// ...
}
// ...
})(jQuery, document, window);
$('#mywidget').widget({optionA: 'a', optionB: 'b'});
console.log($('#mywidget').data('js-widget'));
As you can see, we just expose our wrapper object using jQuery's $.data function. Easy.
Binding some events
Let's use our wrapper object's init() function to bind some events and write some real plugin code:
;(function($, doc, win) {
"use strict";
var name = 'js-widget';
function Widget(el, opts) {
this.$el = $(el);
this.defaults = {
optionA: 'someOption',
optionB: 'someOtherOption'
};
var meta = this.$el.data(name + '-opts');
this.opts = $.extend(this.defaults, opts, meta);
this.$el.data(name, this);
this.$header = this.$el.find('.header');
this.$body = this.$el.find('.body');
}
Widget.prototype.init = function() {
var self = this;
this.$header.on('click.' + name, '.title', function(e) {
e.preventDefault();
self.editTitle();
});
this.$header.on('change.' + name, 'select', function(e) {
e.preventDefault();
self.saveTitle();
});
};
Widget.prototype.editTitle = function() {
this.$header.addClass('editing');
};
Widget.prototype.saveTitle = function() {
var val = this.$header.find('.title').val();
// save val to database
this.$header.removeClass('editing');
};
// ...
})(jQuery, document, window);
Notice that we have bound the events via .on() in delegation mode, which means that our .title element doesn't even have to be in the DOM yet when the events are bound.
It's generally good practice to use event delegation, as you do not have to constantly bind/unbind events as elements are added/removed to/from the DOM.
We use our name variable as an event namespace here, which allows easy unbinding later without removing event listeners on the widget elements that were not bound using our plugin.
How to prevent designers from breaking your plugins
Something else that I like doing is attaching different classes to elements depending on if they are meant for css styling or for javascript functionality.
The markup that we could use for the plugin above could be:
<div class="widget">
<div class="header"></div>
<div class="body"></div>
</div>
And then we do:
$(function() {
$('.widget').widget();
});
This is all fine and dandy, but now our designer comes along, changes the classes around, because he is not aware of our new plugin (or worse, he doesn't even care). This would break our client javascript functionality.
So what I often do is this instead:
<div class="widget js-widget">
<div class="header js-header"></div>
<div class="body js-body"></div>
</div>
Then in our plugin change how we find the body and header:
function Widget(el, opts) {
this.$el = $(el);
this.defaults = {
optionA: 'someOption',
optionB: 'someOtherOption'
};
var meta = this.$el.data(name + '-opts');
this.opts = $.extend(this.defaults, opts, meta);
this.$header = this.$el.find('.js-header');
this.$body = this.$el.find('.js-body');
}
And change our invocation:
$(function() {
$('.js-widget').widget();
});
I agree it's a little more markup to write and you might not need to use this. Also, since we manipulate the DOM with jQuery plugins we might depend on specific tag types anyway.
So if the designer changed all div's to be li's instead, it might still break our plugin. If you are in a situation where you have regressions due to frontend engineers and designers not communicating properly, using js- prefixed classes on all important elements might be a step in the right direction.
Notice how this.$header and this.$body are also agnostic to the html tag of the element that they cover.
How to remove our plugin without removing the DOM element
For large applications it's important to allow multiple plugins to operate on the same elements.
For this to work, you need to be able to add and remove plugins on the same element without affecting the other plugins.
Most jQuery plugins expect you to remove the element entirely to teardown the plugin. But what if you want to remove the plugin without removing the element?
We can do this using a destroy function:
Widget.prototype.destroy = function() {
this.$el.off('.' + name);
this.$el.find('*').off('.' + name);
this.$el.removeData(name);
this.$el = null;
};
It takes our local name variable again and removes all events in that namespace. It also removes the reference to the wrapper object from the element.
Now we can easily remove the plugin from the outside:
$('.js-widget').data('js-widget').destroy();
By the way, if you remove the DOM element, jQuery will take care of removing all associated data and events by itself, so there is no need to worry about that case.
How to write self-initializing plugins
If you need to deal with a lot of Ajax requests in your app and then need to bind plugins on the DOM elements that were just loaded, this tip might be pretty useful for you.
What I like doing is using a PubSub implementation to automatically invoke plugins:
$(function() {
var $document = $(document);
$document.trigger('domloaded', $document);
$('.someselector').load('/my/url', function(nodes) {
$document.trigger('ajax_loaded', nodes);
});
});
Now we can allow the plugin to bind itself to all elements that by class definition need to be bound to it:
;(function($, doc, win) {
"use strict";
var name = 'js-widget';
// wrapper object implementation, etc.
$(doc).on('domloaded ajaxloaded', function(nodes) {
var $nodes = $(nodes);
var $elements = $nodes.find('.' + name);
$elements = $elements.add($nodes.filter('.' + name));
$elements.widget();
});
})(jQuery, document, window);
You can also come up with your own very custom events and even namespaces to allow your plugins to talk to each other without having to know about each other.
Advantages of this:
This removes a lot of boilerplate code from our app! Re-initializing plugins after an ajax request without extra codelines? No problem!
We can simply remove functionality from our application by not loading a specific plugin's javascript file.
I can see two obvious disadvantages here, though:
We cannot provide options to the plugin invocation via this. We'd have to rely on options bound using the html5 data property "data-js-widget-opts" (read above).
In my experience this not as often needed as one would think, though.
If you have a very complex app with a lot of plugins and code flying around, this PubSub mechanism might not be the most performant way of doing things.
Think of 20 plugins all doing some .find() and .filter() operation on a large piece of markup that was just loaded via ajax. Ugh. :)
Conclusion
Wrapping usable code in a jQuery plugin is not always easy, but following a few guidelines makes it a much better experience.
The ultimate takeaway here is to always wrap the plugin functionality in a wrapper class, so that the elements in your jQuery set that you bind the plugin to do not interfer with each other.
If your plugin is more complex, you could even use multiple wrapper classes and let their objects talk to each other. Or even cooler, try to move some of the functionality your plugin requires out into another, smaller plugin.
And let the both of them talk to each other via PubSub.
The rest is a couple nice extras that made my live easier.
Here is the full skeleton again that I use when I write new plugins (rename accordingly):
;(function($, doc, win) {
"use strict";
var name = 'js-widget';
function Widget(el, opts) {
this.$el = $(el);
this.$el.data(name, this);
this.defaults = {};
var meta = this.$el.data(name + '-opts');
this.opts = $.extend(this.defaults, opts, meta);
this.init();
}
Widget.prototype.init = function() {
};
Widget.prototype.destroy = function() {
this.$el.off('.' + name);
this.$el.find('*').off('.' + name);
this.$el.removeData(name);
this.$el = null;
};
$.fn.widget = function(opts) {
return this.each(function() {
new Widget(this, opts);
});
};
$(doc).on('dom_loaded ajax_loaded', function(e, nodes) {
var $nodes = $(nodes);
var $elements = $nodes.find('.' + name);
$elements = $elements.add($nodes.filter('.' + name));
$elements.widget();
});
})(jQuery, document, window);
Kind regards,
Tim
@tim_kos