How can I convert an Array of nodes to a static NodeList?
Asked Answered
B

5

40

NOTE: Before this question is assumed a duplicate, there is a section at the bottom of this question that addresses why a few similar questions do not provide the answer I am looking for.


We all know that it is easy to convert a NodeList to an Array and there are many ways to do it:

[].slice.call(someNodeList)
// or
Array.from(someNodeList)
// etc...

What I am after is the reverse; how can I convert an array of nodes into a static NodeList?


Why do I want to do this?

Without getting too deep into things, I am creating a new method to query elements on the page i.e:

Document.prototype.customQueryMethod = function (...args) {...}

Trying to stay true to how querySelectorAll works, I want to return a static collection NodeList instead of an array.


I have approached the problem in three different ways so far:

Attempt 1:

Creating a Document Fragment

function createNodeList(arrayOfNodes) {
    let fragment = document.createDocumentFragment();
    arrayOfNodes.forEach((node) => {
        fragment.appendChild(node);
    });
    return fragment.childNodes;
}

While this does return a NodeList, this does not work because calling appendChild removes the node from its current location in the DOM (where it should stay).

Another variation of this involves cloning the nodes and returning the clones. However, now you are returning the cloned nodes, which have no reference to the actual nodes in the DOM.


Attempt 2:

Attempting to "mock" the NodeList constructor

const FakeNodeList = (() => {

    let fragment = document.createDocumentFragment();
    fragment.appendChild(document.createComment('create a nodelist'));

    function NodeList(nodes) {
        let scope = this;
        nodes.forEach((node, i) => {
            scope[i] = node;
        });
    }

    NodeList.prototype = ((proto) => {
        function F() {
        }

        F.prototype = proto;
        return new F();
    })(fragment.childNodes);

    NodeList.prototype.item = function item(idx) {
        return this[idx] || null;
    };

    return NodeList;
})();

And it would be used in the following manner:

let nodeList = new FakeNodeList(nodes);

// The following tests/uses all work
nodeList instanceOf NodeList // true
nodeList[0] // would return an element
nodeList.item(0) // would return an element

While this particular approach does not remove the elements from the DOM, it causes other errors, such as when converting it to an array:

let arr = [].slice.call(nodeList);
// or
let arr = Array.from(nodeList);

Each of the above produces the following error: Uncaught TypeError: Illegal invocation

I am also trying to avoid "mimicking" a nodeList with a fake nodelist constructor as I believe that will likely have future unintended consequences.


Attempt 3:

Attaching a temporary attribute to elements to re-query them

function createNodeList(arrayOfNodes) {
    arrayOfNodes.forEach((node) => {
        node.setAttribute('QUERYME', '');
    });
    let nodeList = document.querySelectorAll('[QUERYME]');
    arrayOfNodes.forEach((node) => {
        node.removeAttribute('QUERYME');
    });
    return nodeList;
}

This was working well, until I discovered that it doesn't work for certain elements, like SVG's. It will not attach the attribute (although I did only test this in Chrome).


It seems this should be an easy thing to do, why can't I use the NodeList constructor to create a NodeList, and why can't I cast an array to a NodeList in a similar fashion that NodeLists are cast to arrays?

How can I convert an array of nodes to a NodeList, the right way?


Similar questions that have answers that don't work for me:

The following questions are similar to this one. Unfortunately, these questions/answers don't solve my particular problem for the following reasons.

How can I convert an Array of elements into a NodeList? The answer in this question uses a method that clones nodes. This will not work because I need to have access to the original nodes.

Create node list from a single node in JavaScript uses the document fragment approach (Attempt 1). The other answers try similar things at Attempts 2, and 3.

Creating a DOM NodeList is using E4X, and therefore does not apply. And even though it is using that, it still removes the elements from the DOM.

