2

I'm trying to write a bookmarklet that works on two different pages. I can successfully iterate through and process the elements once I get hold of them, but in order to accommodate the two different pages, I wanted to compile a list of DIVs by two different class names. I started with this:

traces=
  [].concat.apply(document.getElementsByClassName('fullStacktrace'),
                  document.getElementsByClassName('msg'))

but on the first page it results this:

> traces=[].concat.apply(document.getElementsByClassName('fullStacktrace'),
                         document.getElementsByClassName('msg'))
[HTMLCollection[2]]
> traces[0]
[div.fullStacktrace, div.fullStacktrace]
> traces[1]
undefined
> traces[0][0]
<div class=​"fullStacktrace" style=​"height:​ auto;​">​…​</div>​

whereas on the other page

> traces=[].concat.apply(document.getElementsByClassName('fullStacktrace'),
                         document.getElementsByClassName('msg'))
[HTMLCollection[0], div#msg_2.msg.l1,  ... div#msg_6460.msg.l1]
> traces[0]
[]
> traces[1]
<div class=​"msg l1" id=​"msg_2">​…​</div>​

So getElementsByClassName works for both pages, but it looks like concat.apply doesn't iterate its first argument, but does iterate its second argument?!

So I tried using concat twice and going all out with parentheses:

traces=(
  (
    [].concat.apply(document.getElementsByClassName('fullStacktrace'))
  )
  .concat.apply(document.getElementsByClassName('msg'))
)

but it gets even stranger: the first page said:

Array[1]
> 0: HTMLCollection[0]
  length: 0
  > __proto__: HTMLCollection
length: 1
> __proto__: Array[0]

and the other:

Array[1]
> 0: HTMLCollection[283]  // Lots of div#msg_3.msg.l1 sort of things in here
  length: 1
>__proto__: Array[0]

get its full complement of divs.

Since the two groups of elements are only on one or the other page, I can just use a conditional in my particular case, but the behaviour above is very surprising to me, so I would like to understand it. Any ideas?

For reference, this is on Mac Chrome Version 56.0.2924.87 (64-bit)

android.weasel
  • 3,343
  • 1
  • 30
  • 41

3 Answers3

5

To use concat, the collections would need to be arguments that are Arrays. Any other argument won't get flattened. You can use .slice() for this:

traces= [].concat([].slice.call(document.getElementsByClassName('fullStacktrace')),
                  [].slice.call(document.getElementsByClassName('msg')))

Modern syntax would make it quite a bit nicer. This uses the "spread" syntax to create a new array with the content of both collections distributed into it:

traces = [...document.getElementsByClassName('fullStacktrace'),
          ...document.getElementsByClassName('msg')]

Or to use something that is more easily polyfilled, you can use Array.from() to convert the collections:

traces = Array.from(document.getElementsByClassName('fullStacktrace'))
              .concat(Array.from(document.getElementsByClassName('msg'))))

Overall, I wouldn't use getElementsByClassName in the first place. I'd use .querySelectorAll. You get better browser support and more powerful selection capabilities:

traces = document.querySelectorAll('.fullStacktrace, .msg')

This uses the same selectors that CSS uses, so the above selector is actually passing a group of two selectors, each of which selects the elements with its respective class.


Detailed explanation

First Example:

My explanation of the issue above was too terse. Here's your first attempt:

traces = [].concat.apply(document.getElementsByClassName('fullStacktrace'),
              document.getElementsByClassName('msg'))

The way .concat() works is to take whatever values are given as its this value and its arguments, and combine them into a single Array. However, when the this value or any of the arguments are an actual Array, it flattens its content one level into the result. Because an HTMLCollection isn't an Array, it's seen as just any other value to be added, and not flattened.

The .apply() lets you set the this value of the method being called, and then spread the members of its second argument as individual arguments to the method. So given the above example, the HTMLCollection passed as the first argument to .apply() does not get flattened into the result, but the one passed as the second argument to .apply() does, because it's .apply() doing the spreading, not .concat(). From the perspective of .concat(), the second DOM selection never existed; it only saw individual DOM elements that have the msg class.


Second Example:

traces=(
  (
    [].concat.apply(document.getElementsByClassName('fullStacktrace'))
  )
  .concat.apply(document.getElementsByClassName('msg'))
)

This is a bit different. This part [].concat.apply(document.getElementsByClassName('fullStacktrace')) suffers from the same problem as the first example, but you noticed that the HTMLCollection doesn't end up in the result. The reason is that you actually abandoned the result of that call when you chained .apply.concat(... to the end.

