Re: [WebIDL] Simplify callbacks

I want to try to summarise the arguments we have so far.  Like most 
contentious Web IDL issues, this one revolves around what style we want 
to encourage or allow.  I think it's clear that there is nothing you can 
do with object style or function style callbacks that you can't do with 
the other.

We have the three options that Jonas mentioned:

a. accept only functions (except where required for web compatibility)
b. accept functions and objects with handleEvent
c. accept functions and objects with a descriptive function name

Let's consider an (untested) example with a combination of callback APIs 
and callback source objects, with the three different approaches.  The 
example is a manager object that handles mouse events for dragging an 
object around the canvas, and it also uses requestAnimationFrame to 
animate the element while it's being dragged.  I'm writing the example 
under the assumption that we actually want to encapsulate the state 
somewhere.

(I'm assuming in the example that each element we're managing has a 
distinct ID.)


a. using objects with descriptive function names
------------------------------------------------

   function isEmpty(set) {
     for (var k in set) {
       return false;
     }
     return true;
   }

   var manager = {
     _elementState: { },
     _animatingElements: { },

     startManaging: function(element) {
       element.addEventListener("mousedown", this, false);
       element.addEventListener("mousemove", this, false);
       element.addEventListener("mouseup", this, false);
       this._elementState[element.id] = { };
     },

     stopManaging: function(element) {
       element.removeEventListener("mousedown", this, false);
       element.removeEventListener("mousemove", this, false);
       element.removeEventListener("mouseup", this, false);
       delete this._elementState[element.id];
     },

     handleEvent: function(event) {
       if (event.type == "mousedown") {
         this.handleMouseDown(event);
       } else if (event.type == "mousemove") {
         this.handleMouseMove(event);
       } else if (event.type == "mouseup") {
         this.handleMouseUp(event);
       }
     },

     handleMouseDown: function(event) {
       var element = event.target;
       if (isEmpty(this._animatingElements)) {
         requestAnimationFrame(this);
       }
       this._animatingElements[element.id] = true;
     },

     handleMouseMove: function(event) {
       var element = event.target;
       // move the element to the current drag position
       // store the current drag position in this._elementState[element.id]
     },

     handleMouseUp: function(event) {
       var element = event.target;
       delete this._animatingElements[element.id];
     },

     sample: function(time) {
       for (var id in this._animatingElements) {
         var state = this._elementState[id];
         // do animation for this element
       }
       if (!isEmpty(this._animatingElements)) {
         requestAnimationFrame(this);
       }
     },
   };

Since DOM Events routes all events through a single callback, our
handleEvent dispatches them to specific methods for particular event
types.  It is easy to distinguish which element handleMouseDown etc. are
for since they can use the Event object passed in to look at the target.
So we can easily look up this._elementState[element.id] to get to the
state for the particular element being dragged.

requestAnimationFrame though doesn't identify what the source of the
callback is.  So instead we need to record in a separate state variable
which elements are animating so that the single sample call can handle
all of them.


b. using objects with handleEvent
---------------------------------

   function isEmpty(set) {
     for (var k in set) {
       return false;
     }
     return true;
   }

   var manager = {
     _elementState: { },
     _animatingElements: { },

     startManaging: function(element) {
       element.addEventListener("mousedown", this, false);
       element.addEventListener("mousemove", this, false);
       element.addEventListener("mouseup", this, false);
       this._elementState[element.id] = { };
     },

     stopManaging: function(element) {
       element.removeEventListener("mousedown", this, false);
       element.removeEventListener("mousemove", this, false);
       element.removeEventListener("mouseup", this, false);
       delete this._elementState[element.id];
     },

     handleEvent: function(arg) {
       if (typeof arg == "number") {
         // This is the callback for requestAnimationFrame.
         this.handleAnimationFrame(arg);
       } else {
         // This is the callback for addEventListener.
         if (arg.type == "mousedown") {
           this.handleMouseDown(event);
         } else if (arg.type == "mousemove") {
           this.handleMouseMove(event);
         } else if (arg.type == "mouseup") {
           this.handleMouseUp(event);
         }
       }
     },

     handleMouseDown: function(event) {
       var element = event.target;
       if (isEmpty(this._animatingElements)) {
         requestAnimationFrame(this);
       }
       this._animatingElements[element.id] = true;
     },

     handleMouseMove: function(event) {
       var element = event.target;
       // move the element to the current drag position
       // store the current drag position in this._elementState[element.id]
     },

     handleMouseUp: function(event) {
       var element = event.target;
       delete this._animatingElements[element.id];
     },

     handleAnimationFrame: function(time) {
       for (var id in this._animatingElements) {
         var state = this._elementState[id];
         // do animation for this element
       }
       if (!isEmpty(this._animatingElements)) {
         requestAnimationFrame(this);
       }
     },
   };

So the only difference here is that inside handleEvent we now need to 
detect whether it is being called due to requestAnimationFrame or due to 
an event dispatch.  In this case it is possible -- we can just check 
whether the argument is a number or an Event object -- but in general 
this is not always going to be possible.  (I renamed the sample method 
to handleAnimationFrame to point out that here you don't need to 
remember that "sample" is the official method name, unlike in (a).)