Boutte answered 18/7, 2016 at 15:22 Comment(13)
Don't forget that failure is always an option. I would personally prefer querySelectorAll to return an array. The only real benefit to a NodeList is its potential to be live.Globe
@Teemu methods like document.getElementsbyTagName return a NodeList without removing the elements from the document. That's the behaviour that OP tries to mimicCorporeal
If the native NodeList is using appendChild, how does the NodeList keep the elements where they were before the query? My goal is to keep the nodes in the location they were before the query such as how querySelectorAll, etc., works. If that makes sense. @Pablo's comment is correct.Boutte
There is an attempt similar to your Attempt #2 that adds classes instead of attributes: gist.github.com/marcoos/1143928 But that still won't work on text nodes (and you may possibly be averse to adding classes, e.g., if you're concerned about mutation observers seeing the change happen or the remote possibility of a collision with a real class name)Slipsheet
@apsillers, yes, that would work better than the attribute I was trying to add. But you are spot on about avoiding mutation observers. I have a number of web components in use that rely on className change, etc.Boutte
Your first attempt probably also doesn't work because .childNodes is a live NodeList, not a static one.Quotidian
@Bergi, I hadn't considered that might be the case. I will have to look into if there is a way to make it static from that context. Although I'm skeptical at first since appendChild is needed in the first place in that attempt.Boutte
@KevBot: Can you try fragment.querySelectorAll(":scope > *") or fragment.find("> *")? Those should give you static nodelist, and you can move the nodes to their previous location in the DOM after having selected them.Quotidian
@Bergi, I tried both. :scope is not working within the context of a document fragment. I also tried to add the find prototypical method to the DocumentFragment which also did not work. I used some of the polyfill ideas from this answerBoutte
How about creating a regular JS object, which emulates a NodeList?Predigestion
@Teemu, that sounds like my second attempt. Are you referring to something different?Boutte
Yep, just a regular JS object, with a self-made prototype. Something like this.Predigestion
@Teemu: I gave that a try. While that mimics a NodeList perfectly well and is MUCH better than my second attempt, it would also ideally be an instance of NodeList too. It is nice though that I can call Array.from(...) on it though.Boutte
S
20

why can't I use the NodeList constructor to create a NodeList

Because the DOM specification for the NodeList interface does not specify the WebIDL [Constructor] attribute, so it cannot be created directly in user scripts.

why can't I cast an array to a NodeList in a similar fashion that NodeLists are cast to arrays?

This would certainly be a helpful function to have in your case, but no such function is specified to exist in the DOM specification. Thus, it is not possible to directly populate a NodeList from an array of Nodes.

While I seriously doubt you would call this "the right way" to go about things, one ugly solution is find CSS selectors that uniquely select your desired elements, and pass all of those paths into querySelectorAll as a comma-separated selector:

// find a CSS path that uniquely selects this element
function buildIndexCSSPath(elem) {
    var parent = elem.parentNode;

     // if this is the root node, include its tag name the start of the string
    if(parent == document) { return elem.tagName; } 

    // find this element's index as a child, and recursively ascend 
    return buildIndexCSSPath(parent) + " > :nth-child(" + (Array.prototype.indexOf.call(parent.children, elem)+1) + ")";
}

function toNodeList(list) {
    // map all elements to CSS paths
    var names = list.map(function(elem) { return buildIndexCSSPath(elem); });

    // join all paths by commas
    var superSelector = names.join(",");

    // query with comma-joined mega-selector
    return document.querySelectorAll(superSelector);
}

toNodeList([elem1, elem2, ...]);

This works by finding CSS strings to uniquely select each element, where each selector is of the form html > :nth-child(x) > :nth-child(y) > :nth-child(z) .... That is, each element can be understood to exist as a child of a child of a child (etc.) all the way up the root element. By finding the index of each child in the node's ancestor path, we can uniquely identify it.

Note that this will not preserve Text-type nodes, because querySelectorAll (and CSS paths in general) cannot select text nodes.

I have no idea if this will be sufficiently performant for your purposes, though.

Slipsheet answered 18/7, 2016 at 16:39 Comment(3)
That's what I meant with looking for another approachCorporeal
This is definitely a creative approach! I like that it actually creates a NodeList and doesn't affect any of the mutation observers I have watching class attributes on my web components. Just as a heads up, this does capture an incorrect use-case every now and then. Here is a JS fiddle that shows how when I am targeting a nested select element only, it also obtains the first option element after passing it to the converter.Boutte
@Boutte Oops, that's my CSS mistake; fixed now. I need to use > to indicate specifically a parent-child relationship between each :nth-child, instead of a general ancestor-descendant relationship, which is indicated by a space. (It was breaking because the <option> did match the final :nth-child(1) pseudo-element, and it has ancestors, somewhere, that match the other :nth-childs in the right order. Without > it was too permissive about where those ancestors could be.)Slipsheet
C
4

