-2

What are some techniques for creating a jQuery-like fluent interface?

At the heart of this question is the notion of abstracting over a selection of NodeLists and doing DOM changes on all the nodes in them. jQuery is usually exposed by the name $, and typical code might look like this1:

$ ('.nav li:first-child a') 
  .color ('red')
  .size (2, 'em')
  .click ((evt) => {
    evt .preventDefault (); 
    console .log (`"${evt .target .closest ('a') .textContent}" link clicked`)
  })

Note the fluent interface there. The nodes are selected once, and then the functions color, size, and click are executed against each matching node. The question, then, is how can we build such a system that allows us to write simple versions of color, size, and click which are bound together in a common chained interface?

In response to a similar question (now deleted), I wrote my own version, and user @WillD linked to a JSFiddle, which I'm hoping will become another answer.

For this pass, I'm suggesting that we don't try to mimic other parts of jQuery's functionality; certainly not things like .ajax or $(document).ready(...), but also to not bother with the plug-in mechanism (unless it comes for free) or the ability to generate a new NodeList from the current one.

How would one go about turning a collection of functions into a function that generates a collection of nodes from a selector and allows you to chain those functions as methods on this collection.


1This library long predated document.querySelector/querySelectorAll, and I'm assuming it was a large part of the inspiration for the DOM methods.

