Object-Oriented Event Listening through Partial Application in JavaScript

来源:互联网 发布:雅马哈电缸软件 编辑:程序博客网 时间:2024/06/05 11:34

原文在这里

http://www.brockman.se/writing/method-references.html.utf8

Object-Oriented Event Listening through Partial Application in JavaScript

Daniel Brockmandaniel@brockman.seJuly 23, 2004Revised April 29, 2008

Abstract

A general solution to the simple but common problem of attaching event listeners to HTML elements owned by object-oriented code is presented. The solution does not rely on the questionable method of injecting hidden backreferences to the application logic into the HTML elements; rather, the listeners are connected to the application logic in a purely hygeinic fashion through the use of lexical closures.

Method references

The following code does not do what the programmer intended it to:

  1. function GreetingButton(greeting) {
  2.     this.greeting = greeting;
  3.     this.element = document.createElement("button");
  4.     this.element.appendChild(document.createTextNode("Receive Greeting"));
  5.     this.element.onclick = this.greet;
  6. }
  7. GreetingButton.prototype.greet = function () {
  8.     alert(this.greeting);
  9. };
  10. onload = function () {
  11.     gb = new GreetingButton("Hello!");
  12.     document.body.appendChild(gb.element);
  13.     gb.greet();
  14. };

Upon loading the document, the expression gb.greet() will be evaluated, which will produce the greeting Hello!. However, upon clicking the button labelled Receive Greeting, the string undefined will appear instead of Hello!. The reason is that in the latter case, this in the expression alert(this.greeting) will refer to the HTML button that was clicked—not, as in the former case, gb, the GreetingButton object.

When we say gb.greet, we get the exact same function object as we get by saying GreetingButton.prototype.greet—a function object that obviously has no way of knowing that, in this case, we would like to think of it as a method of the object gb. In JavaScript, the exact same function object may act as a method for any number of different objects: the relevant object is supplied by the caller in each case and bound to the special variable this.

Consider what would happen if we created another greeting button—let's call it foo. Using foo, we would have another way of referring to the same old function object: foo.greet. The expressions gb.greet and foo.greet would be entirely equivalent.

With this background, one immediately realizes that the line this.onclick = this.greet cannot do the right thing, because it is equivalent to this.onclick = foo.greet.

Let me remark also that although the expressions gb.greet and foo.greet are equivalent, the similar expressions gb.greet() and foo.greet() are not.

To side-step the problem, you might consider doing this:

  1. function GreetingButton(greeting) {
  2.     this.greeting = greeting;
  3.     this.element = document.createElement("button");
  4.     this.element.appendChild(document.createTextNode("Receive Greeting"));
  5.     this.element.greetingButton = this;
  6.     this.element.onclick = this.greet;
  7. }
  8. GreetingButton.prototype.greet = function () {
  9.     alert(this.greetingButton.greeting);
  10. };
  11. onload = function () {
  12.     gb = new GreetingButton("Hello!");
  13.     document.body.appendChild(gb.element);
  14.     gb.greet();
  15. };

This code works as intended, but the solution is inelegant. While it is unlikely that the name greetingButton will ever conflict with anything, you’re still essentially polluting someone else’s namespace.

Another problem with this approach is that it theoretically breaks encapsulation, since you are in fact setting up a backdoor to your logic. Anyone with access to the HTML document that your element is attached to could aquire a reference to your backdoored element, and, by extension, your private data. This will probably not present itself as a practical problem to anyone, but it necessarily remains a fundamental inelegancy.

Binding a variable to the value of this and then closing over it is a superior approach:

  1. function GreetingButton(greeting) {
  2.     this.greeting = greeting;
  3.     this.element = document.createElement("button");
  4.     this.element.appendChild(document.createTextNode("Receive Greeting"));
  5.     var greetingButton = this;
  6.     this.element.onclick = this.greet;
  7.     this.element.onclick = function () {
  8.         greetingButton.greet();
  9.     };
  10. }
  11. GreetingButton.prototype.greet = function () {
  12.     alert(this.greeting);
  13. };
  14. onload = function () {
  15.     gb = new GreetingButton("Hello!");
  16.     document.body.appendChild(gb.element);
  17.     gb.greet();
  18. };

Essentially, a closure, or lexical closure, is a function f coupled with a snapshot of its lexical environment (i.e., the non-local variable bindings used in its body).