Here are my two cents:

  • Document is a native object and extending it may not be a good idea.
  • NodeList is a native object with a private constructor and no public methods to add elements, and there must be a reason for it.
  • Unless someone is able to provide a hack, there is no way to create and populate a NodeList without modifying the current document.
  • NodeList is like an Array, but having the item method that works just like using square brackets, with the exception of returning null instead of undefined when you are out of range. You can just return an array with the item method implemented:

myArray.item= function (e) { return this[e] || null; }

PS: Maybe you are taking the wrong approach and your custom query method could just wrap a document.querySelectorAll call that returns what you are looking for.

Corporeal answered 18/7, 2016 at 16:19 Comment(0)
P
4

Since it seems that creating a real NodeList from an array is having severe fallbacks, maybe you could use a regular JS object with a self-made prototype to emulate a NodeList instead. Like so:

var nodeListProto = Object.create({}, {
        item: {
            value: function(x) {
                return (Object.getOwnPropertyNames(this).indexOf(x.toString()) > -1) ? this[x] : null;
            },
            enumerable: true
        },
        length: {
            get: function() {
                return Object.getOwnPropertyNames(this).length;
            },
            enumerable: true
        }
    }),
    getNodeList = function(nodes) {
        var n, eN = nodes.length,
            list = Object.create(nodeListProto);
        for (n = 0; n < eN; n++) { // *
            Object.defineProperty(list, n.toString(), {
                value: nodes[n],
                enumerable: true
            });
        }
        return list;
    };
// Usage:
var nodeListFromArray = getNodeList(arrayOfNodes);

There are still some fallbacks with this solution. instanceof operator can't recognize the returned object as a NodeList. Also, console loggings and dirrings are shown differently from a NodeList.

(* = A for loop is used to iterate the passed array, so that the function can accept a passed NodeList too. If you prefer a forEach loop, that can be used as well, as long as an array only will be passed.)

A live demo at jsFiddle.

Predigestion answered 18/7, 2016 at 21:33 Comment(3)
A native NodeList is never nullish. Maybe it's better to return the list, empty or notJokester
@GustvandeWal The code always returns a list. It returns null, if you're trying to read an non-existing index (as did ES5 NodeList).Predigestion
What?? You've already resolved my comment 30 minutes after I posted it. See your last edit. (It won't let me @ you on my phone)Jokester
H
2

You can use outerHTML property of each element, and add it to a parent element (that will create by document.createElement(), the element type doesn't matter). For example, in ES6:

function getNodeList(elements) {
  const parentElement = document.createElement('div');
  // This can be a differnet element type, too (but only block (display: block;) element, because it impossible to put block element in inline element, and maybe 'elements' array contains a block element).
  let HTMLString = '';
  for (let element of elements) {
    HTMLString += element.outerHTML;
  }

  parentElement.innerHTML = HTMLString;

  return parentElement.childNodes;
}
Hereford answered 27/2, 2018 at 16:46 Comment(1)
Instead of putting the original elements in a new NodeList, this implementation also clones them, which is something OP explicitly doesn't want. I also wonder what happens with, say, col, figcaption or li elements here; haven't tested anything, though. Lastly, childNodes returns a live NodeList, not a static one. All of that said, I like the simplicity :)Jokester
D
1

Paul S. wrote an answer in 2013 that this is based on.

var toNodeList = (function() {      
  // Create a document fragment
  var emptyNL = document.createDocumentFragment().childNodes;

  // This is returned from a self-executing function so that
  // the DocumentFragment isn't repeatedly created.
  return function(nodeArray) {
    // Check if it's already a nodelist.
    if (nodeArray instanceof NodeList) return nodeArray;

    // If it's a single element, wrap it in a classic array.
    if (!Array.isArray(nodeArray)) nodeArray = [nodeArray];

    // Base an object on emptyNL
    var mockNL = Object.create(emptyNL, {
      'length': {
        value: nodeArray.length, enumerable: false
      },
      'item': {
        "value": function(i) {
          return this[+i || 0];
        },
        enumerable: false
      }
    });

    // Copy the array elemnts
    nodeArray.forEach((v, i) => mockNL[i] = v);

    // Return an object pretending to be a NodeList.
    return mockNL;
  }
}())
    
    
var arr = document.querySelectorAll('body');
console.log('Actual NodeList is NodeList?', arr instanceof NodeList)
arr = Array.from(arr)
console.log('Plain Array is NodeList?', arr instanceof NodeList)
arr = toNodeList(arr)
console.log('Emulated NodeList is NodeList?', arr instanceof NodeList)
Disconnect answered 10/6, 2021 at 5:12 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.