Web Platform

Part 1: DOM chaining | jQuery's not .done yet

16 min read

jQuery, first released all the way back in 2006, has lost a lot of goodwill in recent times and is undoubtedly seen as uncool and aged technology. Despite having a bad reputation, its market share has hardly decreased in recent times, and it remains present on close to 80% of the top 10 million sites. If anything, perhaps shockingly, its prevalence has actually increased the past couple years.

Its impact on the web platform cannot be overstated. .classList, .querySelector, CORS (through the widespread use of the highly insecure JSONP), dataset and data- attributes, CSS animations and transitions, .replaceWith, .append and .prepend, .remove, fetch, and countless more functions have been added by browsers over the years as a result. Many of these methods are even poorly known as other frameworks have taken the spotlight since, and old tutorials for working with the DOM were never updated.

Even JavaScript as a language has much to thank jQuery for. Methods like $.each, $.map, $.grep (filter), $.merge (spreads), Date.now, ''.trim, and many more have landed in the language proper as part of ES5 and ES2015. Deferreds popularized Promises even before Node.js gained notoriety for its often-seen ‘callback hell’. The ergonomics of chaining (returning this from a method, also known as fluent interfaces) inspired countless other influential libraries like Underscore (Lodash’s predecessor) and d3.

Despite all these amazing improvements, the web platform (“vanilla JS”) remains a pain to use. The core language is better than ever, but even the newer additions to the DOM missed the mark. Even though evergreen browsers should have made jQuery obsolete, the APIs offered by browser makers continue to be lackluster. They managed to succeed in stabilizing the web platform and enabling rapid innovation, but have left developers who would prefer to work without a framework in the dust.

Four recurring pain points come to mind;

  1. Most DOM methods, including newer ones, cannot be chained; the majority instead return a meaningless undefined, requiring multiple lines of code to perform simple tasks.
  2. NodeLists (return type of document.querySelectorAll and others) need to be iterated over manually when changes to the underlying nodes are desired, whereas in jQuery methods called on $ collections apply to all the matched nodes
  3. DOM methods and properties are needlessly verbose
  4. Events and lifecycles are complicated; features like event delegation and web components just create more problems and introduce additional complexity

By no means is this a complete list of all the issues found in the modern web platform. We need to start somewhere, and in my opinion tackling these issues would at least solve a lot of existing issues with ergonomics. More fundamental redesigns of the browser APIs, like a declarative rendering framework akin to React, built-in right to the browser would be very welcome too, but this does not appear to be on the radar of browser makers. Even just a diffing algorithm that would apply a new DOM without throwing away existing event handler registrations would go a long way, though.

In this series, jQuery’s not .done yet, I will be going over these issues in detail and offer a concise way to solve the problems. The proposed solutions make for a great learning experience, but please do not use them in a production website. Reflection and aliasing methods and properties will make your successor very sad. Stick to jQuery or an existing framework, or bite the bullet and write out the DOM boilerplate.

Chaining and the developer experience

Read section Chaining and the developer experience

What if instead of writing element. over and over again;

const element = document.createElement("div");
element.classList.add("p-4"); // returns undefined
element.classList.add("border"); // returns undefined
element.style.height = `${window.innerHeight}px`; // assignment operator returns this string value
element.addEventListener("click", () => {}); // returns undefined
element.addEventListener("mouseenter", () => {}); // returns undefined
element.setAttribute("aria-label", "Example"); // returns undefined
document.body.appendChild(element); // actually returns `element`, but at this point we're already done with it

We could write it fluently?

// Non-functional pseudocode
new HTMLDivElement()
  // This method could be overloaded to support setters (passing an array, Set, or string),
  // getter (no arguments), and this function style which allows for more
  // arbitrary conditions without breaking chaining
  .class((list) => list.add("p-4").add("border"))
  .style({ height: `${window.innerHeight}px` })
  .attribute({ "aria-label": "Example" })
  .on("click", () => {})
  .on("mouseenter", () => {})
  .appendTo(document.body);

Or even allow the constructor to do much of the work, similar to the concept behind HyperScript?

