1

We're trying to replace document.querySelectorAll() with our own function, and we don't want to have to check all the current uses and maybe have to refactor those. We're trying to return a NodeList, but that seems to be impossible because there's no apparent way to create one.

So we're trying to return an array of HTML elements making it look like it's a NodeList. It was relatively easy to replicate NodeList's interface, but the problem is: how to overload the brackets ([]) operator? Apparently it's impossible to do in JavaScript.

  • *"...how to overload the brackets ([]) operator? Apparently it's impossible to do in JavaScript."* No, it isn't; you use [`Proxy`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy). – T.J. Crowder Apr 16 '21 at 10:37

2 Answers2

2

Since NodeList objects are snapshots (they don't track the contents of the DOM the way an HTMLCollection does), the contents of the NodeList are static, which makes supporting [] indexing easy: just assign to the indexes. Arrays are just objects with a couple of additional behaviors (a dynamic length property, adjusting length when you assign via [], and of course Array.prototype). The same thing that makes array[0] work is what makes anyObject["property name"] work.

To make something that looks like a NodeList, offhand I think you need to:

  1. Put NodeList.prototype in its prototype chain so instanceof works
  2. Support item
  3. Support indexing (in this case, just by assigning to those properties)
  4. Support length as an accessor with a getter and no setter rather than a non-writable data property (in case anyone looks)

For instance (see comments):

// Constructor
function PseudoNodeList(arrayLike) {
    const length = arrayLike.length;
    // Define `length` -- slight difference with `NodeList` here, this is
    // defined on the object itself, but `NodeList` has it on the prototype
    Object.defineProperty(this, "length", {
        get() {
            return length;
        },
        enumerable: true, // Oddly, it is on `NodeList.prototype`
        configurable: true,
    });
    // Copy the indexed entries
    Object.assign(this, Array.from(arrayLike));
    // (Instead of the statement above, you could use a `for` loop, which
    // would likely be faster -- you did mention performance)
}
// Make `instanceof` work, and inherit the implementations of
// [Symbol.iterator] and other methods -- though you'll want to test that
// Safari and Firefox are okay with inheriting them, I only checked on
// Chromium-based browsers (Chromium, Chrome, Brave, Edge, Opera I think).
// They may be more picky about `this`.
PseudoNodeList.prototype = Object.create(NodeList.prototype);
// Fix `constructor` property
PseudoNodeList.prototype.constructor = PseudoNodeList;
// Add item method
Object.defineProperty(PseudoNodeList.prototype, "item", {
    value(index) {
        if (index < 0 || index >= this.length) {
            return null;
        }
        return this[index];
    },
    enumerable: true, // Oddly, it is on `NodeList.prototype`
    configurable: true,
});

Live Example:

// Constructor
function PseudoNodeList(arrayLike) {
    const length = arrayLike.length;
    // Define `length` -- slight difference with `NodeList` here, this is
    // defined on the object itself, but `NodeList` has it on the prototype
    Object.defineProperty(this, "length", {
        get() {
            return length;
        },
        enumerable: true, // Oddly, it is on `NodeList.prototype`
        configurable: true,
    });
    // Copy the indexed entries
    Object.assign(this, Array.from(arrayLike));
    // (Instead of the statement above, you could use a `for` loop, which
    // would likely be faster -- you did mention performance)
}
// Make `instanceof` work, and inherit the implementations of
// [Symbol.iterator] and other methods -- though you'll want to test that
// Safari and Firefox are okay with inheriting them, I only checked on
// Chromium-based browsers (Chromium, Chrome, Brave, Edge, Opera I think).
// They may be more picky about `this`.
PseudoNodeList.prototype = Object.create(NodeList.prototype);
// Fix `constructor` property
PseudoNodeList.prototype.constructor = PseudoNodeList;
// Add item method
Object.defineProperty(PseudoNodeList.prototype, "item", {
    value(index) {
        if (index < 0 || index >= this.length) {
            return null;
        }
        return this[index];
    },
    enumerable: true, // Oddly, it is on `NodeList.prototype`
    configurable: true,
});

// ======= Using it:

const p = new PseudoNodeList(
    document.querySelectorAll(".example")
);
console.log(`p instanceof NodeList? ${p instanceof NodeList}`);
console.log(`p.length = ${p.length}`);
console.log(`p.keys():`, [...p.keys()]);
console.log(`p.values():`, [...p.values()]);
console.log(`p.entries():`, [...p.entries()]);

// Turn all of them green via iteration
for (const el of p) {
    el.style.color = "green";
}
// Use `item` method to make the first match `font-size: 20px`
p.item(0).style.fontSize = "20px";
// Use indexed property to make the first match `font-style: italic`
p[1].style.fontStyle = "italic";
<div>one</div>
<div class="example">two</div>
<div>one</div>
<div class="example">three</div>

I didn't take the approach of subclassing Array (which would certainly be another way to go) because I didn't want to hide all the array methods that NodeList doesn't have.

If you wanted to make the [] indexing dynamic (you don't need to for a NodeList stand-in, since again those are static), you could use a Proxy.

T.J. Crowder
  • 1,031,962
  • 187
  • 1,923
  • 1,875
-2

I've found that this code works as expected:

function arrayToNodeList(initialArray) {
  const tempArray = Array(initialArray);
  initialArray.__proto__ = {
    get length() {
      return tempArray.length;
    },

    item(position) {
      if (position < 0 || position > tempArray.length - 1) {
        return null;
      }
      return tempArray[position];
    },

    entries() {
      return tempArray.entries();
    },

    forEach(callback, thisArg = initialArray) {
      return tempArray.forEach(callback, thisArg);
    },

    keys() {
      return tempArray.keys();
    },

    values() {
      return tempArray.values();
    },
  };

  return initialArray;
}

It might be a little bit worse, performance wise, but it solves the brackets operator problem and hides the rest of the API for Array objects. The main difference is that console.log() shows that it's not really a NodeList, but otherwise it just works.

NOTE: the tempArray is needed because the prototype of the initialArray is being modified. If I rely on initialArray to call functions I get an Uncaught RangeError: Maximum call stack size exceeded because it's recursively calling its own functions.

Cerbrus
  • 70,800
  • 18
  • 132
  • 147
  • 1
    I think answers shouldn't end with _"What do you think?"_ If your intent is to share something you made, that sounds like a better fit for a blog. The question as it currently stands is pretty vague and not answerable. – Cerbrus Apr 16 '21 at 09:46
  • @Cerbrus the question is "how to hack an array to make it look like a nodelist?" and then I propose an answer that I found. The answer ends with a "what do you think?" question because there might be a better way to do it. And this question+answer is here because I already searched in stackoverflow and couldn't find an answer. So I spent some time on this and I wanted to share the results. – Daniel Hernández Alcojor Apr 16 '21 at 09:55
  • 1
    Then please make that question more focused, less ambiguous... Remove all the background noise about performance, just focus on _"How to convert an array to a nodelist"_ – Cerbrus Apr 16 '21 at 09:57
  • @Cerbrus Is it better now? – Daniel Hernández Alcojor Apr 16 '21 at 10:06
  • 1
    That's much better – Cerbrus Apr 16 '21 at 10:07
  • 1
    Forking `__proto__` is an awful solution, why not just make a subclass extending `Array`? Or, if that matters, `NodeList` is not an array, it's an object. – Teemu Apr 16 '21 at 10:09
  • @Teemu The problem with extending Array class is that you don't get the brackets operator with it. – Daniel Hernández Alcojor Apr 16 '21 at 10:10
  • @DanielHernándezAlcojor What do you want the "brackets operator" to do instead of the native behavior? Considering a NodeList is read-only, there should be no difference with the use of brackets, not even when using an object instead of an array. – Teemu Apr 16 '21 at 10:12
  • @Teemu I want the brackets operator to work. If I create my own class, since I cannot overload operators, I lose that functionality. It can be done with the .item() function, I know, but there might be code using the brackets operator and we don't want to brake anything. The new class would be an object, but using [0] on it wouldn't work as expected. – Daniel Hernández Alcojor Apr 16 '21 at 10:15
  • @DanielHernándezAlcojor Again, the brackets will work with a subclassed array just like they work with a native array. See [this my answer](https://stackoverflow.com/a/38446348/1169519), it emulates a NodeList (such that it was in 2018), there's also a jsFiddle you can play with. – Teemu Apr 16 '21 at 10:17
  • Even if you were going to change the prototype of an object (which I recommend never doing), use `Object.setPrototypeOf`, not `__proto__`. (Similarly, to get the prototype, use `Object.getPrototypeOf`, not `__proto__`). – T.J. Crowder Apr 16 '21 at 10:26
  • Thanks @Teemu for that answer, I don't know how I missed all those other questions. I need some time to process and test your proposal, but it seems promising! – Daniel Hernández Alcojor Apr 16 '21 at 10:36
  • Thanks @T.J.Crowder for your advice, noted! – Daniel Hernández Alcojor Apr 16 '21 at 10:37
  • 1
    @DanielHernándezAlcojor I think T.J. Crowder's answer to your post is much better than those other answers. A small fix to my comment above, my answer emulates a NodeList as it was in ES5, in 2018 we had more methods in the list, and T.J. has included those too in his answer. – Teemu Apr 16 '21 at 10:39