Thursday, May 3, 2012

Which jQuery plugin design pattern should I use?


I need to build a jQuery plugin that would return a single instance per selector id. The plugin should and will only be used on elements with id (not possible to use selector that matches many elements), so it should be used like this:




$('#element-id').myPlugin(options);



  • I need to be able to have few private methods for the plugin as well as few public methods. I can achieve that but my main issue is that I want to get the very same instance every time I call $('#element-id').myPlugin().

  • And I want to have some code that should be executed only the first time the plugin is initialized for a given ID (construct).

  • The options parameter should be supplied the first time, for the construct, after that I do not want the construct to be executed, so that I can access the plugin just like $('#element-id').myPlugin()

  • The plugin should be able to work with multiple elements (usually up to 2) on the same page (but each and every one of them will need own config, again - they will be initialized by ID, not common class selector for example).

  • The above syntax is just for example - I'm open for any suggestions on how to achieve that pattern



I have quite some OOP experience with other language, but limited knowledge of javascript and I'm really confused on how do it right.



EDIT



To elaborate - this plugin is a GoogleMaps v3 API wrapper (helper) to help me get rid of code duplication as I use google maps on many places, usually with markers. This is the current library (lots of code removed, just most important methods are left to see):




;(function($) {
/**
* csGoogleMapsHelper set function.
* @param options map settings for the google maps helper. Available options are as follows:
* - mapTypeId: constant, http://code.google.com/apis/maps/documentation/javascript/reference.html#MapTypeId
* - mapTypeControlPosition: constant, http://code.google.com/apis/maps/documentation/javascript/reference.html#ControlPosition
* - mapTypeControlStyle: constant, http://code.google.com/apis/maps/documentation/javascript/reference.html#MapTypeControlStyle
* - mapCenterLatitude: decimal, -180 to +180 latitude of the map initial center
* - mapCenterLongitude: decimal, -90 to +90 latitude of the map initial center
* - mapDefaultZoomLevel: integer, map zoom level
*
* - clusterEnabled: bool
* - clusterMaxZoom: integer, beyond this zoom level there will be no clustering
*/
$.fn.csGoogleMapsHelper = function(options) {
var id = $(this).attr('id');
var settings = $.extend(true, $.fn.csGoogleMapsHelper.defaults, options);

$.fn.csGoogleMapsHelper.settings[id] = settings;

var mapOptions = {
mapTypeId: settings.mapTypeId,
center: new google.maps.LatLng(settings.mapCenterLatitude, settings.mapCenterLongitude),
zoom: settings.mapDefaultZoomLevel,
mapTypeControlOptions: {
position: settings.mapTypeControlPosition,
style: settings.mapTypeControlStyle
}
};

$.fn.csGoogleMapsHelper.map[id] = new google.maps.Map(document.getElementById(id), mapOptions);
};

/**
*
*
* @param options settings object for the marker, available settings:
*
* - VenueID: int
* - VenueLatitude: decimal
* - VenueLongitude: decimal
* - VenueMapIconImg: optional, url to icon img
* - VenueMapIconWidth: int, icon img width in pixels
* - VenueMapIconHeight: int, icon img height in pixels
*
* - title: string, marker title
* - draggable: bool
*
*/
$.fn.csGoogleMapsHelper.createMarker = function(id, options, pushToMarkersArray) {
var settings = $.fn.csGoogleMapsHelper.settings[id];

markerOptions = {
map: $.fn.csGoogleMapsHelper.map[id],
position: options.position || new google.maps.LatLng(options.VenueLatitude, options.VenueLongitude),
title: options.title,
VenueID: options.VenueID,
draggable: options.draggable
};

if (options.VenueMapIconImg)
markerOptions.icon = new google.maps.MarkerImage(options.VenueMapIconImg, new google.maps.Size(options.VenueMapIconWidth, options.VenueMapIconHeight));

var marker = new google.maps.Marker(markerOptions);
// lets have the VenueID as marker property
if (!marker.VenueID)
marker.VenueID = null;

google.maps.event.addListener(marker, 'click', function() {
$.fn.csGoogleMapsHelper.loadMarkerInfoWindowContent(id, this);
});

if (pushToMarkersArray) {
// let's collect the markers as array in order to be loop them and set event handlers and other common stuff
$.fn.csGoogleMapsHelper.markers.push(marker);
}

return marker;
};

// this loads the marker info window content with ajax
$.fn.csGoogleMapsHelper.loadMarkerInfoWindowContent = function(id, marker) {
var settings = $.fn.csGoogleMapsHelper.settings[id];
var infoWindowContent = null;

if (!marker.infoWindow) {
$.ajax({
async: false,
type: 'GET',
url: settings.mapMarkersInfoWindowAjaxUrl,
data: { 'VenueID': marker.VenueID },
success: function(data) {
var infoWindowContent = data;
infoWindowOptions = { content: infoWindowContent };
marker.infoWindow = new google.maps.InfoWindow(infoWindowOptions);
}
});
}

// close the existing opened info window on the map (if such)
if ($.fn.csGoogleMapsHelper.infoWindow)
$.fn.csGoogleMapsHelper.infoWindow.close();

if (marker.infoWindow) {
$.fn.csGoogleMapsHelper.infoWindow = marker.infoWindow;
marker.infoWindow.open(marker.map, marker);
}
};

$.fn.csGoogleMapsHelper.finalize = function(id) {
var settings = $.fn.csGoogleMapsHelper.settings[id];
if (settings.clusterEnabled) {
var clusterOptions = {
cluster: true,
maxZoom: settings.clusterMaxZoom
};

$.fn.csGoogleMapsHelper.showClustered(id, clusterOptions);

var venue = $.fn.csGoogleMapsHelper.findMarkerByVenueId(settings.selectedVenueId);
if (venue) {
google.maps.event.trigger(venue, 'click');
}
}

$.fn.csGoogleMapsHelper.setVenueEvents(id);
};

// set the common click event to all the venues
$.fn.csGoogleMapsHelper.setVenueEvents = function(id) {
for (var i in $.fn.csGoogleMapsHelper.markers) {
google.maps.event.addListener($.fn.csGoogleMapsHelper.markers[i], 'click', function(event){
$.fn.csGoogleMapsHelper.setVenueInput(id, this);
});
}
};

// show the clustering (grouping of markers)
$.fn.csGoogleMapsHelper.showClustered = function(id, options) {
// show clustered
var clustered = new MarkerClusterer($.fn.csGoogleMapsHelper.map[id], $.fn.csGoogleMapsHelper.markers, options);
return clustered;
};

$.fn.csGoogleMapsHelper.settings = {};
$.fn.csGoogleMapsHelper.map = {};
$.fn.csGoogleMapsHelper.infoWindow = null;
$.fn.csGoogleMapsHelper.markers = [];
})(jQuery);



