Saturday, July 17, 2010

Wallflower - Unobtrusive jQuery

Unobtrusive Javascript — the page implementation technique of keeping as much raw Javascript code as possible off the pages, and applying it by implication from external script files — is a generally-accepted best practice and has been extensively written about in every web design blog that exists. I therefore won't expound upon the topic itself other than to say in summary that one generally marks page elements with classes or possibly attributes (especially in HTML5 pages), and then writes Javascript code that "knows" what classes and attributes to look for and what those annotations mean and thus knows what the elements want done to them.

That's all easy enough, but once you get rolling on a site of even modest extent, the variety of page markers starts to grow, as does the code that understands them. When interesting, more involved page behaviors arise out of design progress or updates, affected elements must communicate more details about the behavior to the code. As soon as there are two such interesting behaviors, the need arises to organize and "standardize" the page-to-code communication scheme. (Ideally, it's preferrable not to have to maintain separate unobtrusive Javascript files, instead keeping a central "do smart things to the page" script that can handle any of the "behavioral vocabulary" on the site. It's not mandatory of course, but growing a forest of little page-specific script files reaps its own problems.)

As the number of features grows, so too grows the code. Feature by feature, the code for each behavior is likely to differ more than the markup annotation, so there's even less organization. In my experience, I ended up with a surprisingly large function, hundreds of lines of innocuous little stanzas that, combined, were a monster. That's in an application that comprises less than 100 separate main pages.

Yet another challenge arises on sites that involve dynamic content — content fetched after initial page load time to populate tables, dialogs, status views, or whatever. Quite often, those fragments of content will also require unobtrusive intervention by the Javascript monster. Now that mess has to be wrangled into some sort of re-invocable tool.

To this situation, I now ask that you contemplate the introduction of another developer working on pages. And another one. They've been hired or redirected because some new functionality has to be built out in a hurry. That's not making you feel comfortable, right? What's the source base going to look like when the smoke clears?

On the application site that currently occupies my time, I've cobbled together a mechanism to handle this problem, and it's mostly under control. However, it's got a wild organic nature to it that defies explanation, and I really wouldn't want to create something like that again.

Wallflower

Because I couldn't find anything out there that claimed to deal with this problem, or even anybody else's description of the problem, I decided to write a simple jQuery plugin to act as a focal point. The plugin, called "wallflower" (wallflowers are unobtrusive), doesn't actually do any of the unobtrusive Javascript work for you. What it does provide, however, is a framework for organizing it all.

Wallflower use is based on the identification of "features" — the elements of what I called your site's "behavioral vocabulary". This aspect of the plugin is good for you and good for your users. When every page of your site is a unique little snowflake with its own behavior and its own quirks, you've set yourself up for a maintenance nightmare (again something I know from experience), and you're making your users have to learn the unique ways of each page. With organized unobtrusive Javascript, you'll naturally gravitate towards consistency across your site, because you'll grow a toolkit for page personality that will naturally become familiar to your users too.

A wallflower feature primarily consists of a "handler" function. The handler function is what "knows" what a page element wants done when the element has been made to wave the special little flag corresponding to the feature. The feature must be introduced to wallflower with a registration call before the plugin can know to apply it to page elements. The introduction consists of a simple call to the plugin to tell it the feature's name, how to identify elements that want to be affected, default parameters, and how to find per-element parameters. Most of that can be defaulted, even the handler function itself — wallflower will assume that the handler function is an existing jQuery instance method ($.fn.whatever) if the handler isn't supplied. That's particularly useful with things like the jQuery UI widgets. For example, the following initialization call: $.wallflower('datepicker'); tells wallflower that it should look for elements marked with the class "datepicker", look for parameters embedded in the class and in the default wallflower "data-wf" attribute, and then for each such element invoke the jQuery UI "datepicker" function with those parameters. Features don't have to be jQuery plugins of course; the handlers can be functions written just for wallflower. Another simple example: $.wallflower('timestamp', function() { $(this).text('Loaded at ' + new Date()); }); would pair up with page markup like this: <div id='timestamp-footer' class='timestamp'> </div> to put a timestamp onto the page when wallflower is applied.

Invoking wallflower would generally be done at page "load" or "ready" time. Wallflower, when invoked on a jQuery object, applies the features it's been told about to the selected elements. In other words, $(function() { $('body').wallflower(); }); would treat the entire page. For dynamically-loaded content, one would apply the plugin only to the container into which content had been loaded: $('#dialog-container').load(url, function() { $(this).wallflower(); }); It's possible to selectively deactivate particular features, which might be handy when initializing dynamic content and you'd like to skip the expense of initializing page-global content like the nav or page sidebars: $('#dynamic-table').load(url, function() { $(this).wallflower({'sitenav': false}); }); That would apply every feature except the one named "sitenav".

