Splint.js: Functional first-aid for legacy jQuery applications

We're working on the next version of our web-based management User Interface (UI) in Hoplon, a ClojureScript framework, and we're really excited about how much faster, more robust, and easier to use it will be.

In the meantime, we will continue to support and improve our existing UI, much of which is written in JavaScript. Unfortunately, like any successful JavaScript code that's a little heavy on jQuery selectors and callbacks, and a little light on architecture, parts of it are pretty gnarly.

There are a lot of cool new ways to rewrite JavaScript applications, but there aren't many cool ways to incrementally improve them in place. That's why we made splint.js. splint is a packaging of some ClojureScript goodies that can be retrofitted on legacy JavaScript/jQuery applications. splint provides:

  • ClojureScript's immutable collections
  • A wealth of ClojureScript functions to support functional programming
  • An abstract spreadsheet library, Javelin, for modeling state
  • an optional jQuery plugin for attaching forms to Javelin cells, and vice versa
  • a conceptual model for consolidating, subsuming, and simplifying legacy code using the above tools

If you're familiar with Hoplon and ClojureScript, splint will make sense to you and you can go start playing with it.

If not, no worries. Read on for a demonstration of overhauling some jQuery code with splint.

Example: Counting Letters

Consider the text box below. When you type in it, the letter count updates:

Letter Count:

A typical legacy implementation of this using jQuery might look something like this:

HTML

<input id="js-input-1" type="text"/>

<p><em>Letter Count:</em> <span id="js-output-1"></span></p>

JavaScript

(function () {
  function getCount() {
    return $("#js-input-1").val().length;
  }

  function updateCount(newLength) {
    $("#js-output-1").text(getCount());
  }

  $(function() {
    updateCount();
    var inputEvents = "propertychange keyup input paste";
    $("#js-input-1").bind(inputEvents, function(e) {
      updateCount();
    })
  });
})()

Notes so far

Control flow begins with $(function () {...}), which schedules a callback function for execution once the page has loaded. Once the page has loaded:

  1. updateCount() immediately updates the letter count in case there is input before the user has started typing.
  2. All inputEvents on the #js-input-1 text input are bound to another anonymous callback.
  3. This anonymous callback calls updateCount() any time an the user enters text resulting in an inputEvent firing.

Apparent in this program is an organizational style characteristic of legacy JavaScript: there is no model beyond callbacks for encoding the behavior of the program. It’s impossible to avoid callbacks in JavaScript, because they are the only way to collect user input from the DOM. What’s dangerous is using callbacks not just to collect input, but also to model the control flow of the entire program - its behavior.

The technique is dangerous because adding new outputs implies the modification of existing input callbacks, and adding new inputs might interfere with the behavior of existing outputs by changing evaluation order.

For example, suppose we wanted to add a new metric: the number of vowels in the input. Following the established pattern, we would create an HTML element to display output, write an update function, and call the update function where the input events are received. This would work fine until the state of some output depended on another output. Perhaps we want to show the ratio of vowels to letters. Once this happens, the order in which callbacks are fired is of critical import, because the callbacks can influence - through mutation of the DOM, that mutable variable that all callbacks share - the effect of subsequent callbacks.

Example: Counting Letters and Vowels

Let's go ahead and add the vowel-counting feature. After this, we'll try to reign it all in and finally get to using splint.

Letter Count:

Vowel Count:

HTML

<input id="js-input-2" type="text"/>

<p><em>Letter Count:</em> <span id="js-output-2"></span></p>
<p><em>Vowel Count:</em> <span id="js-output-3"></span></p>

JavaScript

(function () {
  function getCount() {
    return $("#js-input-2").val().length;
  }

  function getVowelCount() {
    var text = $("#js-input-2").val();
    return (text.match(/[aeiouy]/gi) || []).length
  }

  function updateCount() {
    $("#js-output-2").text(getCount());
  }

  function updateVowelCount() {
    $("#js-output-3").text(getVowelCount());
  }

  $(function() {
    updateCount();
    updateVowelCount();
    var inputEvents = "propertychange keyup input paste";
    $("#js-input-2").bind(inputEvents, function(e) {
      updateCount();
      updateVowelCount();
    })
  });
})()