It's usage looks like this (not actually exactly like this, because there is a PHP wrapper to automate it with one call, but basically):




$js = "$('#$id').csGoogleMapsHelper($jsOptions);\n";

if ($this->venues !== null) {
foreach ($this->venues as $row) {
$data = GoogleMapsHelper::getVenueMarkerOptionsJs($row);
$js .= "$.fn.csGoogleMapsHelper.createMarker('$id', $data, true);\n";
}
}

$js .= "$.fn.csGoogleMapsHelper.finalize('$id');\n";
echo $js;



The problems of the above implementation are that I don't like to keep a hash-map for "settings" and "maps"



The $id is the DIV element ID where the map is initialized. It's used as a key in the .map and .settings has maps where I hold the settings and GoogleMaps MapObject instance for each initialized such GoogleMaps on the page. The $jsOptions and $data from the PHP code are JSON objects.



Now I need to be able to create a GoogleMapsHelper instance that holds its own settings and GoogleMaps map object so that after I initialize it on certain element (by its ID), I can reuse that instance. But if I initialize it on N elements on the page, each and every of them should have own configuration, map object, etc.



I do not insist that this is implemented as a jQuery plugin! I insist that it's flexible and extendable, because I will be using it in a large project with over dozen currently planned different screens where it will be used so in few months, changing it's usage interface would be a nightmare to refactor on the whole project.



I will add a bounty for this.


Source: Tips4all