c. using functions
------------------

   var manager = {
     _elementState: { },

     init: function() {
       this.handleMouseDown = this.handleMouseDown.bind(this);
       this.handleMouseMove = this.handleMouseMove.bind(this);
       this.handleMouseUp = this.handleMouseUp.bind(this);
     },

     startManaging: function(element) {
       element.addEventListener("mousedown", this.handleMouseDown, false);
       element.addEventListener("mousemove", this.handleMouseMove, false);
       element.addEventListener("mouseup", this.handleMouseUp, false);
       this._elementState[element.id] = { };
     },

     stopManaging: function(element) {
       element.removeEventListener("mousedown", this.handleMouseDown, 
false);
       element.removeEventListener("mousemove", this.handleMouseMove, 
false);
       element.removeEventListener("mouseup", this.handleMouseUp, false);
       delete this._elementState[element.id];
     },

     handleMouseDown: function(event) {
       var element = event.target;
       this._elementState[element.id].animating = true;
       requestAnimationFrame(this.handleAnimationFrame.bind(this, element));
     },

     handleMouseMove: function(event) {
       var element = event.target;
       // move the element to the current drag position
       // store the current drag position in this._elementState[element.id]
     },

     handleMouseUp: function(event) {
       var element = event.target;
       this._elementState[element.id].animating = false;
     },

     handleAnimationFrame: function(element, time) {
       var state = this._elementState[element.id];
       // do animation for this element
       if (state.animating) {
         requestAnimationFrame(this.handleAnimationFrame.bind(this, 
element));
       }
     },
   };

   manager.init();

Using functions, we can now identify individual callback methods rather 
than having all DOM Events callbacks go through handleEvent.  That 
eliminates our own discrimination/dispatching code.  The downside is 
that we need to have these callback functions bound to the manager 
object, so that their "this" will refer to the manager.  We do this in 
the init function, so that we can call removeEventListener successfully 
later.

(An alternative since this example is obviously a singleton object would 
be to avoid binding them and just refer to "manager" instead of this.)

requestAnimationFrame is now also being passed a function rather than an 
object.  We can use bind() here to identify which element the animation 
frame request is for, which means we don't need to coalesce all 
animations into the one callback, which means we can do away with 
_animatingElement, but we still do need to track which elements are 
being animated.  So handleMouseDown is a bit simpler, handleMouseUp is 
about the same, and handleAnimationFrame is simpler.


Summary
-------

a. using objects with descriptive function names
    Pros:
      * listener registration calls are shorter than (c)
      * different callback types can be handled without fragile sniffing
        of arguments
      * no storing of bound functions required for removeEventListener to
        work
    Cons:
      * when looking at listener registration function, it is less clear
        than (c) which function will be called
      * for APIs like DOM Events, where multiple callback APIs are
        routed through the same function, manual discrimination must be
        done
      * extra state needed to track callbacks where the source is not
        identified, like requestAnimationFrame
      * need to remember particular calback method name, like "sample"
      * more complex implementation and more complex model to understand
        for authors than "just function"

b. using objects with handleEvent
    Pros:
      * listener registration calls are shorter than (c)
      * no need to remember particular callback method name, it is always
        "handleEvent"
      * no storing of bound functions required for removeEventListener to
        work
    Cons:
      * when looking at listener registration function, it is less clear
        than (c) which function will be called
      * for APIs like DOM Events, where multiple callback APIs are
        routed through the same function, manual discrimination must be
        done
      * extra state needed to track callbacks where the source is not
        identified, like requestAnimationFrame
      * fragile argument sniffing required inside handleEvent when
        multiple callback types are handled
      * more complex implementation and more complex model to understand
        for authors than "just function"

c. using functions
    Pros:
      * no need to remember particular callback method name
      * when looking at listener registration function, it is clearer
        than (a) and (b) which function will be called
      * for APIs like DOM Events, separate functions can be identified
        per registration, meaning no manual discrimination required
      * no extra state needed to track callbacks where the source is not
        identified (like requestAnimationFrame), it can be encoded into
        the identity of the callback function or into its argument list
        with bind()
      * simpler implementation and simpler model to understand for
        authors than "function or object-with-property with defined
        precedence between the two"
    Cons:
      * listener registration calls are longer than (a) and (c)
      * result of bind() needs to be stored so that removeEventListener
        works, and this can't be done nicely within the object
        initialisation syntax


Another argument proponents of (a) or (b) could make is consistency with 
existing APIs.  For existing object-accepting callback APIs, we're 
already inconsistent though; some use handleEvent, others use other 
names.  We also don't accept objects everywhere -- event listener 
properties (onfoo) and setTimeout/setInterval come to mind.

People will weight the pros and cons differently, I imagine.  But for me 
based on the above I prefer (c).

Received on Friday, 16 December 2011 03:07:09 UTC