Permalink
Find file
Fetching contributors…
Cannot retrieve contributors at this time
161 lines (120 sloc) 8.27 KB
layout category tags
post
webdev
javascript

I'm going to talk about something today that I was introduced to by a friend, which is such a ridiculously simple bit of code that I'm going to feel a little silly writing an entire post about it, but I think it might be worthwhile.

function Undoer() {
  this.actions = [];
}

Undoer.prototype.record = function (fn) {
  this.actions.push(fn);
}

Undoer.prototype.run = function () {
  while (this.actions.length > 0) {
    this.actions.pop()();
  }
}

All it does is hold an array, some functions, and eventually calls them in reverse order. Big deal, right? It's actually surprisingly powerful and useful with just a couple of additional helper functions, and applied in the right circumstances.

Undoer#onoff

Undoer.prototype.onoff = function (target) {
  var args = Array.prototype.slice.call(arguments, 1);
  target.on.apply(target, args);
  this.record(target.off.bind.apply(target, args));
}

Suddently we have a great tool for managing event subscriptions. To show its use, we can try and use it to simplify a fairly common issue in JavaScript event handling, which is dealing with dragging. In a naive implementation of element drag, you bind mousedown, mousemove, and mouseup to some element that you want to add drag functionality to. We're also going to add a little bit of utility, such that there's a shared object passed between drag events. This can be very handy when doing coordinate calculations and moving elements.

(I'm going to assume we have jQuery available for this, but it would work given any event wrapper exposing on/off functions for event binding, and simplifies the demonstration.)

function assignDrag(target, onstart, onmove, onend) {
  var drag_context = {};

  $(target).on('mousedown', function (e) { onstart.call(this, e, drag_context); })
           .on('mousemove', function (e) { onmove.call(this, e, drag_context); })
           .on('mouseup', function (e) { onend.call(this, e, drag_context); });
}

This will work rather well, up until the point you drag near the edge, and your mouse leaves the element before you can move the element underneath. The solution? Lazily adding the mousemove and mouseup handlers to the document on mousedown, and removing them again on mouseup.

function assignDrag(target, onstart, onmove, onend) {
  var doc = $(document),
      drag_context = {};

  $(target).on('mousedown', function (e) {
    onstart.call(this, e, drag_context);
    doc.on('mousemove', bound_move);
    doc.on('mouseup', bound_end);
  });

  function bound_move(e) {
    onmove.call(this, e, drag_context);
  }

  function bound_end(e) {
    onend.call(this, e, drag_context);
    doc.off('mousemove', bound_move);
    doc.off('mouseup', bound_end);
  }
}

Well, it still works, but it's gained a bit of weight. Conceptually we're doing something very similar to before, but because of the need for unbinding we have to be a bit more careful with anonymous functions so that we can unbind them later. Would an Undoer help here?

function assignDrag(target, onstart, onmove, onend) {
  var u = new Undoer(),
      doc = $(document),
      drag_context = { undoer: u };

  $(target).on('mousedown', function (e) {
    onstart.call(this, e, drag_context);
    u.onoff(doc, 'mousemove', function (e) {
      onmove.call(this, e, drag_context);
    });
    u.onoff(doc, 'mouseup', function (e) {
      onend.call(this, e, drag_context);
      u.run();
    });
  });
}

With Undoer.onoff managing our subscriptions, we don't have to worry about naming our trivial functions. As long as we run the undoer we're guaranteed to remove all the callbacks. We can also pass the undoer along inside the context. This could be useful on a drag if you needed to create auxillary elements when starting the drag and wanted to ensure they were removed when the drag completed. The onstart callback can then setup those elements and ensure they are removed, without having to remember about it in the onend callback.

The undoer could also be run by the onstart/onmove functions if a drag wants to cancel itself. We get the ability to cancel a drag from the inside for free, because we've packaged up the cleanup. But what if there are side effects inside the drag, and we don't want to allow the drag to be cancelled like that? We now have two separate object lifecycles (the drag, and side effects within the drag) and to deal with that neatly, we need another helper.

Undoer#child

Undoer.prototype.child = function () {
  var undoer = new Undoer();
  this.record(undoer.run.bind(undoer));
  return undoer;
}

All this does is create a new undoer, add its run function to our actions, and return the new undoer. Now we can deal with hierarchies.

drag_context = { undoer: u.child() }

With this change, the onstart and onmove (and onend, but that's probably pointless) functions can safely make actions with side effects and be sure that they're cleaned up when the drag completes.

This ability to form hierarchies becomes very useful when dealing with MVC style applications, and ensuring that views clean up after themselves even in the presence of nested views or other elements they inject into the page. Each subview can be passed its own undoer which is a child of the parents undoer, and then if the parent view is ever removed, the subviews side effects are guaranteed to be cleared up. This isn't so much of a gain if your Models and Views lifecycles are tightly coupled, but when you can have long-lived Models and short-lived views, it provides an easy verification that you're not leaking subscriptions and thwarting the garbage collector.

Helpers are cheap and powerful

Not quite right for your needs? Remember that extending it to add different management options for the actions is just a prototype away. I haven't created a repository, put a 10 line file up, given it a silly name like 'Remembrall' and published it on npm, because the core is so small and it's best to adapt and extend it to your own needs as they arise. I'm including a couple of examples here of simple extensions for managing other side effects.

Adding/removing a DOM element

Maybe we need to add elements inside of others, as might be the case of plugins which inject into different areas of an application, rather than being neatly contained.

Undoer.prototype.appendChild = function (parent, child) {
  parent.appendChild(child);
  this.record(function () {
    try {
      child.parentNode.removeChild(child);
    } catch (exc) {}
  });
}

Limiting the action queue

If we wanted to display a trail of elements behind our cursor when dragging, we could record those elements with appendChild as defined above, and then call limitActions each time to remove the oldest one, keeping the most recent n. And the trail would be automatically collected when we finished dragging.

Undoer.prototype.limitActions = function (n) {
  while (this.actions.length > n) {
    a.shift()()
  }
}

A note on Metrics

At some point you might want to ensure that your undoers are being called and disposed of correctly, and it's also rather interesting in large applications to see just how many Views you have flying around the place. To facilitate this, you just have to add a name parameter to the constructor and child helper, and then increment a counter inside the constructor based on the name, and decrement on run. You might need to fiddle with this logic if you call run multiple times on an undoer, of course.

Why not redo?

You may have noticed that the helper methods like onoff actually have enough information to implement basic redo functionality as well, so I thought I'd note why it's not part of the Undoer. A big reason is because I've never found the need for it. Almost always when managing lifecycles and side-effects, you're just wanting to ensure that everything gets cleaned up in future.

Where I've needed redo functionality, it's inevitably been tied to user input relative to a document, and in that case a centralised manager utilising the Command Pattern is a lot easier to reason about, because the actions you want to step forward/backward become a lot more complicated at the document level, compared with simple subscriptions.