6 comments:

  1. When you say "get" the instance via $('#element').myPlugin() I assume you mean something like:

    var instance = $('#element').myPlugin();
    instance.myMethod();


    This might seem to be a good idea at first, but it’s considered bad practice for extending the jQuery prototype, since you break the jQuery instance chain.

    Another handy way to do this is to save the instance in the $.data object, so you just initialize the plugin once, then you can fetch the instance at any time with just the DOM element as a reference, f.ex:

    $('#element').myPlugin();
    $('#element').data('myplugin').myMethod();


    Here is a pattern I use to maintain a class-like structure in JavaScript and jQuery (comments included, hope you can follow):

    (function($) {

    // the constructor
    var MyClass = function( node, options ) {

    // node is the target
    this.node = node;

    // options is the options passed from jQuery
    this.options = $.extend({

    // default options here
    id: 0

    }, options);

    };

    // A singleton for private stuff
    var Private = {

    increaseId: function( val ) {

    // private method, no access to instance
    // use a bridge or bring it as an argument
    this.options.id += val;
    }
    };

    // public methods
    MyClass.prototype = {

    // bring back constructor
    constructor: MyClass,

    // not necessary, just my preference.
    // a simple bridge to the Private singleton
    Private: function( /* fn, arguments */ ) {

    var args = Array.prototype.slice.call( arguments ),
    fn = args.shift();

    if ( typeof Private[ fn ] == 'function' ) {
    Private[ fn ].apply( this, args );
    }
    },

    // public method, access to instance via this
    increaseId: function( val ) {

    alert( this.options.id );

    // call a private method via the bridge
    this.Private( 'increaseId', val );

    alert( this.options.id );

    // return the instance for class chaining
    return this;

    },

    // another public method that adds a class to the node
    applyIdAsClass: function() {

    this.node.className = 'id' + this.options.id;

    return this;

    }
    };


    // the jQuery prototype
    $.fn.myClass = function( options ) {

    // loop though elements and return the jQuery instance
    return this.each( function() {

    // initialize and insert instance into $.data
    $(this).data('myclass', new MyClass( this, options ) );
    });
    };

    }( jQuery ));


    Now, you can do:

    $('div').myClass();


    This will add a new instance for each div found, and save it inside $.data. Now, to retrive a certain instance an apply methods, you can do:

    $('div').eq(1).data('myclass').increaseId(3).applyIdAsClass();


    This is a pattern I have used many times that works great for my needs.

    You can also expose the class so you can use it without the jQuery prototyp by adding window.MyClass = MyClass. This allows the following syntax:

    var instance = new MyClass( document.getElementById('element'), {
    id: 5
    });
    instance.increaseId(5);
    alert( instance.options.id ); // yields 10

    ReplyDelete
  2. Here's an idea...

    (function($){
    var _private = {
    init: function(element, args){
    if(!element.isInitialized) {
    ... initialization code ...
    element.isInitialized = true;
    }
    }
    }

    $.fn.myPlugin(args){
    _private.init(this, args);
    }
    })(jQuery);


    ...and then you can add more private methods. If you want to 'save' more data, you can use the element passed to the init function and save objects to the dom element... If you're using HTML5, you can use data- attributes on the element instead.

    EDIT

    Another thing came to mind. You could use jQuery.UI widgets.

    ReplyDelete
  3. I think what you need to solve your problem is basically a good OO structure to hold both your setting and GoogleMap.

    If you are not tied to jQuery and know OOP pretty well, I would use YUI3 Widget.

    A glance at the Sample Widget Template should give you an idea that the framework provide access to the OOP structure such as:


    It provides Namespace support.
    It support notion of classes and objects
    It supports class extension neatly
    It provides constructor and destructor
    It supports the concept of instance variables
    It provides render and event binding


    In your case:


    You can create your GoogleHelper class which has its own instance variables along with the Google Map object which I think is what you intended.
    You would then start creating the instance of this class with its own settings.
    For each new instance, you will just have to map it with an ID that you could refer it later. By referencing the ID to the GoogleHelper instance that has both the settings and GoogleMap, you don't have to keep two maps (one to hold the setting and one for the GoogleMap) which I happen to agree with you that it is not an ideal situation.


    This is basically goes back to basic OO programming and the right JS framework can empower you to do that. While other OO JS framework can be used as well, I find that YUI3 provide better structure than others for large Javascript project.

    ReplyDelete
  4. I will provide a link to a recent blog post I did about something similar. http://aknosis.com/2011/05/11/jquery-pluginifier-jquery-plugin-instantiator-boilerplate/

    Basically this wrapper (pluginifier I've called it) will allow you to create a seperate JavaScript object that will house everything (public/private methods/options objects etc.) but allow for quick retrieval and cretation with common $('#myThing').myPlugin();

    The source is available on github as well: https://github.com/aknosis/jquery-pluginifier

    Here's a snippet where you would put your code:

    //This should be available somewhere, doesn't have to be here explicitly
    var namespace = {

    //This will hold all of the plugins
    plugins : {}
    };

    //Wrap in a closure to secure $ for jQuery
    (function( $ ){

    //Constructor - This is what is called when we create call new namspace.plugins.pluginNameHere( this , options );
    namespace.plugins.pluginNameHere = function( ele , options ){
    this.$this = $( ele );
    this.options = $.extend( {} , this.defaults , options );
    };

    //These prototype items get assigned to every instance of namespace.plugins.pluginNameHere
    namespace.plugins.pluginNameHere.prototype = {

    //This is the default option all instances get, can be overridden by incoming options argument
    defaults : {
    opt: "tion"
    },

    //private init method - This is called immediately after the constructor
    _init : function(){
    //useful code here
    return this; //This is very important if you want to call into your plugin after the initial setup
    },

    //private method - We filter out method names that start with an underscore this won't work outside
    _aPrivateMethod : function(){
    //Something useful here that is not needed externally
    },

    //public method - This method is available via $("#element").pluginNameHere("aPublicMethod","aParameter");
    aPublicMethod : function(){
    //Something useful here that anyone can call anytime
    }
    };

    //Here we register the plugin - $("#ele").pluginNameHere(); now works as expected
    $.pluginifier( "pluginNameHere" );

    })( jQuery );


    The $.pluginifier code is in a separate file but can be include in the same file as your plugin code as well.

    ReplyDelete
  5. A lot of your requirements are unnecessary. Anyhow here is a rough outline of the design pattern I have adopted for myself - which is essentially direct from the jQuery authoring documentation. If you have any questions, just leave me a comment.

    The pattern described allows the following use:

    var $myElements = $('#myID').myMapPlugin({
    center:{
    lat:174.0,
    lng:-36.0
    }
    });

    $myElements.myMapPlugin('refresh');

    $myElements.myMapPlugin('addMarker', {
    lat:174.1,
    lng:-36.1
    });

    $myElements.myMapPlugin('update', {
    center:{
    lat:175.0,
    lng:-33.0
    }
    });

    $myElements.myMapPlugin('destroy');


    And here is the general pattern - only a few method implemented.

    ;(function($) {
    var privateFunction = function () {
    //do something
    }

    var methods = {
    init : function( options ) {

    var defaults = {
    center: {
    lat: -36.8442,
    lng: 174.7676
    }
    };
    var t = $.extend(true, defaults, options);

    return this.each(function () {
    var $this = $(this),
    data = $this.data('myMapPlugin');

    if ( !data ) {

    var map = new google.maps.Map(this, {
    zoom: 8,
    center: new google.maps.LatLng(t['center'][lat], t['center']['lng']),
    mapTypeId: google.maps.MapTypeId.ROADMAP,
    mapTypeControlOptions:{
    mapTypeIds: [google.maps.MapTypeId.ROADMAP]
    }
    });

    var geocoder = new google.maps.Geocoder();

    var $form = $('form', $this.parent());
    var form = $form.get(0);
    var $search = $('input[data-type=search]', $form);

    $form.submit(function () {
    $this.myMapPlugin('search', $search.val());
    return false;
    });

    google.maps.event.addListener(map, 'idle', function () {
    // do something
    });

    $this.data('myMapPlugin', {
    'target': $this,
    'map': map,
    'form':form,
    'geocoder':geocoder
    });
    }
    });
    },
    resize : function ( ) {
    return this.each(function(){
    var $this = $(this),
    data = $this.data('myMapPlugin');

    google.maps.event.trigger(data.map, 'resize');
    });
    },
    search : function ( searchString ) {
    return this.each(function () {
    // do something with geocoder
    });
    },
    update : function ( content ) {
    // ToDo
    },
    destroy : function ( ) {
    return this.each(function(){

    var $this = $(this),
    data = $this.data('myMapPlugin');

    $(window).unbind('.locationmap');
    data.locationmap.remove();
    $this.removeData('locationmap');
    });
    }
    };


    $.fn.myMapPlugin = function (method) {
    if ( methods[method] ) {
    return methods[ method ].apply( this, Array.prototype.slice.call( arguments, 1 ));
    } else if ( typeof method === 'object' || ! method ) {
    return methods.init.apply( this, arguments );
    } else {
    $.error( 'Method ' + method + ' does not exist on jQuery.myMapPlugin' );
    }
    };
    })(jQuery);


    Note that the code is untested.

    Happy Coding :)

    ReplyDelete
  6. This may be outside the scope of your question, but I really think that you should refactor how you handle the PHP -> JS transition (specifically, your entire last PHP code block).

    I think it's an anti-pattern to generate tons of JS in PHP, which is then run on the client. Instead, you should be returning JSON data to your client, which invokes whatever is needed based off of that data.

    This example is incomplete, but I think it gives you an idea. ALL of your JS should actually be in JS, and the only thing being sent back & forth should be JSON. Generating dynamic JS is not a sane practice IMO.

    <?php
    // static example; in real use, this would be built dynamically
    $data = array(
    $id => array(
    'options' => array(),
    'venues' => array(/* 0..N venues here */),
    )
    );

    echo json_encode($data);
    ?>

    <script>
    xhr.success = function (data) {
    for (var id in data)
    {
    $('#' + id).csGoogleMapsHelper(data[id].options);
    for (var i = 0, len = data[id].venues.length; i < len; i++)
    {
    $.fn.csGoogleMapsHelper.createMarker(id, data[id].venues[i], true);
    }
    $.fn.csGoogleMapsHelper.finalize(id);
    }
    }
    </script>

    ReplyDelete