Hence, closing over some variable v means creating a closure that refers to v.

Strictly, the term “closure” can be used to describe any function that refers to one or more variables in an outer lexical scope. This is a rather broad definition that includes, for example, all functions that refer to global variables (such as document, alert, String, and so on).

Some closures are more worthy of the classification. Firstly, it is common to exclude functions whose only outer scope is the global scope. That is, global functions are usually not described as closures. Secondly, the interesting kind of closures are those that really utilize the power leveraged by lexical scoping. I try to use the term only when f has the following characteristics:

  • The body of f appears within the body of another function g (this excludes global functions).
  • Some variables that are bound by g are referred to in the body of f (this excludes functions that only refer to the global environment).
  • The function f can be called by code that does not appear within the body of g, making f a kind of interface between its environment and external code (this excludes functions that are not utilizing the power of lexical scoping).

The canonical example of a closure is the counter:

  1. function makeCounter(start) {
  2.     var current = start;
  3.     function counter() { return current++; };
  4.     return counter;
  5. }

Here, our f — the closure — is counter, which appears within the body of makeCounter (our g). The variable current is bound by makeCounter and referred to in the body of counter. Finally, counter can be called by code that does not appear within the body of makeCounter because it is returned to the outside code. In case you were wondering, makeCounter could equivalently be written like so:

  1. function makeCounter(current) {
  2.     return function () { return current++; };
  3. }

It is important to understand that a new closure is created and returned every time makeCounter is called. The parallels to object-oriented programming are obvious and indeed interesting. If you are confused about closures and lexical scoping, I recommend the classic Structure and Interpretation of Computer Programs (they just call them procedures, though—not closures).

You might wonder why we can’t just close over this, instead of declaring a proxy variable and closing over that.

  1. var greetingButton = this;
  2. this.onclick = function () {
  3.     greetingButton.greet();
  4.     this.greet();
  5. }

The reason why this approach won’t work is that this is always bound anew every time a function is called, so the outer binding would be shadowed by a new binding of this.

Refactoring this a bit, we get the following:

  1. function GreetingButton(greeting) {
  2.     this.greeting = greeting;
  3.     this.element = document.createElement("button");
  4.     this.element.appendChild(document.createTextNode("Receive Greeting"));
  5.     this.element.onclick = function () {
  6.         greetingButton.greet();
  7.     };
  8.     this.element.onclick = createMethodReference(this"greet");
  9. }
  10. GreetingButton.prototype.greet = function () {
  11.     alert(this.greeting);
  12. };
  13. function createMethodReference(object, methodName) {
  14.     return function () {
  15.         object[methodName]();
  16.     };
  17. };
  18. onload = function () {
  19.     gb = new GreetingButton("Hello!");
  20.     document.body.appendChild(gb.element);
  21.     gb.greet();
  22. };

And thus we arrive at the essential point of this article. We have just discovered a basic method for “welding” a function and an object, forming what we have been calling a method reference.

I call this form of event listening “object-oriented,” because instead of attaching callback functions, you attach callback methods. When doing object-oriented programming, an equivalent or similar facility is nearly essential.

I say “through partial application” because partial application—i.e., informally, calling a function with only a subset of its arguments and getting back the remainder—is actually what createMethodReference does.

The concept of partial application can be visualized by thinking of a function as a box with a number of empty slots—one for each argument that the function expects. Partially applying the function to some arguments, then, amounts to plugging just those arguments into the appropriate slots, producing a new box with fewer empty slots.

Now note that a method can be thought of as a function taking an extra first argument (i.e., the new value of this). Thus, when we call createMethodReference(o, "m"), we are just plugging o into the first slot (or, perhaps more accurately, the “zeroth” slot) of the function o.m—partial application.

Letting external arguments pass through method references

When you click a button on a web page, the browser calls the event listener referred to by the element’s onclick property. It sends one argument to the event listener: the event object, which says things like the exact coordinates at which the click occured.

However, using the above definition of createMethodReference, an event listener method has no way of accessing the event object. Therefore, we would like the method reference to pass on whatever arguments it receives—the event object, in this case—to the method it references.

  1. function GreetingButton(greeting) {
  2.     this.greeting = greeting;
  3.     this.element = document.createElement("button");
  4.     this.element.appendChild(document.createTextNode("Receive Greeting"));
  5.     this.element.onclick = createMethodReference(this"greet");
  6. }
  7. GreetingButton.prototype.greet = function (event) {
  8.     alert(this.greeting);
  9. };
  10. function createMethodReference(object, methodName) {
  11.     return function () {
  12.         object[methodName]();
  13.         object[methodName].apply(object, arguments);
  14.     };
  15. };
  16. onload = function () {
  17.     gb = new GreetingButton("Hello!");
  18.     document.body.appendChild(gb.element);
  19.     gb.greet();
  20. };

