The anatomy of a vanilla JavaScript plugin
For those of you who are in the process of ditching jQuery, I thought it might be helpful to talk through how I structure my native JavaScript plugins.
The Template
Here’s the template I start all of my projects from. We’re going to walk through it step-by-step.
(function (root, factory) {
if ( typeof define === 'function' && define.amd ) {
define(['buoy'], factory(root));
} else if ( typeof exports === 'object' ) {
module.exports = factory(require('buoy'));
} else {
root.myPlugin = factory(root, root.buoy);
}
})(typeof global !== "undefined" ? global : this.window || this.global, function (root) {
'use strict';
//
// Variables
//
var myPlugin = {}; // Object for public APIs
var supports = !!document.querySelector && !!root.addEventListener; // Feature test
var settings; // Placeholder variables
// Default settings
var defaults = {
someVar: 123,
initClass: 'js-myplugin',
callbackBefore: function () {},
callbackAfter: function () {}
};
//
// Methods
//
// @todo add plugin methods here
/**
* Handle events
* @private
*/
var eventHandler = function (event) {
// @todo Do something on event
};
/**
* Destroy the current initialization.
* @public
*/
myPlugin.destroy = function () {
// If plugin isn't already initialized, stop
if ( !settings ) return;
// Remove init class for conditional CSS
document.documentElement.classList.remove( settings.initClass );
// @todo Undo any other init functions...
// Remove event listeners
document.removeEventListener('click', eventHandler, false);
// Reset variables
settings = null;
};
/**
* Initialize Plugin
* @public
* @param {Object} options User settings
*/
myPlugin.init = function ( options ) {
// feature test
if ( !supports ) return;
// Destroy any existing initializations
myPlugin.destroy();
// Merge user options with defaults
settings = buoy.extend( defaults, options || {} );
// Add class to HTML element to activate conditional CSS
document.documentElement.classList.add( settings.initClass );
// @todo Do stuff...
// Listen for click events
document.addEventListener('click', eventHandler, false);
};
//
// Public APIs
//
return myPlugin;
});
Dependencies
I include two addtional files with most of my projects. The classList.js polyfill extends classList support back to IE8 (natively, it’s IE10+). Buoy is a tiny collection of helper methods taht I use in most of my scripts.
UMD Wrapper
I use a Universal Module Definition (UMD) wrapper for all of my plugins. This wrapper means that my scripts are compatibile with both AMD and CommonJS, and also work as a traditional module pattern.
It also creates scope around the plugin, preventing variables and functions from being added to the global scope or being overridden by similarly named variables in other scripts.
(function (root, factory) {
if ( typeof define === 'function' && define.amd ) {
define(['buoy'], factory(root));
} else if ( typeof exports === 'object' ) {
module.exports = factory(require('buoy'));
} else {
root.myPlugin = factory(root, root.buoy);
}
})(typeof global !== 'undefined' ? global : this.window || this.global, function (root) {
// Plugin stuff...
});
This sets up the namespacing for your plugin. You should change myPlugin
to the name of your plugin.
Any references to buoy
pass in my helper library as a dependency. In a browser, root
is the window
. This line addresses a Browserify bug that doesn’t pass in root
as window
like it’s supposed to.
typeof global !== 'undefined' ? global : this.window
use strict
use strict
tells the browser (and JS linting tools) to be more strict about the errors that they throw. This sounds like a bad thing, but if forces you to write better, more cross-compatibile code. Always use it.
Variables
At the top of my plugin, I setup the variables I’m going to be using throughout. This keeps everything neatly organized and in one place, and makes it easier to access variables without having to specifically pass them in to methods later.
//
// Variables
//
var myPlugin = {}; // Object for public APIs
var supports = !!document.querySelector && !!root.addEventListener; // Feature test
var settings; // Placeholder variables
// Default settings
var defaults = {
someVar: 123,
initClass: 'js-myplugin',
callbackBefore: function () {},
callbackAfter: function () {}
};
myPlugin
should be changed to the name of your plugin. Any public methods (ones that can be accessed and used outside of the module wrapper) are added to this object, which is returned at the end of the script. No variables or functions can be accessed or used outside of your plugin unless they’re explicitly added to the myPlugin
object.
For example, myPlugin.init()
will be used to initialize the plugin, and is accessible from other scripts, where as the supports
variable cannot be.
Event Handler
I pass any event listeners-clicks, scrolls, window resizing-through my eventHandler
method.
/**
* Handle events
* @private
*/
var eventHandler = function (event) {
// @todo Do something on event
};
You can put all sorts of login in here. For example, I like to put my click event listeners on the document
element, and then check to see if the element that was clicked is one of the ones I care about.
var eventHandler = function (event) {
var toggle = buoy.getClosest(event.target, '[data-example]');
if ( toggle ) {
// Prevent default click event
if ( toggle.tagName.toLowerCase() === 'a') {
event.preventDefault();
}
// Run your methods
myPlugin.someMethod();
}
};
If you want can pass all event types into a single handler, and use some logic to determine the course of action based on the event type.
var eventHandler = function (event) {
if ( event.type === 'scroll' ) {
myPlugin.scrollMethod();
}
if ( event.type === 'click' ) {
myPlugin.clickMethod();
}
};
Destroy Method
I like to provide a way to destroy the current initialization of a plugin. This is useful if you need to reinitialize for some reason, or if another script simply needs to halt whatever you’ve got going. As always, change myPlugin
to the name of your plugin.
/**
* Destroy the current initialization.
* @public
*/
myPlugin.destroy = function () {
// If plugin isn't already initialized, stop
if ( !settings ) return;
// Remove init class for conditional CSS
document.documentElement.classList.remove( settings.initClass );
// @todo Undo any other init functions...
// Remove event listeners
document.removeEventListener('click', eventHandler, false);
// Reset variables
settings = null;
};
Initialize
While I sometimes write scripts that run on page load, I generally prefer a deliberate initialization. This allows developers to pass in their own settings that can override plugin defaults. It also lets developers include the script on every page as part of a concatenate file without actually running it on every page.
/**
* Initialize Plugin
* @public
* @param {Object} options User settings
*/
myPlugin.init = function ( options ) {
// feature test
if ( !supports ) return;
// Destroy any existing initializations
myPlugin.destroy();
// Merge user options with defaults
settings = buoy.extend( defaults, options || {} );
// Add class to HTML element to activate conditional CSS
document.documentElement.classList.add( settings.initClass );
// @todo Do stuff...
// Listen for click events
document.addEventListener('click', eventHandler, false);
};
First, I run a check to make sure the required web and JavaScript APIs are supported. In my case, document.querySelector
and window.addEventListener
are the big ones. These are defined in the supports
variable at the beginning of the script.
if ( !supports ) return;
Then, I destroy any existing initializations of the script to avoid conflicts or duplicate event listeners.
myPlugin.destroy();
Next, I merge any user settings with the defaults using the extend
method in Buoy.
settings = buoy.extend( defaults, options || {} );
A user would pass settings in like so:
myPlugin.init({
someVar: 456,
initClass: 'js-changeme',
})
I’m a huge advocate of progressive enhancement, and I wait until my JavaScript plugin is initialized before using CSS to hide any content. As a result, I add a class to the html
element that I can hook onto with my CSS after the script initializes.
document.documentElement.classList.add( settings.initClass );
Lastly, I create my event listeners.
document.addEventListener('click', eventHandler, false);
Any methods that should run as soon as the plugin initializes should also be called in myPlugin.init()
.
Return your public methods
The last thing in my plugins is a return with the myPlugin
object. This let’s developers run any of the public methods in the plugin by prefixing them with myPlugin.
.
return myPlugin;
An example
Let’s look at a simple example to see how this all works together.
I want to create a plugin called clickMe.js
that adds a class to a link when the link is clicked. The class will vary from link to link, and not all links will trigger this behavior. We’re going to use the [data-click-me]
data attribute to identify links that should trigger the class-adding behavior. We’ll also use this attribute to pass in the class that should be added.
When the browser is resized, I want to print a message in the console log. It will be same message every time. Here’s how I would write this plugin.
(function (root, factory) {
if ( typeof define === 'function' && define.amd ) {
define(['buoy'], factory(root));
} else if ( typeof exports === 'object' ) {
module.exports = factory(require('buoy'));
} else {
root.clickMe = factory(root, root.buoy);
}
})(typeof global !== 'undefined' ? global : this.window || this.global, function (root) {
'use strict';
//
// Variables
//
var clickMe = {}; // Object for public APIs
var supports = !!document.querySelector && !!root.addEventListener; // Feature test
var settings; // Placeholder variables
// Default settings
var defaults = {
resizeLog: 'The window was resized!',
callbackBefore: function () {},
callbackAfter: function () {}
};
//
// Methods
//
/**
* Add a class to a link when it's clicked
* @private
* @param {Event} event The click event
*/
var addClass = function ( event ) {
// Get the thing that was clicked
var toggle = event.target;
// Check if the thing that was clicked has the [data-click-me] attribute
if ( toggle && toggle.hasAttribute( 'data-click-me' ) ) {
// Prevent default click event
if ( toggle.tagName.toLowerCase() === 'a') {
event.preventDefault();
}
// Set the [data-click-me] value as a class on the link
toggle.classList.add( toggle.getAttribute( 'data-click-me' ) );
}
};
/**
* Handle events
* @private
*/
var eventHandler = function (event) {
// Callback before the event handler runs
settings.callbackBefore;
// On click
if ( event.type === 'click' ) {
addClass( event );
}
// On resize
if ( event.type === 'resize' ) {
console.log( settings.resizeLog );
}
// Callback after the event handler runs
settings.callbackAfter;
};
/**
* Destroy the current initialization.
* @public
*/
clickMe.destroy = function () {
// If plugin isn't already initialized, stop
if ( !settings ) return;
// Remove all added classes
var links = document.querySelectorAll( '[data-click-me]' );
for ( var i = 0, len = links.length; i < len; i++ ) {
links[i].classList.remove( links[i].getAttribute( 'data-click-me' ) );
}
// Remove event listeners
document.removeEventListener('click', eventHandler, false);
window.removeEventListener('resize', eventHandler, false);
// Reset variables
settings = null;
};
/**
* Initialize Plugin
* @public
* @param {Object} options User settings
*/
clickMe.init = function ( options ) {
// feature test
if ( !supports ) return;
// Destroy any existing initializations
clickMe.destroy();
// Merge user options with defaults
settings = buoy.extend( defaults, options || {} );
// Listen for click events
document.addEventListener('click', eventHandler, false);
window.addEventListener('resize', eventHandler, false);
};
//
// Public APIs
//
return clickMe;
});