// Non-functional pseudocode
document.body.appendChild(
  document.createElement("div", {
    class: "p-4 border",
    style: { height: `${window.innerHeight}px` },
    attributes: { "aria-label": "Example" },
    events: { click() {}, mouseenter() {} },
  })
);

Two ergonomic problems can be identified:

  1. Many methods, like addEventListener, return undefined
  2. Properties need to be set directly on the element (no setter functions are defined), sometimes needing to be accessed on an object too (like style)

jQuery and HyperScript both make for much more ergonomic approaches than what the browser platform provides, but are a radical departure from how the DOM works today. Both require a nontrivial amount of code to improve the developer experience. What if we could do most of the work by redefining existing or adding additional properties, instead of creating an entirely new API surface? Before we commit to a solution, let’s first scope out the problems better.

Methods returning undefined

Read section Methods returning undefined

This is the easier of the two problems and could be fixed just by updating the signatures to return this. As it’s unlikely much third-party code relies on the undefined return value, this could even be done globally on your site like so:

const builtinAddEventListener = Element.prototype.addEventListener;
Element.prototype.addEventListener = function addEventListener(...args) {
  builtinAddEventListener.apply(this, args);
  return this;
};

Do think twice before making changes like this to global functions. TypeScript’s built-in types won’t acknowledge the return type. You may also run into performance hits when redefining native code methods.

Rather than modifying a whole API surface, we could also improve the core language. To my understanding there is no proposal in the works that would facilitate chaining for APIs not designed for it. The call-this proposal comes closest, but would essentially facilitate the new HTMLDivElement() style above. Perhaps it could look like this?

// Non-functional pseudocode
document.body.appendChild(
  document.createElement('div')
    &.classList.add('p-4')
    &.addEventListener('click', () => {})
)

// And for adding multiple classes:
document.createElement('div')
  &.(classList&.add('p-4')&.add('border'))

Properties lacking setters

Read section Properties lacking setters

This one is a lot trickier to solve without introducing a new API surface. jQuery solves this by defining dozens of functions like addClass. In JavaScript, setters always return the new value and ignore the return value.

const a = {
  set x(n) {
    return this;
  },
  y: 1,
};
a.x = true;

Proxies, which can redefine setters as well, follow this limitation as well. The set trap can only return a boolean indicating success.

Seemingly the only solution here is to create a bunch of wrappers for the existing API:

Element.prototype.class = function setClass(cb) {
  cb(this.classList);
  return this;
};
document.createElement("div").class((list) => list.add("test"));

Or taking the previous proposal further by extending the assignment operator to have different behavior when encountering a &. bound value:

// Non-functional pseudocode
document.createElement('div')
  &.style&.(padding='4px'&.border='1px solid')
  &.addEventListener('click', () => {});

// Or perhaps combined with a spread-set operator, which looks easier to read and would enable setting arbitrary properties
document.createElement('div')
  // Assignments on `&.` bound properties return 'this', not the right side value
  &.style ...= { padding: '4px', border: '1px solid' }
  &.addEventListener('click', () => {})

// Without a spread-set operator, we could also write the spread manually like so:
document.createElement('div')
  &.style = { ...&.style, padding: '4px', border: '1px solid' }

Proxies are a little-used feature from ES2015. A proxy can wrap an existing object and provides different handlers, called traps, for built-in operators and functions like new, delete, .property, Object.keys, and more. They enable a lot of crazy metaprogramming to be done, but at a cost: performance even in 2022 is still very poor. For DOM UI code however, the ops/sec should be plenty; if there’s anything to optimize, it would be near the bottom of the list.

The get trap, which is called whenever a property is accessed on the proxied value, can return any value and effectively lazily create functions. This helps future-proof this approach; any new properties or methods added to the DOM specification will follow the same rules as existing ones.

A useful property of proxies is that they can be nested; a Proxy can return another Proxy inside traps like get. Wrapping an existing function with an apply trap (function call) makes it indistinguishable from the original function. Even when calling a method like toString, it should still return [native code]. This is because proxies work on a different layer in the engine and there is absolutely no way to detect them from user code.