The apply method on functions is analogous to the apply functions found in Common Lisp and Scheme, except that the this argument must be given separately.

In pseudocode, f.apply(o, as) means roughly this:

  1. o.___tmp___ = f;
  2. o.___tmp___(as[0], as[1], as[2], ...);

It might help to note that o.m.apply(o, [a, b, c]) is exactly equal to o.m(a, b, c).

Note that arguments in the expression apply(object, arguments) refers to the arguments of the inner, anonymous function—not the createMethodReference function itself.

But wait. In making the greet method take an event object, we have introduced a subtle inconsistency: our code is explicitly calling the greet method without an event object. We’ll just add some indirection to clean this up:

  1. function GreetingButton(greeting) {
  2.     this.greeting = greeting;
  3.     this.element = document.createElement("button");
  4.     this.element.appendChild(document.createTextNode("Receive Greeting"));
  5.     this.element.onclick = createMethodReference(this"greet");
  6.     this.element.onclick = createMethodReference(this"buttonClicked");
  7. }
  8. GreetingButton.prototype.buttonClicked = function (event) {
  9.     this.greet();
  10. };
  11. GreetingButton.prototype.greet = function (event) {
  12.     alert(this.greeting);
  13. };
  14. function createMethodReference(object, methodName) {
  15.     return function () {
  16.         object[methodName].apply(object, arguments);
  17.     };
  18. };
  19. onload = function () {
  20.     gb = new GreetingButton("Hello!");
  21.     document.body.appendChild(gb.element);
  22.     gb.greet();
  23. };

Making method references not look like eval

You might want to be able to pass any function to createMethodReference instead of just a method name. That way, you can create method references to functions which aren’t really “methods” of the object in question (you might, for instance, be participating in an obfuscated JavaScript contest).

  1. function createMethodReference(object, methodName) {
  2.     if (!(method instanceof Function))
  3.         method = object[method];
  4.     return function () {
  5.         method.apply(object, arguments);
  6.     };
  7. };

But perhaps more convincing is the fact that we no longer have to deal with strings whose content are more or less code:

  1. this.element.onclick = createMethodReference(this"buttonClicked");
  2. this.element.onclick = createMethodReference(thisthis.buttonClicked)

Taking special measures for event listeners

If you want to be compatible with Internet Explorer (as of 2005), you usually have to put this code at the top of every event listener:

  1. event = event || window.event;

We could say that more succintly with a logical assignment operator if we had ECMAScript 4 (also known as JavaScript 2.0).

To avoid always having to do that, you can pull this logic into the function that creates the event listener method references:

  1. function createEventListenerMethodReference(object, methodName) {
  2.     return function (event) {
  3.         object[methodName].call(object, event || window.event);
  4.     };
  5. }

In this case we assume that the method always takes exactly one argument, so we do not have to use apply to pass the arguments.

The call method on functions is to Common Lisp’s funcall as the apply method is to Common Lisp’s and Scheme’s apply.

In other words, call is just like apply, except that instead of taking an array of arguments, it simply takes the actual arguments one after another. Thus, f.call(o, a, b, c) is exactly the same as f.apply(o, [a, b, c]).

Giving method references a respectable look

The name createMethodReference is clearly long and awkward. We can improve on it by stealing a better name from Ruby, in which you can do the equivalent of createMethodReference by first obtaining an UnboundMethod (which is a roundabout way of saying “function”), and then binding it to some object. This is the terminology we’re looking for, so we’ll just rename our function to bind and put it on the prototype of Function.

  1. Function.prototype.bind = function (object) {
  2.     var method = this;
  3.     return function () {
  4.         method.apply(object, arguments);
  5.     };
  6. }

Note again how we had to create a variable binding in order to close over the value of this. As previously stated, every function implictly binds this, so the above code works like this:

  1. Function.prototype.bind = function (object) {
  2.     var this = <object 1>;
  3.     var method = this;
  4.     return function () {
  5.         var this = <object 2>;
  6.         method.apply(object, arguments);
  7.     };
  8. }

