Skip to content

jQuery Hooks

You probably know all about rolling your own plugins for jQuery. But did you know that some core jQuery functions have their own "plugin API" called "hooks"?

jQuery provides an API to hook into calls to the methods .attr(), .prop(), .val() and .css(). Fiddling with Sizzle, jQuery's selector-engine, you can even roll your own pseudo-class-selectors such as $('div:is-awesome') or $('div:is-it(awesome enough)'). Let's see what we can (ab)use these, mostly undocumented features for.

Hook Function Signature

The hooks for .attr(), .prop(), .val() and .css() look and behave the same:

var someHook = {
    get: function(elem) {
        // obtain and return a value
        return "something";
    },
    set: function(elem, value) {
        // do something with value
    }
}

Hooking into .attr() and .prop()

For a distinction of .attr() and .prop() see the docs on .prop().

Let's assume we've built a polyfill for the HTML5 <details> tag. Said tag knows the attribute open, which denotes if the contents of the element are initially visible or not. In Javascript we can set that property to true and false to programmatically open and close the <details>.

Besides initializing the polyfill with $('details').details() the plugin also offers $('details').details(true) and $('details').details(false) to open and close the <details> elements as well as $('details').details('is-open') to check if it's open or not. While this serves the purpose of programmatically mutating the open state, it imposes a new API on us rather than letting us use $('details').prop('open', true) like we would for any regularly supported element.

This is where jQuery's attribute and property hooks come to the rescue. They let us redirect calls to the open attribute and property to our details() polyfill:

$.propHooks.open = {
  get: function(elem) {
    if (elem.nodeName.toLowerCase() !== 'details') {
      // abort for non-<detail> elements
      return undefined;
    }
    // return the open-state as a boolean
    // (.prop() is supposed to return a boolean)
    return !!$(elem).details('is-open');
  },
  set: function(elem, value) {
    if (elem.nodeName.toLowerCase() !== 'details') {
      // abort for non-<detail> elements
      return undefined;
    }
    // convert value to a boolean and pass it to the plugin
    return $(elem).details(!!value);
  }
};

$.attrHooks.open = {
  get: function(elem) {
    if (elem.nodeName.toLowerCase() !== 'details') {
      // abort for non-<detail> elements
      return undefined;
    }
    // convert the plugin's boolean to the string "open" or empty-string
    // (.attr() is supposed to return the actual value of the attribute)
    return $(elem).details('is-open') ? 'open' : '';
  },
  set: $.propHooks.open.set
};

And now we can manipulate the open-state like <detail> was implemented natively:

$('details').prop('open', true);

Hooking into .val()

valHooks allow us to intercept calls to the .val() function.

<div id="world" data-foobar="hello world">and mars</div>

$('#world').data() === "" because the <div> does not have a value property. We can fix that with:

$.valHooks.div = {
  get: function(elem) {
    return $(elem).data('foobar');
  },
  set: function(elem, value) {
    $(elem).data('foobar', value);
  }
};

This may come in handy when you're writing a polyfill for the new HTML5 <input> types to sanitize input / output data.

jQuery first checks the element's type before its nodeName. So for <input type="foobar"> jQuery will first look for $.valHooks.foobar and then for $.valHooks.input. (As of jQuery 1.7.2)

Hooking into .css()

cssHooks allow us to intercept calls to .css(). With that we can fix CSS across browsers, polyfill missing CSS3 features and even introduce custom CSS-properties. On top of that, cssHooks can also be made available to .animate().

See the jQuery documentation on cssHooks.

Say we're writing a polyfill for box-sizing. The polyfill needs to know the Internet Explorer version the script is executed in, so we require the conditional classes to be set.

$.cssHooks.boxSizing = {
  set: function(elem, value) {
    if (value === 'border-box' && $('html').is('.ie6, .ie7')) {
      // fix <IE8
      elem.style.behavior = 'url(/scripts/boxsizing.htc)';
    }

    elem.style.boxSizing = value;
  }
};

Note that (as of jQuery 1.7.2) cssHooks are not invoked for .swap().

There's a list of [CSS3 Polyfills] using cssHooks worth checking out.

Custom Pseudo-Class Selectors