Let’s see what we can do with proxies to wrap DOM elements and create ergonomic APIs.

Making functions default to returning this

Read section Making functions default to returning this

For the first pain point, all we need to do is wrap document.createElement with a proxy that traps get such that any functions it returns, when called, return the original element instead of undefined.

const createElement = (tagName) =>
  new Proxy(document.createElement(tagName), {
    get(target, prop, receiver) {
      const val = target[prop];
      if (typeof val === "function") {
        return new Proxy(val, {
          apply(fn, thisArg, args) {
            return fn.apply(target, args) ?? receiver;
          },
        });
      }

      return val;
    },
  });
createElement("div")
  .addEventListener("click", () => {})
  .addEventListener("mouseenter", () => {})
  // The original function is used indistinguishably:
  .addEventListener.toString(); /* "function () {
    [native code]
}" */

As a side note, DOM Element is very particular about its this binding. If we use Reflect.get on target and/or the thisArg parameter from the apply trap, it throws called on an object that does not implement interface Element. This is normally the recommended way to implement proxy traps, but we have to be a bit more careful.

const createElement = (tagName) =>
  new Proxy(document.createElement(tagName), {
    get(target, prop, receiver) {
      const val = Reflect.get(target, prop, receiver);
      if (typeof val === "function") {
        return new Proxy(val, {
          apply(fn, thisArg, args) {
            return fn.apply(thisArg, args) ?? receiver;
          },
        });
      }
      return val;
    },
  });
// Throws
createElement("div").addEventListener("click", () => {});

Introducing setter functions

Read section Introducing setter functions

To facilitate chaining, we also want to provide setters to be able to avoid using the assignment operator (x = 1). Proxies can lazily intercept accesses to properties and generate a function in response. In this example, accesses to $property return a setter for property. Making $classList ergonomic, whose functions (add, remove, etc.) also return undefined, requires us to reuse the trap from the previous example.

// This is the proxy handler object of the previous `createElement` example
const chainUndefinedTraps = {
  get(target, prop, receiver) {
    const val = target[prop];
    if (typeof val === "function") {
      return new Proxy(val, {
        apply(fn, thisArg, args) {
          return fn.apply(target, args) ?? receiver;
        },
      });
    }

    return val;
  },
};

function elementSetter(element, property, receiver) {
  return (arg) => {
    // $class(list => list.add('class1').add('class2'))
    // .add() also returns undefined, so we need to wrap this in a proxy too
    if (typeof arg === "function") {
      arg(new Proxy(element[property], chainUndefinedTraps));
    }
    // $style({ border: '1px solid' })
    else if (typeof arg === "object" && arg !== null) {
      Object.assign(element[property], arg);
    }
    // Direct setter
    else {
      element[property] = arg;
    }

    return receiver;
  };
}

const createElement = (tagName) =>
  new Proxy(document.createElement(tagName), {
    get(target, prop, receiver) {
      if (prop.startsWith("$")) {
        return elementSetter(target, prop.slice(1), receiver);
      }
      return chainUndefinedTraps.get(target, prop, receiver);
    },
  });

createElement("div")
  .$classList((list) => list.add("p-4").add("border"))
  .$style({ height: `${window.innerHeight}px` })
  .setAttribute("aria-label", "Example")
  .addEventListener("click", () => {})
  .addEventListener("mouseenter", () => {}).outerHTML;
// "<div class=\"p-4 border\" style=\"height: 966px;\" aria-label=\"Example\"></div>"

This is remarkably close to the jQuery-esque example given before! Of course, we’re far from there yet - the other three prominent authoring problems (collections, verbosity, event handling/lifecycles) with the web platform can still be seen in full force.

As mentioned previously, this technique is purely illustrative; please do not use it on a real website. If the misery afflicted to your colleagues does not sway you, consider the impact on the user. Simply introducing proxies results in a performance hit exceeding 50%, worse than the runtime impact of many popular frameworks. The more calls to proxied functions, the slower it gets.

In Chrome 102, the performance hit of proxies compared to plain DOM calls exceeds 50%