Collecting DOM events

The DOM change notification I presented yesterday is one piece in the puzzle to rejuvenate my languishing JavaScript form validator. I’ve not yet made up my mind on a lot of issues, but there’s one thing that’s certain: the next version will have to be able to work with changing forms, that is, forms that show different input elements depending on already chosen values.

Since yesterday, I have a way to be notified when the DOM has changed. As it is, the dom:changed custom event is completely general and not quite what I need. Of course, I do need some kind of notification, but what I get when a function changes the DOM in several places, is a barrage of dom:changed events. I don’t want to do lots of stuff for each of these events, but I’ve got to do something about the events collectively. There is no natural bracketing for events, there’s nothing that says which events belong to the same high-level change. But that’s not really necessary, rather, it is good enough to collect all relevant events that occur in period of time.

Here’s a class that collects all events that it receives over a given interval (of seconds) and passes them on to a handler function.

Element.EventCollector = Class.create({
  initialize: function(handler, interval) {
    this.handler = handler;
    this.interval = (interval || 1) * 1000;
    this.reset();
    this.dischargeEvents = this.discharge.bind(this);
  },
  reset: function() {
    this.events = [];
    this.timer = null;
  },
  observer: function() {
    return this.onEvent.bindAsEventListener(this);
  },
  onEvent: function(event) {
    this.events.push(event);
    this.wait();
  },
  wait: function() {
    if (!this.timer) {
      this.timer = setTimeout(this.dischargeEvents, this.interval);
    }
  },
  discharge: function() {
    this.timer = null;
    var events = this.events;
    this.reset();
    this.handler(events);
  }
});

Add some syntactic sugar, so that we can do things like

document.body.collectEvents('dom:changed', function(events) { ... });

Element.addMethods({
  collectEvents: function(element, eventName, handler, interval) {
    var collector = new Element.EventCollector(handler, interval);
    $(element).observe(eventName, collector.observer());
  }
});

For my motivating case of the validator that’s still not exactly what I need. I can arrange to get a notification that a whole bunch of elements was changed, but I don’t want to deal with these elements individually. The validator I have (and the next version I envision) works it’s way down from a starting element and attaches its magic pixie dust to everything below. Thus, what I need is a common ancestor element for all the changed elements. The idea is easy: Go down from the root of the “family” (document) tree and follow it until the path to the elements branches.

Element.addMethods({
  commonAncestor: function() {
    var ancestorsLists = $A(arguments).map(function(el) {
      el = $(el);
      var ancestors = el.ancestors().reverse()
      ancestors.push(el);
      return ancestors;
    });
    var common = document;
    Enumerable.zip.apply(ancestorsLists.shift(), ancestorsLists).each(
      function(ancestorTuple) {
        var cand = ancestorTuple.shift();
        if (!ancestorTuple.all(function(el) { return el == cand; })) {
          throw $break;
        }
        common = cand;
      });
    return common;
  }
});

[See Element#ancestors, Enumerable#zip, $break.]

Finally, here’s an example that puts the existing pieces together.

document.observe('dom:loaded', function() {
  document.body.collectEvents('dom:changed', function(events) {
    var affectedElements = events.invoke('element');
    var ancestor = Element.commonAncestor.apply(null, affectedElements);
    console.log('dom:changed below: ', ancestor);
  }, 10);
});

The DOMs they are a-changin’

So we’ve put in the effort to make our JavaScript code nicely unobtrusive and all is well — until it isn’t. A pesky Ajax request has messed up the DOM and suddenly there are elements in there where our unobtrusive behavioral goodness has not been attached. Never give up, never surrender. You won’t even have to get out Grabthar’s hammer, it’s only just a small matter of programming.

First, let’s shrink the problem a little. Let’s concentrate on changes effected through the abstraction layer provided by the Prototype library. Prototype implements several DOM-changing functions on the Element singleton and also mixes them into extended DOM elements. These functions are remove, update, replace, insert, wrap, and empty. Prototype also gives us the wrap function that wraps a function around another, somewhat akin to AOP‘s around advice, but without pointcuts.

Now the strategy is clear: Replace the original DOM-changing functions with versions that record a bit of information about these changes. A good mechanism to serve the actual notification is to use Custom Events. Then, anyone intersted in changes to a branch of the DOM can register as an observer for our new dom:changed event.

However, it is a good idea not to fire the event straightaway after the DOM has been changed. We might be in the middle of a batch of changes and the DOM in a correspondingly unorderly state. Besides, it is sometimes necessary to give the browser’s rendering engine a bit of breathing room after changes. For all this, at first the event data is appended to a list and a timer is set to dispatch the corresponding events some time later. This timer is reset for every change, so that for a batch of changes it only executes once and and then fires all events successively.

To understand the resulting behavior, it is important to know that JavaScript is single-threaded. As a result, when a timer times out, it does not interrupt the currently executing code. Rather, the function attached to the timer is scheduled to be executed as soon as there is nothing else to do. Therefore, the usual sequence is like this

  • An event triggers a DOM-changing function
    • Change the DOM
    • Remember the change
    • Change the DOM
    • Remember the change
  • Fire events for all remembered changes

Finally, here’s the code.

(function() {
  var methods = ['remove', 'update', 'replace', 'insert', 'wrap', 'empty'];
  var changes = [];
  var timeout;
  
  function rememberChange(element, method) {
    changes.push({
      element: $(element),
      parent: element.parentNode,
      operation: method
    });
  }
  
  function scheduleEvent() {
    if (timeout) {
      clearTimeout(timeout);
    }
    timeout = setTimeout(fireEvent, 10);
  }
  
  function fireEvent() {
    changes.each(function(change) {
      var affectedNode = change.element;
      var operation = change.operation;
      if (!change.parent || 
          (change.parent && !affectedNode.descendantOf(change.parent))) {
        affectedNode = change.parent || affectedNode.parentNode;
        operation += 'Child';
      }
      affectedNode.fire('dom:changed', {operation: operation});
    });
    changes = [];
  }

  methods.each(function(m) {
    Element.Methods[m] = Element.Methods[m].wrap(
      function(proceed, element) {
        rememberChange(element, m);
        scheduleEvent();
        return proceed.apply(null, $A(arguments).slice(1)); 
      });
  });
})();

When an element has been removed from the DOM, it is pointless to fire an event on it that bubbles upwards. There’s no surface to bubble up to anymore. Therefore, in this case, the dom:changed event is fired on the parent of the removed element and the operation is signified as removeChild.

To peek at the modification events, or do something useful with them, add code like this

document.observe('dom:loaded', function() {
  document.body.observe('dom:changed', function(event) {
    console.log('dom:changed: ', event.element(), event.memo.operation);
  });
});