Wrapping DOM elements for easier cross-browser Javascript

One thing that has always bugged me a little about the cross-browser Javascript functions I write, is that they usually take as their first argument, the DOM element upon which the function is going to act. For example:

addEvent(myElem, evtName, myFn, false);

Here, we're forced to pass the DOM element into the function as the first argument. This works just fine, but it's a little cumbersome and doesn't read nearly as nicely as the standardized .addEventListener(..) or even the proprietary .attachEvent(..).

myElem.addEventListener(evtName, myFn, false);

Notice how in the second case, we're not passing the DOM element into the function. Rather, the function is a public member of the DOM element that we can invoke, and in my opinion, this feels a lot more natural than the first scenario.

As it turns out, mimicking this style of programming in a cross-browser fashion isn't all that hard.

One interesting way to achieve a coding style more akin to the second example is by wrapping DOM elements up in a custom Javascript object. You can then expose functions that allow the manipulation of the wrapped up DOM element in a cross-browser manner. I don't think there is anything new or earth-shattering here. Other Javascript developers have probably been doing this for a long time, but only recently has the concept really grown on me.

Exactly how far you want to go with this concept is really up to you. Your wrapper might be intelligent enough to distinguish between a single DOM object, or an entire array of DOM objects and act accordingly, or it might just take a string representing the ID of the element you want to wrap.

Let's start with a really simple example, just to see how this concept might work.


var DOMElemWrapper = new function()
{
   this.getAnInstance = function(_elemId)
   {
      return new function()
      {
         var entity = document.getElementById(_elemId);

         this.addEvent = function(_evtName, _fn, _useCapture)
         {
            if (typeof entity.addEventListener != 'undefined')
            {
               entity.addEventListener(_evtName, _fn, _useCapture);
            }
            else if (typeof entity.attachEvent != 'undefined')
            {
               entity.attachEvent('on' + _evtName, _fn);
            }
         };
      };
   };
};

var $ = DOMElemWrapper.getAnInstance;

I mapped $ to DOMElemWrapper.getAnInstance, mostly for convenience, but also because it seems that $ has become the widely accepted way to reference a DOM element (or elements as the case may be). I see no reason to buck the trend, so I'm going to use that idea here.