Details

There are two wallflower APIs: a "global" function on the jQuery object and an ordinary jQuery instance method. The global function is for registration of handlers. The instance method is for applying behavior to the page.

There's a general form for plugin registration: $.wallflower({ 'feature-name': { 'handler': function(params) { ... }, 'selector': 'selector', // defaults to '.feature-name' 'attribute': 'attr name', // defaults to 'data-wf' 'evalAttr': true, // boolean 'depends', ['name','name', ...], // defaults to [] 'defaults': { 'param': value, ... } // defaults to {} }, 'feature-name': { ... }, ... }); With that form, you can register as many features as you like. Specifics of the initialization block are:

handler
function to be called when wallflower is initializing a portion of the DOM. The function is called in the context of one element wrapped in a jQuery instance. The function is passed a parameter object built from the default parameters supplied at registration time, overlaid by parameters found on the element, and then finally overlaid again by parameters supplied to the invocation of wallflower at application time.
selector
the jQuery selector string to be used when searching for page elements to be affected by the feature. By default, the selector is formed by treating the feature name as a class name.
attribute
the name of the element attribute that should be inspected for feature parameters. Wallflower uses the jQuery "metadata" plugin to extract parameters from element class values. By default, it also looks in the "data-wf" attribute, if present, and also with the metadata plugin. That plugin expects what amounts to JSON notation in the class (surrounded by curly braces) or in the attribute. When providing element parameters in that form, the attribute values should look like a JSON object with parameter names and values. In the (probably rare) event of an element requiring the application of two or more features, the parameter JSON can include a preliminary layer of names and values, where the names are feature names and the values are themselves the parameter name/value objects. (See also the "evalAttr" configuration option.)
evalAttr
boolean flag indicating whether the configuration attribute value should be interpreted as a JSON name/value expression, or as a simple value. For some features, using a different attribute as a source for parameter information because the attribute naturally conveys to the feature handling what the element wants to do. An example might be a feature that used the value of an "input" tag's "maxlength" attribute to guide behavior. In that case, the markup should involve a simple number for "maxlength". Setting "evalAttr" to "false" for that feature would result in a parameter being included in the block passed to the handler named "maxlength" and with the value given in the element HTML.
depends
a list of feature names that, upon feature application, be applied before this feature. This mechanism allows for the (probably rare) case of one form of behavior application being required before another makes sense. An example might be the establishment of some sorts of event handlers that a subsequent feature will trigger. Features that claims no dependencies are applied in unspecified order.
defaults
parameters for the feature that should be used in the absence of parameters encoded in the HTML with affected elements, or passed in to wallflower at feature application time.
The following shortcut registration forms are recognized: $.wallflower('feature-name'); // same as: // $.wallflower({ // 'feature-name': { // 'handler': $.fn['feature-name'], // 'selector': '.feature-name' // } // }); $.wallflower('feature-name', function() { ... }); // same as: // $.wallflower({ // 'feature-name': { // 'handler': function() { ... }, // 'selector': '.feature-name' // } // }); $.wallflower('feature-name', { param: value, ... }); // same as: // $.wallflower({ // 'feature-name': { // 'handler': $.fn['feature-name'], // 'selector': '.feature-name', // 'defaults': { param: value, ... } // } // }); $.wallflower('feature-name', { param: value, ... }, function() { ... }); // same as: // $.wallflower({ // 'feature-name': // 'handler': function() { ... }, // 'selector': '.feature-name', // 'defaults': { param: value, ... } // } // ");

Invocation

When calling wallflower to make stuff happen to the page, you use the jQuery instance form: $(whatever).wallflower(parameters); The parameters passed to wallflower, if any, should be an object of a form similar to the generalized registration object: $(whatever).wallflower({ datepicker: { minDate: 1 }, dialog: { modal: true } }); In other words, the parameter object's keys represent feature names, and the values are used to override values from elements and from the defaults from registration.