Take a simpler example. When you do this:

[].concat.apply(["bar"], ["baz", "buz"])

...the [] is actually a wasted Array. It's just a short way to get to the .concat() method. Inside .concat() the ["bar"] will be the this value, and "baz" and "buz" will be passed as individual arguments, since again, .apply() spreads out its second argument as individual arguments to the method.

You can see this more clearly if you change the simple example to this:

["foo"].concat.apply(["bar"], ["baz", "buz"])

...notice that "foo" is not in the result. That's because .concat() has no knowledge of it; it only knows if ["bar"], "baz" and "buz".

So when you did this:

[].concat.apply(collection).concat.apply(collection)

You did the same thing. The second .concat.apply basically drops the first and carries on with only the data provided to .apply(), and so the first collection doesn't appear. If you hadn't used .apply for the second call, you'd have ended up with an array with two unflattened HTMLCollections.

[].concat.apply(collection).concat(collection)
// [HTMLCollection, HTMLCollection]
  • 1
    Up-vote for providing the `querySelectorAll` solution – Pineda Mar 17 '17 at 12:31
  • I'm up-voting for querySelectorAll too, but I still don't understand why the fully parenthesised expression didn't result in [HTMLCollection[], HTMLCollection[]], nor why for the first expression, the second HTMLCollection was iterated (in the first case, out of existence, in the second all its divs are appended), while the first HTMLCollection was simply injected as-is. – android.weasel Mar 17 '17 at 13:17
  • @android.weasel: `.concat()` only flattens a collection into the result if it's an actual Array, so an `HTMLCollection` is seen as just some other value. Using `.apply()` in your first example should have added the `.msg` elements though, so if there were `msg` elements, then `traces[1]` should not be `undefined`. If there were no `msg`, then it would be `undefined` because there's nothing to add. –  Mar 17 '17 at 13:29
  • ...the second attempt loses the context of the first DOM selection because you chained `.concat.apply(...` off of it. That's basically the same as doing `[].concat.apply(...`. Remember, the first argument to `apply` sets the `this` value of the function being called, so the `this` value can't also be the previous result. If you had just chained `.concat(/*DOM selection*/)`, you'd have ended up with the two `HTMLCollections` in the result. –  Mar 17 '17 at 13:32
  • 1
    @android.weasel: I updated the answer to give a more comprehensive description of the results you encountered. Let me know if anything is unclear. –  Mar 17 '17 at 13:50
  • Long overdue, but it makes perfect sense - thanks for going into so much detail! – android.weasel Aug 07 '20 at 20:57
0

Because the call to document.getElementByClassName returns a HTMLCollection rather than an Array you're getting the strange results you're seeing. What we can do to combat this is convert the HTMLCollection to an array, and then concat them, like this:

traces= [].slice.call(document.getElementsByClassName('fullStacktrace')).concat([].slice.call(document.getElementsByClassName('msg')))

If browser compatibility is a concern though, have a look at some of the other solutions from here that may help for earlier versions of IE.

Community
  • 1
  • 1
haxxxton
  • 6,422
  • 3
  • 27
  • 57
  • Thanks for the workaround, but I'm still not quite clear why the array for the second page gets [HTMLCollection, div, div, div] rather than [HTMLCollection[0], HTMLCollection[234]] or something similar - especially given that I'm not trying to concat one HTMLCollection into another, but both into [] which *is* an array? – android.weasel Mar 17 '17 at 13:13
0

Explanation:

Document.getElementsByClassName returns a live HTMLCollection of elements that match the provided class names. This is an array-like object but not an array.

So your initial attempts were trying to merge two live lists of HTML elements rather than merge arrays of elements.

Suggested Solution:

Create a solid (non-live array) using Array#slice() and then you can merge the two arrays directly:

var fullStacktrace = [].slice.call(document.getElementsByClassName('fullStacktrace'), 0),
  msg = [].slice.call(document.getElementsByClassName('msg'), 0),

  mergedDivs = fullStacktrace.concat(msg);
Pineda
  • 7,435
  • 3
  • 30
  • 45
  • I'm still not clear why the concat works differently for the first and second getElement calls: the first is simply inserted, where the second is iterated. In the second parenthesised expression, why is there only one HTMLCollection in the final array? – android.weasel Mar 17 '17 at 13:21