Sizzle is jQuery's selector engine. Before browsers implemented neat stuff like document.querySelectorAll() it was the one of the few options to traverse the DOM using CSS selectors. But even today, a time where browsers do most of Sizzle's work natively (and thus a magnitude faster), Sizzle has some nice features for us - namely filters.


Update: As of jQuery 1.8 there is a documentation for extending filters. While the content of this chapter is not wrong (yet), their API has improved on this particular topic. Be sure to check out their docs!


jQuery allows you to filter for <input type="checkbox"> that have been checked with a simple enough selector: $(':checked'). :checked is not a standard CSS selector, though - document.querySelectorAll(':checked') throws an Error. This selector is provided by Sizzle in $.expr.filters.checked. The same is true for $(':first') and $(':nth(3)'), which are provided in $.expr.setFilters.first and $.expr.setFilters.nth.

You've probably noticed that there are two kinds of selectors (well, "filters"). .filters look at values and properties, .setFilters look at the position within the matched set. .filters have precedence - so if both $.expr.filters.foo and $.expr.setFilters.foo exist, only $.expr.filters.foowill be executed.

Filter's Function Signature:

function(elem, index, match, array) {
  // Arguments:
  //   elem  - the DOMElement currently inspected
  //   index - the 0-based index within the set of inspected elements
  //   match - tokenized filter expression
  //   array - the set of inspected elements

  // return a boolean true to keep the element
  // return a boolean false to throw it out of the set
  return true;
}

match is the result of the $.expr.match.PSEUDO RegExp, behaving as follows:

selector match
":foo" [":foo", "foo", undefined, undefined]
":foo(bar)" [":foo(bar)", "foo", "", "bar"]
":foo('bar')" [":foo('bar')", "foo", "'", "bar"]
":foo(hello(world) bla)" [":foo(hello(world) bla)", "foo", "", "hello(world) bla"]

In short: match[3] contains the argument passed to the pseudo-class

Filter example:

Let's create a pseudo class referring to links to the same domain:

$.expr.filters.local = function(elem, i, match, array) {
  if (elem.nodeName.toLowerCase() !== 'a') {
    // abort for non-<a>
    return false;
  }
  // use jQuery's URL denormalization
  var href = $(elem).attr('href');
  if (!href || href.substring(0, 1) === '#') {
    // skip anchors
    return false;
  }
 
  if (href.substring(0, 1) === '/') {
    // authority-relative URL
    return true;
  }

  // matching host?
  var host = ('//' + location.host + '/')
    .replace(/([.*+?^=!:${}()|[\]\/\\])/g, '\\$1'));
  return !!href.match(new RegExp(host, 'i'));
};

Now we can filter for links to our own domain using $('a:local').

Please note that $(':local') will be executed on every single element in your DOM, while $('a:local') will first match <a> and then run the :local filter on that (tremendously smaller) set of nodes.

Words of caution

attrHooks, propHooks, valHooks and cssHooks are unique. That means you can only have one $.attrHook.foobar. This may be a problem if multiple plugins try to register the same hook-name. A possible solution could be to proxy any existing hooks:

var _hook = $.attrHooks.foobar;
$.attrHooks.foobar = {
  get: function(elem) {
    if (is_not_meant_for_me) {
      return _hook && _hook.get && _hook.get(elem) || undefined;
    }

    return "my result";
  },
  set: function(elem, value) {
    if (is_not_meant_for_me) {
      return _hook && _hook.set && _hook.set(elem, value) || undefined;
    }

    // do something with value
  }
};

Summary

jQuery allows developers to hook into .attr(), .prop(), .val() and .css() calls. While these are mainly used to simplify cross-browser support by jQuery itself, I can't see any good reason not to implement them in third-party plugins when that makes sense.

Be careful with proprietary filters. Whenever they are used, jQuery is forced to fire up Sizzle even if the browser supports (the much faster) querySelector() functions. Try to return from your custom filters as early as possible, as they are executed for every element in the set. And make sure to tell your plugin-users to write proper selectors (a:foo rather than :foo).

Comments

Display comments as Linear | Threaded

No comments

The author does not allow comments to this entry