In addition, features can be selectively disabled by including them by name in the parameter object but setting the value to the constant "false": $(whatever).wallflower({dialog: false}); If you need to work from the other direction, preferring to disable all features and then selectively enable a few, you can do that too: $(whatever).wallflower({ '*': false, // disable everything: 'datepicker': true }); That will ensure that only "datepicker" elements are affected. It would also work to give the "datepicker" entry a block of override parameters.

Markup

Element markup should be easy, but part of the responsibility for that is yours. Whenever possible, features should be registered with selectors that don't require the page code to do anything at all in order to be located and affected by features. That all depends of course on the nature of the feature: there's probably nothing at all intrinsic about an "input" tag that needs to have the date picker hooked onto it.

Parameters can be supplied in two ways (well, really three but the third one's a secret). First, they can be embedded in the "class": <input class='datepicker {minDate: 1}' type='text' name='the-date' > Everything between curly braces in the class will be handed over to every feature. In the unlikely event that an element requires intervention from more than one feature, you can do this: <input class='datepicker zap {datepicker: {minDate: 1}, zap: {zap: "full"}}' type='text' name='the-date' > That will allow each feature to get its own set of parameters.

If you don't like messing up your "class" strings, the next option is to use attributes. HTML5 promises that attributes whose names start with "data" will validate. Thus, by default, wallflower looks for parameters in "data-wf": <input class='datepicker' data-wf='minDate: 1' type='text' name='the-date' > For attributes, the parameter block need not be surrounded with curly braces. The same convention of labeling sub-objects by feature name applies to attribute values, in the case of collision between features.

Internals

There's not really a lot of code involved with wallflower. The registration process is pretty simple, and most of the code is involved with the piddly details like handling the shortcut registration invocations and dealing with the "depends" system. The registry of handlers is just an array sorted by the "ranking" computed from the partial ordering imposed by the dependencies. (In lots of cases, there'll be no dependencies anyway.)

At the feature application end, the plugin simply works through the list of registered plugins using the feature selectors to find elements to be operated upon. The code treats each matched element separately because each one has to be examined for feature parameters. Those are pulled from the element and then passed to the feature handler, laid over the original defaults from registration and overridden by any parameters for the feature found in the call to the plugin.

Performance-wise, things could get out of hand if all your features did nothing but rely on class-based element selection, and if your pages are big. Thus, initializing features with efficiently restrictive selectors is important when there are lots of features to contend with. For example, instead of allowing the "datepicker" feature to just look around for anything with "datepicker" in the class string, it'd be better to set it up with a more efficient selector: $.wallflower('datepicker', { selector: 'input.datepicker' }); Now jQuery will only have to look at "input" elements. If some features only apply to portions of pages contained within an element that's got a predictable "id" value, like for example a "help" section: $.wallflower('help-toggle', {selector: '#help-section li.help-toggle'}); could really cut down on DOM work by the jQuery selection engine.

My experience (with a system that does basically what wallflower does but without a nice organizer like wallflower) is that so long as the selectors are efficient, applying even a lot of features to whole pages does not result in a significant page load penalty. Obviously it costs something, but on my current site with around thirty or forty separate features, my application pages are snappy and there's no apparent lag. It does, however, depend on selectors: my site has been slow when I've added a feature with a sloppy, careless selector.

Getting It

The github repository for wallflower is here. I barely understand github and git so I really can't provide help or commentary or anything; as far as I can tell the files are there for the plugin itself and a modest demo page. This is an initial release, and it will probably poison your pets if you actually use it.

The plugin relies (for now) on the "metadata" plugin from John Resig, Yehuda Katz, Jörn Zaefferer, and Paul McLanahan. The plugin (version 2.1) is built into the current source and is installed if wallflower doesn't see "$.metadata". If you're using an older version, wallflower should still work fine; it doesn't (yet) use the "html5" metadata stuff.

7 comments:

  1. tl;dr....in one sentence, what does this do, and/or why should I care?

    What problems does it solve?

    Where's a live demo?

    ReplyDelete
  2. This comment has been removed by the author.

    ReplyDelete
  3. This comment has been removed by the author.

    ReplyDelete
  4. This comment has been removed by the author.

    ReplyDelete
  5. This comment has been removed by the author.

    ReplyDelete
  6. Sorry, the site claimed my post was too long, so I broke it up into multiple posts. Then the original post showed up in all its lengthy glory, so I removed the duplicate info.

    -Patrick

    ReplyDelete
  7. It looks good, sort of like a package manager... or something... It borrowed some concepts from package managing I guess. It might be what unobtrusive javascript is missing. That comes just when I was heading off in uki's direction: "unobtrusive javascript is evil". (ukijs.org).
    Now I'm really lost

    ReplyDelete