The map/reduce (and their friends filter, each, flatten etc) paradigm provides a general way to manipulate lists and streams. This is particularly well suited to web work - where we spend most of our days playing with lists of DOM elements. Recent versions of JavaScript give us the tools to do this work natively but before that we had to roll our own, or use a library. jQuery has had some similar features since way-back-when, so today we're going to do a bit of "compare & contrast" on the map function: jQuery vs jQuery vs JavaScript!
Map iterates over a collections and applies a function to each element - returning a new, usually modified, list. Our tests will examine jQuery's "inline" map with it's global $.map map, and JavaScript's native map function. The object (no pun intended) is to see how each implementation varies in terms of the parameters and the value of this we get each iteration, and how they handle weird arrays.
Our initial task will to be to turn a list of <li> tags into an array of numerical values that each element contains. The input will therefore be:
<ul id="list">
<li>1</li>
<li>2</li>
<li>3</li>
</ul>
And the required output is a simple JavaScript array:
[1, 2, 3]
jQuery likes to keep things jQuery-y, so some of the outputs (and inputs) will be wrapped in the jQuery function (i.e. jQuery(1, 2, 3)
instead of [1, 2, 3]
). Chrome's logger will show both the values as [1, 2, 3]
- but if you tried to .reverse
a jQuery object it's going to die - so if you need to get at the underlying array from a jQuery object then used the .get
method: jQuery(1, 2, 3).get()
.
The code for the tests is up on GitHub. I've abstracted the tests a bit for code readability, but I'll list the "long version" here so it makes more sense out of context. Each method will print the collection ("Input"), the this value and arguments ("args") for each item iteration, and the result ("Output").
Ok, let's run some tests...
Ex 1a: inline jQuery map
$("#list li").map(function(){
return parseInt($(this).text(), 10);
});
Input: jQuery(li, li, li)
this: <li> args: [0, li]
this: <li> args: [1, li]
this: <li> args: [2, li]
Output: jQuery(1, 2, 3)
Ex 1b: global jQuery map
$.map($("#list li"), function(el){
return parseInt($(el).text(), 10);
});
Input: jQuery(li, li, li)
this: DOMWindow args: [li, 0, undefined]
this: DOMWindow args: [li, 1, undefined]
this: DOMWindow args: [li, 2, undefined]
Output: [1, 2, 3]
There are some notable differences between the two jQuery methods. The inline version scopes to the list, so this is the element, and the first argument to the callback is the element's index. The second is the same as this (for some reason).
The global version scopes to the document window. To get the element you need to look at the first parameter. The index is the second, and we get a handy undocumented reference to undefined as the third. (I really should have a squizz at the jQuery source code to see what is going on here). The global map also returns us a real JavaScript array - not a jQuery array-like array.
Ex 1c: JavaScript map over array
$("#list li").get().map(function(el){
return parseInt($(el).text(), 10);
});
Input: [li, li, li]
this: DOMWindow args: [li, 0, [li, li, li]]
this: DOMWindow args: [li, 1, [li, li, li]]
this: DOMWindow args: [li, 2, [li, li, li]]
Output: [1, 2, 3]
The native JavaScript map gives us an output that looks like the global jQuery map - except we get something a bit more useful as the third parameter: the original collection. The original collection in this case is the .get() value of the jQuery selection - that is, a real array. But it works over a jQuery collection too, if we apply it - as in the following example...
Ex 1d: JavaScript map over jQuery collection
Array.prototype.map.call($("#list li"), function(el){
return parseInt($(el).text(), 10);
});
Input: jQuery(li, li, li)
this: DOMWindow args: [li, 0, jQuery(li, li, li)]
this: DOMWindow args: [li, 1, jQuery(li, li, li)]
this: DOMWindow args: [li, 2, jQuery(li, li, li)]
Output: [1, 2, 3]
That was just to show the output was the same with the jQuery input. The native map also lets you specify an optional parameter after the callback function to scope the map too - rather than the DOMWindow object.
There are a few things in common with each implementation: they all have a way to access the element and the element's index (zero offset), and none of them mutate the original collection. It's not hard to figure out the differences and adapt your style - but it's not all rosy once we leave the land of jQuery...
Holey arrays, batman
All three map implementations work over regular ol' arrays. However, regular ol' arrays in JavaScript have a tendency to not be that regular at all: we can have null values, and "holes" that can trip up our brave iterators.
var holey = [ "1", "2", null, ["4a", "4b"], , "6"];
Output: ["1", "2", null, Array[2], undefined, "6"]
Here we have an array that is pretty funky. It's got strings, and nulls, and arrays, and holes. First we'll look at how our maps index such a quirky beast:
Ex 2a: jQuery global map-to-index
$.map(holey, function(el, index){
return index;
});
Output: [0, 1, 2, 3, 4, 5]
Ex 2b: JavaScript map-to-index
holey.map(function(el, index){
return index;
});
Output: [0, 1, 2, 3, undefined, 5]
Oh kay... be careful there - our "hole" is not processed by the native map, so we get no value for index, which results in a big fat undefined in the middle. Best keep that in mind.
Finally, we'll look at what happens if we map the elements to themselves.
Ex 2c: JavaScript map-to-element
holey.map(function(el){
return el;
});
Output: ["1", "2", null, ["4a", "4b"], undefined, "6"]
Ex 2d: jQuery global map-to-element
$.map(holey, function(el){
return el;
});
Output: ["1", "2", "4a", "4b", "6"]
Errrgh... Um, wow? Native JavaScript acts as (I) expected, but there's something peculiar happening in the world of jQuery. Actually, if you consult the docs then you'll see it's not a flaw but a feature. Null values are dropped from the resulting map, and any arrays are flattened one level (if fact, if you need to combat that you can return the element wrapped in an extra array).
That's a wacky feature - map is actually a flatmap and filter type thing in jQuery. So make sure you know what you're doing before you go playing with JavaScript arrays with them!
Ok. That'll do us for now. Things get even more funky if you delete items during a loop, but if you're doing that then you're on your own. In the next post we'll have a look at some map/reduce/filter/etc methods in action, and we'll see why it's important to have dissected poor old map. See you then.
7 Comments
Nice writeup. One thing worth mentioning is that if you added a .get() to the end of example 1a, you’d be back to having a “native” (non-jQuery) array. I generally prefer the utility version ($.map) for some unknown, preferential reason, but $().map().get() is nice when you’re at the end of a beautiful chain that you don’t want to gunk up :)
Good write-up. The nested arrays that get flattened by jQuery map often confuse some.
I also wanted to note that the new version of jQuery (1.6) supports objects.
Hola, soy de Argentina.. muy bueno el post con jQuery and map. -)
In reference to 1b about the undefined as the third argument I believe it’s because jquery is protecting itself by ensuring undefined is undefined. Paul Irish explains it better in this video: http://www.youtube.com/watch?v=i_qE1iAmjFg&t=04m15s
Nice! Would love to see some perf benchmarks on the 3 implementations :)
@weehuy, yeah reserving so many keywords but leaving out undefined was crazy.
I was baffled when I wasn’t able to use reduce after using .map, while playing with jQuery in Chrome’s console. So googled and came to this post. I had no idea that I had to use .get(). Thanks for including that info.