Clearly, it wouldn’t do the same thing if it looked like this:

  1. Function.prototype.bind = function (object) {
  2.     var this = <object 1>;
  3.     var method = this;
  4.     return function () {
  5.         var this = <object 2>;
  6.         method.apply(object, arguments);
  7.         this.apply(object, arguments);
  8.     };
  9. }

Now we can use it like this:

  1. this.element.onclick = createMethodReference(thisthis.buttonClicked);
  2. this.element.onclick = this.buttonClicked.bind(this)

Of course, you can do the same with the Internet Explorer-compatible event listener method reference creator:

  1. Function.prototype.bindEventListener = function (object) {
  2.     var method = this;
  3.     return function (event) {
  4.         method.call(object, event || window.event);
  5.     };
  6. }

More partial application (less-partial application)

We can already do partial application on the this argument, but we can just as easily allow for more general partial application:

  1. // Most implementations don’t like expressions such as foo.concat(arguments)
  2. // or arguments.slice(1), due to a kind of reverse duck typing: an argument
  3. // object looks like a duck and walks like a duck, but it isn’t really a
  4. // duck and it won’t quack like one.
  5. function toArray(pseudoArray) {
  6.     var result = [];  // This is our real duck.
  7.     for (var i = 0; i < pseudoArray.length; i++)
  8.         result.push(pseudoArray[i]);
  9.     return result;
  10. }
  11. Function.prototype.bind = function (object) {
  12.     var method = this;
  13.     var oldArguments = toArray(arguments).slice(1);
  14.     return function () {
  15.         method.apply(object, arguments);
  16.         var newArguments = toArray(arguments);
  17.         method.apply(object, oldArguments.concat(newArguments));
  18.     };
  19. }

With this definition of bind, a call such as f.bind(o, 1, 2)(3, 4) will be rendered as f.call(o, 1, 2, 3, 4). The corresponding redefinition of bindEventListener

  1. Function.prototype.bindEventListener = function (object) {
  2.     var method = this;
  3.     var oldArguments = toArray(arguments).slice(1);
  4.     return function (event) {
  5.         method.call(object, event || window.event);
  6.         method.apply(object, [event || window.event].concat(oldArguments));
  7.     };
  8. }

will cause f.bindEventListener(o, "moomin")("snufkin") to be rendered as f.call(o, event, "moomin", "snufkin"). This is handy whenever you want to attach an event listener in a certain “mode”. For example, you might have a grid of cells, each of which should react to click events.

  1. function GridWidget(width, height) {
  2.     // Create elements and populate cell array here.
  3.     for (var x = 0; x < width; x++)
  4.         for (var y = 0; y < height; y++)
  5.             cells[x][y].element.onclick =
  6.                 this.cellClicked.bindEventListener(this);
  7. }
  8. GridWidget.prototype.cellClicked = function (event) {
  9.     alert("I have no idea which cell you just clicked.");
  10. };

As the message says, it is not obvious how to find out which one of the width × height different cells generated the click event, because all cells trigger the same event listener. Certainly it would be ridiculous to attempt to manually define a separate event listener function for each cell, especially since the number of cells may even be variable.

The solution, of course, is declaring the event listener to take two additional arguments giving the cell coordinates, and using partial application to plug these values in during object initialization.

  1. function GridWidget(width, height) {
  2.     // Create elements and populate cell array here.
  3.     for (var x = 0; x < width; x++)
  4.         for (var y = 0; y < height; y++)
  5.             cells[x][y].element.onclick =
  6.                 this.cellClicked.bindEventListener(this, x, y);
  7. }
  8. GridWidget.prototype.cellClicked = function (event, x, y) {
  9.     alert("I have no idea which cell you just clicked.");
  10.     alert("You clicked the cell at (" + x + ", " + y + ")!");
  11.     this.cells[x][y].frobnicate();
  12. };

It should not be hard to guess that it is useful to make the method references pass on any values that the real methods return.

  1. Function.prototype.bind = function (object) {
  2.     var method = this;
  3.     var oldArguments = toArray(arguments).slice(1);
  4.     return function () {
  5.         var newArguments = toArray(arguments);
  6.         return method.apply(object, oldArguments.concat(newArguments));
  7.     };
  8. }
  9. Function.prototype.bindEventListener = function (object) {
  10.     var method = this;
  11.     var oldArguments = toArray(arguments).slice(1);
  12.     return function (event) {
  13.         return method.apply(object, [event || window.event].concat(oldArguments));
  14.     };
  15. }