The quagmire deepens

Adding a second output makes it clear that the program’s complexity does not remain constant as outputs are added. Both temporal “places” where input can happen - after page load, and before the input callback was wired - must be modified.

That said, it could be worse. So far the order updateCount and updateVowelCount are called in each “place” doesn’t matter because they don’t interact. Unfortunately, any real jQuery-based program that anyone would pay you to work on today has been around long enough to have accumulated interactions of this sort, vastly impeding comprehension.

The underlying issue with the shape legacy code like this tends to converge on is that you can't program with values. This sucks, because values are awesome. Values don't necessarily have relationships with other values, can be anonymous, and have no temporal implications. Values leave naming and control flow completely to the programmer, and a good functional program oriented around values is a succinct statement about the relationship between them.

This is the kind of programming we need to be able to do to write comprehensible and maintainable programs. It's time to show how splint can start to help us do that, even with legacy code.

Adding splint

We can start by downloading splint.min.js and jquery.splint.js from the splint.js repo and adding them to our page:

<script type="text/javascript" src="splint.min.js"></script>
<script type="text/javascript" src="jquery.splint.js"></script>

Identify Input Cells

The first order of business when approaching legacy jQuery with splint in hand is to identify input value sources and capture them as input cells. Cells in splint are similar to cells of a spreadsheet. They can contain either values or formulas, and they can be combined together to establish relationships between data in a managed way.

The splint jQuery plugin makes it easy to create a cell backed by a text input, which is exactly what we need to do first.

At the top of the existing code, let's establish a with(splint) {...} block where splint's methods are easily accessible. There we can create an input cell to contain the user input:

(function () {
  with(splint) {
    var inCell = $("#js-input-2").cellOf("propertychange",
                                         "keyup",
                                         "input",
                                         "paste");
  }
  function getCount() {
    return $("#js-input-2").val().length;
  ...

The value contained in inCell now mirrors the value of the string in the #js-input-2 input. The immediate advantage of this added level of indirection is that new code can depend on inCell instead of relying on intimate knowledge of DOM structure. This makes our program easier to adapt to changes in markup, because the DOM is now only observed in one place.

inCell may also coexist with other code that also observes the value of #js-input-2. We can incrementally modify old code depending directly on the DOM element into code that depends instead on inCell.

We can learn the value in inCell at any point by dereferencing it. splint.deref takes a cell and returns the value it currently contains.

For instance, we can look at the value of inCell after page load by logging a call to deref(inCell):

(function () {
  with(splint) {
    var inCell = $("#js-input-2").cellOf("propertychange",
                                         "keyup",
                                         "input",
                                         "paste");

    $(function() { console.log(deref(inCell)); });
  }
  function getCount() {
    return $("#js-input-2").val().length;
  ...

The ability to see a cell's value at a point in time is helpful when integrating with existing callback code, because by its nature a callback is always interested in the value of a cell at a point in time - the time the callback is run.

We're now ready for the first refactoring: we can modify the existing code to look at inCell instead of at the DOM. Our code now looks like this:

JavaScript

$(function () {
  with(splint) {
    var inCell = $("#js-input-2").cellOf("propertychange",
                                         "keyup",
                                         "input",
                                         "paste");
    function getCount() {
      return deref(inCell).length;
    }

    function getVowelCount() {
      var text = deref(inCell);
      return (text.match(/[aeiouy]/gi) || []).length
    }

    function updateCount() {
      $("#js-output-2").text(getCount());
    }

    function updateVowelCount() {
      $("#js-output-3").text(getVowelCount());
    }

    updateCount();
    updateVowelCount();
    $("#js-input-2").bind(inputEvents, function(e) {
      updateCount();
      updateVowelCount();
    })
  }
})

We also moved all code into the jQuery ready callback. Now we don’t have to think about the state of the page before the DOM is ready.

While our code is now looking at inCell, it's not driven by inCell. The callback function attached to events on #js-input-2 is still what drives computation. The next step is to move further away from callbacks and toward formulas.

Add a formula cell

Before we take the next step, let's create an anonymous formula cell that simply prints the value of inCell to the console as it changes. We can make one like this:

$(function () {
  with(splint) {
    var inCell = $("#js-input-2").cellOf("propertychange",
                                         "keyup",
                                         "input",
                                         "paste");

    formula(function(s){ console.log("inCell= "+s) })(inCell)
  }
  function getCount() {
    return deref(inCell).length;
  ...

splint.formula is a "higher-order function" for making formula cells out of functions. formula takes a function and returns a version of that function that can accept cell arguments.

In this example, we took a simple logging function - (function(s){ console.log(s); }) - and made a version of it with formula that continuously runs as the value of s changes. The return value of a formula(...) expression is a formula cell, which here, we have chosen not to name.

Formulas as continuous functions of dependencies

Our getCount and getVowelCount functions would be ideal functions to turn into formulas if they took an input argument instead of deref-ing inCell. Let's take that next step:

$(function () {
  with(splint) {
    var inCell = $("#js-input-2").cellOf("propertychange",
                                         "keyup",
                                         "input",
                                         "paste");

    var countCell = formula(function(s){return s.length;})(inCell);

    var vowelCountCell = formula(function(s) {
      return (s.match(/[aeiouy]/gi) || []).length
    })(inCell);

    function updateCount(newLength) {
      $("#js-output-2").text(newLength);
    }

    function updateVowelCount(newCount) {
      $("#js-output-3").text(newCount);
    }

    updateCount(deref(countCell));
    updateVowelCount(deref(vowelCountCell));
    $("#js-input-2").bind(inputEvents, function(e) {
      updateCount(deref(countCell));
      updateVowelCount(deref(vowelCountCell));
    })
  }
})

With this change, no input values are received except through inCell and the formulas that depend on it.

Output formulas

Now that we have isolated our inputs and converted our functions of the DOM into formulas of values, it's time to emit values back into the DOM - without callbacks. The splint jQuery plugin helps us here - it patches several jQuery mutator functions with versions that can accept cells. We can wire cells to places in the DOM using the cell-aware .text method on jQuery objects:

$(function () {
  with(splint) {
    var inCell = $("#js-input-2").cellOf("propertychange",
                                         "keyup",
                                         "input",
                                         "paste");

    var countCell = formula(function(s){return s.length;})(inCell);

    var vowelCountCell = formula(function(s) {
      return (s.match(/[aeiouy]/gi) || []).length
    })(inCell);

    $("#js-output-2").text(countCell);
    $("#js-output-3").text(vowelCountCell);
  }
})

With this step we've eliminated our reliance on intermediate callbacks completely. Browser and jQuery callbacks still start our program and supply input values, but the flow of data after that is entirely managed by cells.

One last tweak

splint provides dozens of ClojureScript functions and immutable data structures that can be leveraged from JavaScript. One of the functions, splint.count, is a generic counting function that returns the length of strings, arrays, and various other sequential types. We can use it to shorten countCell:

var countCell = formula(count)(inCell);

Let us now revel in the identical but now splint-powered and vastly simpler and more extensible program:

$(function () {
  with(splint) {
    var inCell = $("#js-input-3").cellOf("propertychange",
                                         "keyup",
                                         "input",
                                         "paste");

    var countCell = formula(count)(inCell);

    var vowelCountCell = formula(function(s) {
      return (s.match(/[aeiouy]/gi) || []).length
    })(inCell);

    $("#js-output-4").text(countCell);
    $("#js-output-5").text(vowelCountCell);
  }
})

Letter Count:

Vowel Count:

Wrapping up

Thanks for following along! I hope you find splint a useful tool for extending, maintaining, and debugging legacy jQuery-based applications.

If you're curious to learn more about the difficulties inherent in browser programming and how they can be mitigated with the dataflow paradigm, I encourage you to check out this paper about Flapjax.

Like the article?

Get notified of future blog posts. Don't worry - we won't make it hard to get to inbox zero: no more than 2 e-mails a month. We promise.

Use header bidding for your site? Learn more about Adzerk's new sub-brand, ServerBid, the first and only completely independent server-side header bidding platform.