Scott Sauyet
  • 49,207
  • 4
  • 49
  • 103
  • The crux is: all of your methods `return` an object. In jQuery's case that's a jQuery object (not too surprisingly) in your case you can name it something better than "myFluentObject". That object contains your methods and each method passes it on (via `return`) so that the next method can apply to the same object. – freedomn-m Aug 19 '22 at 14:53
  • @freedomn-m: Yes, my answer below does that. WillD's Fiddle does it in a different way. I'm curious about other approaches. – Scott Sauyet Aug 19 '22 at 14:55
  • 1
    There's a [discussion on Meta](https://meta.stackoverflow.com/q/419909/1243641) regarding why this was closed. – Scott Sauyet Aug 19 '22 at 18:52
  • 2
    There are a number of attempts at creating similar structures littered about in the answers over here: https://stackoverflow.com/questions/7475336/how-does-jquery-chaining-work – Kevin B Aug 19 '22 at 19:20
  • 2
    "The main question is" Do not have a main question. Have 1 question. "At the heart of this question" Do not tell us about the question. Ask it. – philipxy Aug 19 '22 at 22:18
  • @phillipxy: That's quite right. Fixed. Thank you. – Scott Sauyet Aug 22 '22 at 00:43
  • Sadly I missed this question when it was open. [My answer here](https://stackoverflow.com/a/68422592/633183). – Mulan Jun 11 '23 at 17:58

2 Answers2

1

Here is the potential solution I advanced before, with the size and click methods now added.

Here's how it works. I use the class keyword to create a class which accepts a selector as its constructor argument. It runs a querySelectorAll to get a nodelist for that selector and saves it as a property called nodelist. Each method does a specific thing for every item in this nodelist and once done, returns the selfsame object instance to enable a jquery-like chaining support. Such that you could call each method on the return value of each previous method and so on, and they would all reference the same initial nodelist.

class JqueryLikeObject {
  constructor(selector) {
    this.nodelist = document.querySelectorAll(selector);
  }

  recolor(color) {
    this.nodelist.forEach((item) => {
      item.style.color = color;
    })
    return this;
  }

  setText(text) {
    this.nodelist.forEach((item) => {
      item.innerHTML = text;
    })
    return this;
  }
  
  click(callback) {
    this.nodelist.forEach((item) => {
      item.addEventListener('click', callback);
    });
    return this;
  }

  size(size, unites) {
    this.nodelist.forEach((item) => {
      item.style.fontSize = size + unites;
    });
    return this;
  }
}

function $$(selector) {
  return new JqueryLikeObject(selector);
}


$$('.a').recolor('pink');
$$('.group').recolor('green').setText('Foo!');
$$('div').size('2','em').click(()=> alert('You clicked it'));
<div class="a">
a
</div>

<div class="b group">
b
</div>

<div class="c group">
c
</div>

Alternative:

In effort to satisfy your desire for the method definitions to be as terse as possible. Here is an alternative setup for the methods in the Jquerylikeobject class.

Basically it loops through a list of defined methods and as it assigns them as member props to the object it wraps them in the foreach loop that is needed to execute for each item in the nodelist. Passing any arguments along to the inner function.

class JqueryLikeObject {
  constructor(selector) {
    this.nodelist = document.querySelectorAll(selector);

    const methods = {
      recolor: (item, color) => { item.style.color = color },
      setText: (item, text) => { item.innerHTML = text },
      click: (item, callback) => { item.addEventListener('click', callback) },
      size: (item, size, units) => { item.style.fontSize = size + units }
    }

    for (const method in methods) {
      const theFunction = methods[method];
      this[method] = function() {
        this.nodelist.forEach((item) => {
          theFunction(item, ...arguments)
        })
        return this
      }
    }
  }
}

function $$(selector) {
  return new JqueryLikeObject(selector);
}


$$('.a').recolor('pink');
$$('.group').recolor('green').setText('Foo!');
$$('div').size('2', 'em').click(() => alert('You clicked it'));
<div class="a">
  a
</div>

<div class="b group">
  b
</div>

<div class="c group">
  c
</div>
WillD
  • 5,170
  • 6
  • 27
  • 56
  • Thanks. The overall structure is quite a bit simpler than mine. But mine allows simpler individual methods. It's great to see different approaches. – Scott Sauyet Aug 19 '22 at 20:11
  • See edit. I have wrapped the methods in a sort of factory function that reduces the repetition of `this.nodelist.foreach` in each method definition. – WillD Aug 19 '22 at 20:42
  • That's now becoming similar to my own version, although written a bit differently. The largest remaining difference is that in my version, the methods are on a prototype object. Here they're repeated per object. While that might have some memory issues, I doubt there would be enough of these object alive at once to matter much. Very nice! – Scott Sauyet Aug 19 '22 at 21:17
  • Also, note that terseness was not the goal, merely simplicity. These functions don't have to know about NodeLists, don't have to run a `forEach` or similar loop, don't have to return an object they may not even know about. Essentially, we're turning simple functions on a DOM node into methods on an object that will supply multiple such nodes to process. – Scott Sauyet Aug 19 '22 at 21:25
-1

Here's my first attempt at this problem:

const $ = ((fns = {
    color: (color) => (node) => node .style .color = color,
    size: (size, units = '') => (node) => node .style .fontSize = size + units,
    click: (fn) => (node) => node .addEventListener ('click', fn)
    // more here
  }, 
  nodes = Symbol(),
  proto = Object .fromEntries (
    Object .entries (fns) .map (([k, fn]) => [
      k, 
      function (...args) {this [nodes] .forEach ((node) => fn (...args) (node)); return this}
    ])
  )
) => (selector) => 
  Object .create (proto, {[nodes]: {value: document .querySelectorAll (selector)}})
) ()

$ ('.nav li:first-child a') 
  .color ('red')
  .size (2, 'em')
  .click ((evt) => {
    evt .preventDefault (); 
    console .log (`"${evt.target.closest('a').textContent}" link clicked`)
  })
div {display: inline-block; width: 40%}
<div>
  <h3>First nav menu</h3>
  <ul class="nav">
    <li><a href="#">foo</a></li>
    <li><a href="#">bar</a></li>
    <li><a href="#">baz</a></li>
  </ul>
</div>
<div>
  <h3>Second nav menu</h3>
  <ul class="nav">
    <li><a href="#">qux</a></li>
    <li><a href="#">corge</a></li>
    <li><a href="#">grault</a></li>
  </ul>
</div>

Most importantly, the functions are quite simple:

{
  color: (color) => (node) => node .style .color = color,
  size: (size, units = '') => (node) => node .style .fontSize = size + units,
  click: (fn) => (node) => node .addEventListener ('click', fn),
}
  

And we can add as many of them as we like. These are transformed into methods of an object -- which we will use as a prototype -- by creating functions that accept the same arguments, then to each node in our collection, we call this function with the same arguments, and call the resulting function with the node, eventually returning our same object.

This whole thing returns a function which accepts a selector, calls querySelectorAll with it, and wraps the resulting NodeList in an object with our given prototype.

Although I haven't tried, I think this would be easy to extend to allow a plug-in architecture. It would be more difficult to allow for functions that could alter the NodeList or return an entirely different one. At that point, we might look to the jQuery source code for inspiration.

Scott Sauyet
  • 49,207
  • 4
  • 49
  • 103