Skip to main content Accessibility Feedback

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;

});

🚀 I just relaunched my Vanilla JS Pocket Guides with new code examples and real projects to help tie everything you’ll learn together. Check it out.

Have any questions or comments about this post? Email me at chris@gomakethings.com or contact me on Twitter at @ChrisFerdinandi.

Get Daily Developer Tips