Let's Make a Framework: Plugins Part 2
Welcome to part 51 of Let’s Make a Framework, the ongoing series about building a JavaScript framework.
If you haven’t been following along, these articles are tagged with lmaf. The project we’re creating is called Turing. Documentation is available at turingjs.com.
Implementing Plugins
For the basic jQuery-style plugin support we need to be able to insert a function so it can access Turing’s chained API. Remember that this is all DOM-oriented, so we can use domChain. Here’s a quick example:
turing.domChain.turnRed = function() {
this[0].style.backgroundColor = '#ff0000';
};
turing('#element').turnRed();
This would actually work given the correct markup. The turnRed method should work anywhere in a chain off turing(), just like jQuery!
A Public API
I don’t believe relying on domChain is a good idea — for a start it’s only named that way to make it clear for the tutorial series. It may change in the future. I’d prefer a plugin API that supports some metadata, like we looked at last week. This would enable us to do things like reflect on plugins, perhaps facilitating a lazy-loading dependency system.
So how about something like this?
turing.plugins.register('turnRed', {
name: 'Turn Things Red',
version: '1.0.0',
description: 'Turns the background red',
author: 'Alex Young <alex@example.com>',
licenses: [ { type: 'MIT' } ],
turnRed: function() {
this[0].style.backgroundColor = '#ff0000';
return this;
}
});
Returning this will allow subsequent chained calls, but this might not always be desired so it isn’t enforced.
Testing the Plugin API
Before getting into a mess with code, let’s write some plugin API tests. We know we need a register function, so given a function called registerExamplePlugin that creates a simple test plugin, this should pass:
exports.testPlugins = {
'test plugin registration and removal': function() {
registerExamplePlugin();
assert.ok(turing('#example').turnRed());
}
};
In this example, assert.ok should pass because I intend to return this from the example plugin.
What about removing plugins? Let’s test for that too:
exports.testPlugins = {
'test plugin registration and removal': function() {
registerExamplePlugin();
assert.ok(turing('#example').turnRed());
turing.plugins.remove('turnRed');
assert.ok(!turing.plugins.hasOwnProperty('turnRed'));
}
};
How about namespace collisions and removal of non-existent plugins? It would be nice if there were some exceptions for these, plugins.AlreadyRegistered and plugins.NotFound would make sense:
exports.testPlugins = {
'test plugin registration and removal': function() {
registerExamplePlugin();
assert.ok(turing('#example').turnRed());
turing.plugins.remove('turnRed');
assert.ok(!turing.plugins.hasOwnProperty('turnRed'));
},
'test AlreadyRegistered': function() {
registerExamplePlugin();
assert.throws(function() {
registerExamplePlugin();
}, turing.plugins.AlreadyRegistered);
},
'test removing a non-existent plugin': function() {
assert.throws(function() {
turing.plugins.remove('turnBlue');
}, turing.plugins.NotFound);
}
};
The Implementation
With full comments, this is what I came up with to make the tests pass:
/*!
* Turing Plugins
* Copyright (C) 2011 Alex R. Young
* MIT Licensed
*/
/**
* The Turing plugin module.
*/
(function() {
var plugins = {};
plugins.registered = {};
plugins.AlreadyRegistered = Error;
plugins.NotFound = Error;
/**
* Registers a plugin, making it available for
* chained DOM calls.
*
* Throws turing.plugins.AlreadyRegistered if a
* plugin with the same name has been registered.
*
* @param {String} The name of your plugin method
* @param {Object} Your plugin
*/
plugins.register = function(methodName, metadata) {
if (plugins.registered[methodName]) {
throw new plugins.AlreadyRegistered('Already registered a plugin called: ' + methodName);
}
plugins.registered[methodName] = metadata;
turing.domChain[methodName] = metadata[methodName];
};
/**
* Removes a plugin. Throws turing.plugins.NotFound if
* the plugin could not be found.
*
* @param {String} The name of the plugin
*/
plugins.remove = function(methodName) {
if (!plugins.registered.hasOwnProperty(methodName)) {
throw new plugins.NotFound('Plugin not found: ' + methodName);
} else {
delete plugins.registered[methodName]
delete turing.domChain[methodName];
}
};
turing.plugins = plugins;
})();
This implementation doesn’t force too much structure on people — you can build plugins with whatever tools you want, as long as a single function can invoke it. This was inspired by jQuery. What’s slightly different to jQuery is you’re forced to include some machine-readable metadata. I might even force inclusion of properties like licenses and author because it drives me nuts when people email me jQuery plugins to review on DailyJS with little more than a crappy bit of code hanging off jQuery.fn.
Conclusion
The metadata I mocked up last week included properties like engines (for determining if a plugin can be run in a browser or server-side interpreter), and cdn. I’m hinting at a dependency loading system here, which I’d like to go over in a future tutorial.
The commit for this week was d5de1c0.