If you're wondering why the need for the .getAnInstance() function at all (and the inner return new function()...), it's not completely obvious (and doesn't become so until we get to Part 2). I'll explain this in more detail at the end of the post, so as to not get bogged down in that detail right now.

To utilize our DOM element wrapper, you could do something like this:


// assume the existence of a <div> whose id = "myDiv"
$('myDiv').addEvent('click', function()
{
   alert('You clicked the me!');
}, false);
 

Now, whenever you click on the element whose id = "myDiv", you should see an alert saying "you clicked me!"

Of course, a cross-browser .addEvent(..) function isn't all that useful if you still have to write additional code to manage cross browser issue like e.target vs e.srcElement. If you remember, a short while back I wrote a blog entry about Fixing IE's .attachEvent(..) Failures. In that post, I discussed one possible way to get IE to properly handle the this keyword inside of event handlers. We can use that same technique here, and we can even take it a step further and "enhance" the event object in IE to support .target and .relatedTarget, thus achieving even more cross-browser compliance.

So we've already covered the basic idea behind wrapping a DOM object to aid in the development of cross-browser Javascript. Now, I'll take this idea a bit further (but by no means as far as it could go) and show how we can get non-standard browsers to honor event properties like .target and .relatedTarget.

First, the full code, and then I'll follow with an explanation:


var DOMElemWrapper = new function()
{
   var evtHash = [];

   this.getAnInstance = function(_elemId)
   {
      return new function()
      {
         var entity = document.getElementById(_elemId);

         this.addEvent = function(_evtName, _fn, _useCapture)
         {
            if (typeof entity.addEventListener != 'undefined')
            {
               entity.addEventListener(_evtName, _fn, _useCapture);
            }
            else if (typeof entity.attachEvent != 'undefined')
            {
               var key = '{_fnKEY::' + entity.uniqueID + ':' + _evtName + ':' + _fn + '}';
               var f = evtHash[key];
               if (typeof f != 'undefined')
                  { return; }

               f = function(e)
               {
                  e.target = e.srcElement;

                  if (_evtName == 'mouseover') { e.relatedTarget = e.fromElement; }
                  else if (_evtName == 'mouseout') { e.relatedTarget = e.toElement; }

                  _fn.call(entity, e);

                  e.target = null;
                  e.relatedTarget = null;
               };

               evtHash[key] = f;
               entity.attachEvent('on' + _evtName, f);

               // attach unload event to the window to clean up possible IE memory leaks
               window.attachEvent('onunload', function()
               {
                  entity.detachEvent('on' + _evtName, f);
               });

               key = null;
               //f = null;   /* DON'T null this out, or we won't be able to detach it */
            }
         };

         this.removeEvent = function(_evtName, _fn, _useCapture)
         {
            if (typeof entity.removeEventListener != 'undefined')
            {
               entity.removeEventListener(_evtName, _fn, _useCapture);
            }
            else if (typeof entity.detachEvent != 'undefined')
            {
               var key = '{_fnKEY::' + entity.uniqueID + ':' + _evtName + ':' + _fn + '}';
               var f = evtHash[key];
               if (typeof f != 'undefined')
               {
                  entity.detachEvent('on' + _evtName, f);
                  delete evtHash[key];
               }

               key = null;
               f = null;
            }
         };
      };
   };
};

var $ = DOMElemWrapper.getAnInstance;

Explanation:

I won't go into a ton of detail regarding the key variable and the evtHash as those were explained in the post: Fixing IE's .attachEvent(..) Failures.

It is worth taking a closer look however, at the helper function, f. The actual function passed into .addEvent(..) was _fn, and that is the function we really need to execute. The problem is, if we allow IE to execute that function normally, we get all sorts of non-standard behavior (improper reference to the this object, lack of .target and .relatedTarget properties, etc...).

Instead, we wrap the call to _fn up in another function, f, where we can first correct IE's bad behavior, and then execute the desired function. What behavior exactly are we fixing? Glad you asked.

  1. e.target = e.srcElement; Adds a .target property to the event object that will be passed along to the handler which maps to IE's proprietary .srcElement property.
  2. if (_evtName == 'mouseover') { e.relatedTarget = e.fromElement; }else if (_evtName == 'mouseout') { e.relatedTarget = e.toElement; } Check to see if the event is either 'mouseover' or 'mouseout' and then creates a property .relatedTarget, which maps to IE's proprietary .toElement or .fromElement accordingly.
  3. e.target = null;e.relatedTarget = null;Finally, we null our the .target and .relatedTarget properties that we added to event object after we invoke the call to _fn(..). We do this to try and prevent memory leaks in IE.

The .removeEvent(..) function is pretty straight-forward, and I don't believe it quites a very in-depth explanation. We simply generate a key (the same way we do in .addEvent(..)) and then use that key to grab a function reference from the event hash. Once we've got that, we can detach the event that was previously attached.

The net effect of this (somewhat convoluted) piece of code, is that we now have a cross-browser way to add and remove events from DOM elements, which also makes IE honor the this keyword properly, while also adding support for .target and .relatedTarget.

You can go even further with this and add additional functionality to the event object if you want (see Preventing the default action) which I blogged about a few months back. In the interest of keeping this blog post from getting too long though, I'm not going to get into that right now.

In part 3 of this post, I'll show some examples of how to use the code we just wrote. Before I do that though, I want to come back to something I talked about at the beginning of this post: why we need the .getAnInstance(..) function at all.

The DOMElemWrapper variable acts as a singleton. Since it is equal to a new anonymous function that we execute right away, there can only ever be one instance of that function. Beside being a useful technique on its own, it is particularly helpful here, as DOMElemWrapper acts as a "home" for the evtHash object which is outside of each individual instance object we create (albeit indirectly though .getAnInstance(..)).

We need this; we can't simply put the evtHash inside of the .getAnInstance(..) definition because the evtHash needs to keep track of all wired up events. In other words, it needs to essentially be global, but I prefer not creating global variables, and having evtHash live inside of DOMElemWrapper accomplishes just what we need!

The last thing that warrants explanation is the return new function() call that happens right away in the .getAnInstance(..) function. There is nothing magical here, we're simply saying that we want .getAnInstance(..) to return a new instance of an object (defined by our anonymouse function). You can this of this object as an "instace of a DOMElemWrapper" (hence the name -- clever aren't I?). This technique also creates a closure around the _elemId parameter so we can maintain access to that variable later on.

The closure used here is a powerful concept, but it can cause memory leaks in IE becuase of fault in that particular browser's garbage collection. You'll want to be aware of this so you can take the necessary steps to null out the entity (perhaps on window unload) however you see fit. Note that memory leak cleanup techniques are not discussed here.

Finally (and this was previously mentiond in Part 1), we map DOMElemWrapper.getAnInstance to $. This is nothing more than a convenience, but it is quite a convenience, so it just makes good sense.

Now that most of the tricky parts have been explained, we can go ahead and take a look at a sample of the code in use.