For example, it is often necessary to return false from an event listener to signal that the default action should be suppressed.

Finally, here is a way to enable destruction of method references:

  1. var destructMethodReference = new Object;
  2. Function.prototype.bind = function (object) {
  3.     var method = this;
  4.     var oldArguments = toArray(arguments).slice(1);
  5.     return function (argument) {
  6.         if (argument == destructMethodReference) {
  7.             method = null;
  8.             oldArguments = null;
  9.         } else if (method == null) {
  10.             throw "Attempt to invoke destructed method reference.";
  11.         } else {
  12.             var newArguments = toArray(arguments);
  13.             return method.apply(object, oldArguments.concat(newArguments));
  14.         }
  15.     };
  16. }

Now, to destruct a method reference, just invoke it with the special object destructMethodReference as argument. Being able to destruct method references is useful when working around the Internet Explorer reference cycle memory leak.

There is a bug in Internet Explorer that prevents its garbage collector from reclaiming objects that are part of cyclic reference chains which run through at least one DOM object (i.e., element). The GridWidget code above creates creates one such reference cycle for each grid cell. Specifically, the cycle is constructed as follows:

  • each cell element has a reference to a method reference;
  • each method reference has a reference to the GridWidget object;
  • the GridWidget object has one reference to each cell element.

Since cell elements are DOM objects, Internet Explorer fails to reclaim this cycle of objects and a memory leak results. The solution to this problem is to break the cycles when you no longer need them. You can do this in the onunload event handler.

Oh, and if you don’t like the global helper function toArray, just make it a local helper function:

  1. (function () {
  2.      function toArray(pseudoArray) {
  3.          var result = [];
  4.          for (var i = 0; i < pseudoArray.length; i++)
  5.              result.push(pseudoArray[i]);
  6.          return result;
  7.      }

  8.      Function.prototype.bind = function (object) {
  9.          var method = this;
  10.          var oldArguments = toArray(arguments).slice(1);
  11.          return function () {
  12.              var newArguments = toArray(arguments);
  13.              return method.apply(object, oldArguments.concat(newArguments));
  14.          };
  15.      }

  16.      Function.prototype.bindEventListener = function (object) {
  17.          var method = this;
  18.          var oldArguments = toArray(arguments).slice(1);
  19.          return function (event) {
  20.              return method.apply(object, [event || window.event].concat(oldArguments));
  21.          };
  22.      }
  23. })();
Acknowledgements

I want to thank the following people for their help in writing and revising this article.

My dear friend Peter Wängelin provided the initial motivation for the article by asking some good questions that I felt deserved some good, written answers.

Sam Stephenson promoted the article by linking to it from his website and suggested to people that they read it. He also put the bind method into his JavaScript library, Prototype, which is now bundled with Ruby on Rails. Very cool!

Mathieu van Loon corrected an error in the definitions of bind and bindEventListener, and suggested that method references should pass on the return values of the real methods.

Chih-Chao Lam pointed out the fact that argument objects cannot portably be used as arrays.

Ken Tozier asked whether one could avoid defining the helper function toArray globally.

Gary Hall, as well as a few other people, pointed out that Internet Explorer has a problem with cyclic references through DOM objects.

Jordan Gray spotted an error in bindEventListener.

At least a few other people have linked to the article from various places on the web, and I want to thank them too.

I’ve been revising this article occasionally for a couple of years now, and I wouldn’t have found the motivation to do so if it weren’t for thoughtful people like Anders Blomgren, Richard Davies, Chad Burggraf and Nick Eby, who mailed me and thanked me for writing it. I think every such message has spurred a revision—sometimes major, sometimes minor. The article would be nowhere near as polished as it is if I weren’t reminded of it from time to time.

Copying

Copyright © 2004, 2005, 2006, 2007, 2008 Daniel Brockman.

Permission is granted to copy, distribute and/or modify this document under the terms of the GNU Free Documentation License, Version 1.2 or any later version published by the Free Software Foundation; with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. Copies of the GNU Free Documentation License can be obtained at the following URL: http://www.gnu.org/licenses/

In addition, I hereby place all the JavaScript source code in this article into the public domain. If that is not possible, anyone is permitted to use it for any purpose.

原创粉丝点击