- Lo-Dash Essentials
- Adam Boduch
- 2907字
- 2021-08-06 19:27:37
Transforming collections
Lo-Dash has a number of tools for transforming collections into new data structures. Additionally, there are tools that can take two or more collections and combine them into a single collection. These functions focus on the most common, yet most burdensome programming tasks faced by frontend developers. Instead of focusing on boilerplate collection transformations, you can get back to making a great application—users don't care about awesome compact code as much as you do.
Grouping collection items
Items in collections are sometimes grouped implicitly. For example, let's say there's a size
property for a given class of objects whose allowable values are 'S'
, 'M'
, or 'L'
. The code in your frontend application might need to round up the items that contain these various groups for display purposes. Rather than writing our own code, we'll let the groupBy()
function handle the intricacies of constructing such a grouping:
var collection = [ { name: 'Lori', size: 'S' }, { name: 'Johnny', size: 'M' }, { name: 'Theresa', size: 'S' }, { name: 'Christine', size: 'S' } ]; _.groupBy(collection, 'size'); // → // { // S: [ // { name: "Lori", size: "S" }, // { name: "Theresa", size: "S" }, // { name: "Christine", size: "S" } // ], // M: [ // { name: "Johnny", size: "M" } // ] // }
The groupBy()
function, as you might have noticed by now, doesn't return a collection—it takes a collection as the input, but transforms it into an object. This object that groupBy()
returns contains the original items of the input collection, they're just organized differently. The properties of the object are the values you want to group by. A majority of collection items in the preceding code will reside in the S
property.
When you pass in the property name as a string, groupBy()
will use a pluck style callback to grab the value of that property from each item in the collection. The unique property values form the keys of the group object. As is often the case, object properties aren't clear-cut and need to be computed at runtime. In the context of grouping items, function callbacks can be used to group collection items in cases where grouping isn't a matter of a simple comparison, as in the following code:
var collection = [ { name: 'Andrea', age: 20 }, { name: 'Larry', age: 50 }, { name: 'Beverly', age: 67 }, { name: 'Diana', age: 39 } ]; _.groupBy(collection, function(item) { return item.age > 65 ? 'retired' : 'working'; }); // → // { // working: [ // { name: "Andrea", age: 20 }, // { name: "Larry", age: 50 }, // { name: "Diana", age: 39 } // ], // retired: [ // { name: "Beverly", age: 67 } // ] // }
Rather than test for equality, this callback function tests for approximations. That is, anything greater than 65
in the age
property is assumed to be retired. And we return that string as the group label. Keep in mind that it's best if these callback functions return primitive types for the keys. For any other values, the string working
is returned. What's nice about these callback functions is that they can be used to quickly generate reports on the API data you're working with. The preceding example illustrates this with a one-liner callback function passed to groupBy()
.
Note
Although the groupBy()
function will accept a where style object as the second parameter, this might not be what you're after. For example, if an item in the collection passes the test, it'll end up in the true
group. Otherwise, it's a part of the false
group. Be careful before going too far down the road with a pluck or where style callback—they might not do what you expect. Fiddle around and get quick results to sanity check your approach.
Counting collection items
Lo-Dash helps us find the minimum and maximum values of a collection. We might not need any help if we're working with a lot of arrays that contain only numbers. If that's the case, Math.min()
is our friend. In nearly any other scenario, the min()
and max()
functions are the way to go, if for no other reason than the callback support. Let's take a look at the following example:
var collection = [ { name: 'Douglas', age: 52, experience: 5 }, { name: 'Karen', age: 36, experience: 22 }, { name: 'Mark', age: 28, experience: 6 }, { name: 'Richard', : age: 30, experience: 16 } ]; _.min(collection, 'age'), // → { name: "Mark", age: 28, experience: 6 } _.max(collection, function(item) { return item.age + item.experience; }); // → { name: "Karen", age: 36, experience: 22 }
The first call is to min()
and it gets a string argument—the name of the property we want the minimum value of in the collection. This uses the pluck style callback shorthand and produces concise code where you know the property you're working with. The second call in the preceding code is to max()
. This function supports the same callback shorthand as min()
, but here, there's no pre-existing property value for you to work with. Since what you want is the age
property plus the experience
property, the callback function supplied to max()
computes this for us and figures out the maximum.
Note that the min()
and max()
functions return the actual collection item and not the minimum or maximum value. This makes sense because we're probably going to want to do something with the item itself, and not just the min/max value.
Beyond locating the minimum and maximum values of collections is finding the actual size of collections. This is easy if you're working with arrays because they already have the built-in length
property. It is the same with strings. However, objects don't always have a length
property. The Lo-Dash size()
function tells you how many keys an object has, which is the intuitive behavior you'd expect from an object, but isn't there, by default. Take a look at the following code:
var collection = [ { name: 'Gloria' }, { name: 'Janice' }, { name: 'Kathryn' }, { name: 'Roger' } ]; var first = _.first(collection); _.size(collection); // → 4 _.size(first); // → 1 _.size(first.name); // → 6
The first call to size()
returns the length of the collection. It'll look for a length
property, and if the collection has one, this is the value that's returned. Since it's an array, the length
property exists, and has a value of 4
. This is what's returned. The first
variable is an object, so it has no length
property. It'll count the number of keys in the object and return this value—in this case, 1
. Lastly, size()
is called on a string. This has a length value of 6
.
We can see from all three uses of size()
that there's little guessing involved. Where the default JavaScript behavior is inconsistent and unintuitive, Lo-Dash provides a single function to address common use cases.
Flattening and compacting
Arrays can nest to arbitrary depth and sometimes contain falsey values that are of no practical use. Lo-Dash has functions to deal with both these situations. For example, a component of our UI might get passed as an array that has arrays nested inside it. But our component doesn't make use of this structure, and it's, in fact, more of a hindrance than it is helpful. We can flatten the array to extract and throw away the unnecessary structure your component does not need, as shown in the following code:
var collection = [ { employer: 'Lodash', employees: [ { name: 'Barbara' }, { name: 'Patrick' }, { name: 'Eugene' } ]}, { employer: 'Backbone', employees: [ { name: 'Patricia' }, { name: 'Lillian' }, { name: 'Jeremy' } ]}, { employer: 'Underscore', employees: [ { name: 'Timothy' }, { name: 'Bruce' }, { name: 'Fred' } ]} ]; var employees = _.flatten(_.pluck(collection, 'employees')); _.filter(employees, function(employee) { return (/^[bp]/i).test(employee.name); }); // → // [ // { name: "Barbara" }, // { name: "Patrick" }, // { name: "Patricia" }, // { name: "Bruce" } // ]
Of course, we don't actually alter the structure of the original collection, we build a new one on the fly, better suited for the current context. In the preceding example, the collection consists of employer
objects. However, our component is more concerned with the employee
objects. So, the first step is to pluck those out of their objects using pluck()
. This gets us an array of arrays. Because what we're actually plucking is the employee
array from each employer
array.
The next step is to flatten this employee
array into an array of employee
objects, which flatten()
handles easily. The point of doing all this, which isn't really a lot, is now we have an easy structure to filter. Particularly, this code uses the flattened collection structure to filter out the employee names that start with b
or p
.
Note
There's another flatten function called flattenDeep()
, which goes to arbitrary nested array depths to create a flattened structure. This is handy when you need to go beyond the one level of nesting that flatten()
looks in. However, it's not a good idea to flatten arrays of unknown size and depth, simply due to the performance implications. There's a good chance that large array structures can lock the UI for your users.
A close cousin to flatten()
is the compact()
function, often used in conjunction with one another. We'll use compact()
to remove the falsey values from a flattened array, to just use it on a plain array that already exists, or just to take out the falsey values before it's filtered. This is shown in the following code:
var collection = [ { name: 'Sandra' }, 0, { name: 'Brandon' }, null, { name: 'Denise' }, undefined, { name: 'Jack' } ]; var letters = [ 's', 'd' ], compact = _.compact(collection), result = []; _.each(letters, function(letter) { result = result.concat( _.filter(compact, function(item) { return _.startsWith(item.name.toLowerCase(), letter); }) ); }); // → // [ // { name: "Sandra" }, // { name: "Denise" } // ]
We can see that this collection has some values in it that we clearly don't want to deal with. But, the hopefully-not-so-sad reality is that doing frontend development in a dynamically-typed language with backend data means that you have no control over a lot of sanity checking. All that the preceding code does with the compact()
function is remove any of the falsey values from the collection. These are things such as 0
, null
, and undefined
. In fact, this code wouldn't even run without compacting the collection since it makes the implicit assumption about the name
property being defined on each object in the collection.
Not only can compact()
be used for safety purposes—removing items that violate contracts—but also for performance purposes. You'll see that the preceding code searches the collection, inside a loop. Therefore, any items removed from the collection before the outer loop is entered, the greater the performance gain.
Going back to the preceding code, there's one issue that can catch Lo-Dash programmers off guard. Let's say that we don't want anything that doesn't have a name
property. Well, we're only shaving off falsey values—objects without name
properties are still valid, and the compact()
function lets them through. For example, {}
doesn't have a name
property, and neither does 2
, but they're both allowed through in the previous approach. A safer approach might be to pluck then compact, as shown in the following code:
var collection = [ { name: 'Sandra' }, {}, { name: 'Brandon' }, true, { name: 'Denise' }, 1, { name: 'Jack' } ]; var letters = [ 's', 'd' ], names = _.compact(_.pluck(collection, 'name')), result = []; _.each(letters, function(letter) { result = result.concat( _.filter(names, function(name) { return _.startsWith(name.toLowerCase(), letter); }) ); });
Here, we're faced with a similar filtering task but with a slightly different collection. It has objects that will cause our code to fail because they don't have a name key with a string value. The quick-and-dirty workaround is to pluck the name
property from all items in the collection before performing the compact()
call. This will yield undefined values for objects that don't have a name
property. But that's exactly what we're after, since compact()
has no trouble excluding these values. Moreover, our code is actually simpler now. The caveat being, sometimes the simple approach doesn't work. Sometimes, you need the full object and not just the name. Cheat only when you can get away with it.
Validating some or all items
Sometimes, sections of our code hinge on the validity of all, or some collection items. Lo-Dash provides you with two complementary tools for the job. The every()
function returns true
if the callback returns true
for every item in the collection. The some()
function is a lazy brother of every()
—it gives and returns true
as soon as the callback returns true
for an item, as shown in the following code:
var collection = [ { name: 'Jonathan' }, { first: 'Janet' }, { name: 'Kevin' }, { name: 'Ruby' } ]; if (!_.every(collection, 'name')) { return 'Missing name property'; } // → "Missing name property"
This code checks every item in the collection for a name
property before doing anything with it. Since one of the items is using an incorrect property name, the code will return early. The code that runs below the if
statement can assume that each item has a name
property.
On the other hand, we might only want to know whether any items have a necessary value. You can use this technique to greatly increase performance. For example, say that you have a loop that performs expensive operations on each collection item. You can do a preflight check, which is relatively inexpensive, to determine whether the expensive loop is worth running. An example for this is as follows:
var collection = [ { name: 'Sean' }, { name: 'Aaron' }, { name: 'Jason' }, { name: 'Lisa' } ]; if (_.some(collection, 'name')) { // Perform expensive processing... }
If the some()
call makes it all the way through the collection without any true
callback return values, it means that we can skip the more expensive processing. For example, if we have a potentially large collection and we need to filter it using some nontrivial comparison operators, perhaps some function calls as well, the overhead really starts to add up. Using some()
is a cheap way to avoid this heavy processing if it's needless.
Unions, intersections, and differences
The last section of this chapter looks at Lo-Dash functions that compare two or more arrays and yield a resulting array. In a way, we're combining several collections into a single collection. The union()
function concatenates collections, with duplicate values removed. The intersection()
function builds a collection with values common to all the provided collections. Lastly, the xor()
function builds a collection that contains the differences between all provided collections. It's kind of like the inverse of intersection()
.
You can use the union()
function when there are several overlapping collections that contain similar items—possibly the same items. Rather than iterate through each collection individually, it's easier to combine the collections, while at the same time removing duplicates, as you can see in the following code:
var css = [ 'Philip', 'Donald', 'Mark' ]; var sass = [ 'Gary', 'Michelle', 'Philip' ]; var less = [ 'Wayne', 'Ruth', 'Michelle' ]; _.union(css, sass, less); // → // [ // "Philip", // "Donald", // "Mark", // "Gary", // "Michelle", // "Wayne", // "Ruth" // ]
This code takes three arrays and transforms them into a single array. You can see in the resulting array that there's no overlap. That is, any items that exist in more than one of the input arrays are only included in the resulting array once. Let's see what the overlap looks like using intersection()
:
var css = [ 'Rachel', 'Denise', 'Ernest' ]; var sass = [ 'Lisa', 'Ernest', 'Rachel' ]; var less = [ 'Ernest', 'Rachel', 'William' ]; _.intersection(css, sass, less); // → [ "Rachel", "Ernest" ]
Here, the intersection is Ernest
and Rachel
, since these strings exist in all three collections that were passed into intersection()
. Now it's time to look at comparing the differences between two collections using xor()
:
var sass = [ 'Lisa', 'Ernest', 'Rachel' ]; var less = [ 'Ernest', 'Rachel', 'William' ]; return _.xor(sass, less); // → [ "Lisa", "William" ]
Passing these two arrays to xor()
will generate a new array that contains the difference between the two. In this case, the difference is Lisa
and William
. Everything else is the intersection.
Note
The xor()
function accepts an arbitrary number of collections to compare with. Exercise caution, however, when comparing more than two collections. The most common case is to compare two collections to figure out the difference between the two. Going beyond that is venturing into set theory, and you might not get the results you'd expect.