The following example, creates a <div> element and a <p> element. To each element, we use $(..) to wire up a 'mouseover' and a 'mouseout' event. The handler function, test(..), makes use of this, e.target, and e.relatedTarget to demonstrate that those properties do work (even in IE!).

Inside the .test(..) function, you may have noticed a special check: if (e.relatedTarget) before any attempt to actually use the related target. This has nothing to do with the code we just wrote. It's quite possible for .relatedTarget to be null, in which case we'd better check before attempting to reference any of its expected properties.

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">

<html id = "root" xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">

  <head>
    <title>DOM Element Wrapper</title>

      <style type = "text/css">
         html, body { height: 100%; }

         div, p { border: 1px solid #000; }
      </style>

      <script type = "text/javascript" src = "./dew.js"></script>

    <script type = "text/javascript">
         function init()
         {
            $('div1').addEvent('mouseover', test, false);
            $('div1').addEvent('mouseout', test, false);
            $('para1').addEvent('mouseover', test, false);
            $('para1').addEvent('mouseout', test, false);
         }

         function test(e)
         {
            alert(e.type + ' : ' + this.id + ' / ' + e.target.innerHTML);
            if (e.relatedTarget)
               { alert('and the related target is: ' + e.relatedTarget.id); }
         }
      </script>

  </head>

  <body id = "theBody" onload = "init()">

      <div id = "div1">I'm div 1</div>

      <br /><br /><br /><br />

      <p id = "para1">And I'm paragraph 1</p>

  </body>

</html>

You can view a live demo of the code here.

In some browsers (Opera in particular) the alert(..) statements inside the test(..) handler function can be quite annoying. Since Opera fires a 'mouseout' event ever time another element grabs focus, you end up getting a lot of alerts when viewing the live demo in that browser. It probably would have been better for me to use a demo that didn't involve alerts, but I just wrote three pages of javascript/explanation; what more do you want from me?

Final thoughts:

It's worth pointing out that the code posted here is merely a proof of concept. It is in no way complete. After all, upon executing something like $('myDiv'), it would be nice (essential even?) to be able to access real properties of the wrapped up DOM object, say .innerHTML, .id, .className, etc...

Exactly how you'd expose this data is up to you. You could write getters and setters like .getId() or .getHtml(), or you might just write a .getEntity() function which returns a true reference to the element and then access the properties that way. It's all up to you really.

Another thing you might find useful, is modifying the .getInstance(..) function to be "intelligent" enough to distinguish between an object parameter and a string parameter. If a string is passed in, retrieve the element by ID and use the retrieved element as the entity (as we did), or, if an object is passed in (assuming its a valid DOM element), just use that as the entity.

And I'm sure you can probably think of even more ways to enhance this concept.

Hopefully you've seen something interesting in this post, and if you any idea on how to improve it, or if you simple have some comments, feel free to send them my way.

Happy ECMAScripting!

5 Responses

  1. Clay Says:

    Great article. I do have a questions.

    What's the best way to access properties such as id, className and without the use of getters or setters?

  2. Stephen Stchur Says:

    Clay,

    It think your best best would be to stick with .id and .className for those two cases (among others). You could access an attribute by calling .getAttribute('id') or .getAttribute('class') but retrieving an attribute that way is not exactly the same as directly referencing a given DOM element's property via dot notation.

    Most notably, IE has a number of bugs regarding .getAttribute(..) so for DOM elements that expose the property you're interested in, I think the dot notation is going to be a good choice.

  3. Clay Says:

    After testing Prototype and reading this article I was mulling over whether to adopt this style.

    I've been hung up on not being able to do $('mydiv').id or $('mydiv').className. I think i'll go with the getEntity() solution you hinted at, closer to the end of your article.

  4. Stephen Stchur Says:

    Clay,

    Yeah, that could work. Alternatively, you might be interested to check out my Javascript Library: Gimme. It uses the technique discussed in this article, but goes much further and support many CSS selectors as well: http://gimme.stchur.com for demos or http://codeplex.com/gimme for docs

    -Steve

  5. Clay Says:

    My final wrapper object caches each DOM object. It also accepts multiple arguments, each capable of being an array, a string or a object. The CSS Selector feature would be a nice addition but i'm a little worried about the affect the additional checks would have on performance.

    Btw the cache feature is discussed in a article by Matt Snider. In the article he compares YUI "YAHOO.util.Dom.get" to Prototype "$".
    http://mattsnider.com/javascript/prototype-vs-yui-round-2-i-love/

    I've found your articles to be quite insightful and I can imagine it is much the same for Gimme. So I will will definately check it out.

Got something to say?

Please note: Constructive criticism is welcome. Rude or vulgar comments however, are not and will be removed during moderation.