

在 上一篇关于函数式编程的文章 中,我们通过处理典型的 JSON 响应数据 的需求介绍一些函数式编程的主题。
以下是我们的需求:
- (已经完成) 过滤掉一个月前发布(比如说,30天)的文章。
- (本文讨论) 通过文章的标签(
tags
)对文章进行分组(这意味着如果文章有多个标签,那么该文章会出现在多个分组中)。 - (下一篇文章讨论) 按发布日期(
published
)降序排序每个标签文章列表。
上一篇文章 专注于我们上述第一项需求 – 过滤掉发布超过30天的文章。
我们还创建了一个有用的 函数式实用工具库 ,我们将在本文中继续添加它。您可以查看该 gist 的完整源代码。
在这篇文章中,我们将讨论第二个需求,它在新过滤出来的列表中按标签(tags
)对我们的文章记录进行分组。
数据分组
在 JavaScript 中,我们可以使用 Array.prototype.reduce()
对列表中的元素进行分组,我们在 之前的文章 中介绍过。当然如果你不熟悉的话,现在可以去看看;但基本思想是,reduce()
允许我们通过对数组中的每个元素进行某些迭代操作来构建一个新的值。如图:
通常,您会考虑使用一些值列表,并使用 reduce()
来生成一个单独的新值,例如:
[1,2,3,4].reduce(function(sum, n) { return sum += n; }, 0); // 10
正是这种迭代数组元素的能力,并构建一个新的值,允许我们使用 reduce()
来执行分组操作。 例如:
var list = [ { name: 'Dave', age: 40 }, { name: 'Dan', age: 35 }, { name: 'Kurt', age: 44 }, { name: 'Josh', age: 33 } ]; list.reduce(function(acc, item) { var key = item.age < 40 ? 'under40' : 'over40'; acc[key] = acc[key] || []; acc[key].push(item); return acc; }, {}); // { // 'over40': [ // { name: 'Dave', age: 40 }, // { name: 'Kurt', age: 44 } // ], // 'under40': [ // { name: 'Dan', age: 35 }, // { name: 'Josh', age: 33 } // ] // }
在上面的代码段中,我们使用 reduce()
来迭代一个对象数组list
。我们使用一个空对象作为起点,并根据年龄对记录进行分组。这样我们可以像 map 一样处理一个对象,将记录分配给结果对象上由属性名称标识的分组。
让我们通过 reduce()
使用这个功能来创建一个 group()
函数。
var toString = Object.prototype.toString; var isFunction = function(o) { return toString.call(o) == '[object Function]'; }; function group(list, prop) { return list.reduce(function(grouped, item) { var key = isFunction(prop) ? prop.apply(this, [item]) : item[prop]; grouped[key] = grouped[key] || []; grouped[key].push(item); return grouped; }, {}); } // `group()` 的 `rightCurry` 版本 var groupBy = rightCurry(group);
如图:
group()
和 groupBy()
按列表中的每个对象中的 prop
属性名分组。如果 prop
是一个函数,它将使用通过 prop
传递每个值的结果。
这中工作方式类似于 Lodash 和 Underscore.js 库中的 _.groupBy()
。
这是我们以前的例子,现在使用 groupBy()
:
var getKey = function(item) { return item.age < 40 ? 'under40' : 'over40'; }; groupBy(getKey)(list); // 给我们的结果和前面的例子一样
在本例中,我们传递一个函数,返回一个字符串作为分组操作的 prop
参数。但是我们可以使用非函数形式 prop
来操作类似于 JSON 响应数据这样的记录,我们要根据记录中的给定属性进行分组:
var list = [ { value: 'A', tag: 'letter' }, { value: 1, tag: 'number' }, { value: 'B', tag: 'letter' }, { value: 2, tag: 'number' }, ]; groupBy('tag')(list); // { // 'letter': [ // { value: 'A', tag: 'letter' }, // { value: 'B', tag: 'letter' } // ], // 'number': [ // { value: 1, tag: 'number' }, // { value: 2, tag: 'number' } // ] // }
这看起来应该很有效。但是,我们上面分组的对象列表只能属于一个可能的组:’letter’ 或 ‘number’。对象列表与分组键具有多对一的关系。
在我们的 JSON 响应数据中,情况并非如此,因为每篇文章记录的tag
属性是包含一个或多个标签名称的数组。
多对多关系的分组?
那么,我们是否遇到了麻烦,构建了一个无法满足我们需求的groupBy()
函数呢?绝对不是!毕竟,这是函数式编程,所以我们只需要使用其他函数合成的函数来构建我们想要的函数就可以!
让我们回顾一下 JSON 响应数据;换个角度看,它就像一个典型的数据库表,如图:
我们需要一种方法来拆分我们的列表,以便我们输出一个列表,在该列表中,每个标签和文章记录都一一对应。该输出列表非常类似于数据库中使用的链接或连接表,具有多对多关系的表 – 在本例中为标签到文章(tags to posts)。
这样做必然会在我们的输出中创建副本;但是我们需要这些,因为根据我们的需求,一个post可以出现在多个分组中。
我们的输出列表将类似于下面给出的表示例,如图:
在上面的图表中,很明显,我们所做的是将输出一个组合。在本例中,我们输出的是文章记录与每个标签的组合。
我们知道我们需要映射我们的列表,所以让我们创建一个 map()
和 mapWith()
函数,我们可以使用它,如图:
// 通过对`list`中的每一项应用函数`fn` // 来返回一个新的列表 function map(list, fn) { return list.map(fn); } var mapWith = rightCurry(map);
现在,我们来创建一个 pair()
函数,来组合两个列表中的元素。
function isArray(o) { return toString.call(o) == '[object Array]'; } function pair(list, listFn) { isArray(list) || (list = [list]); (isFunction(listFn) || isArray(listFn)) || (listFn = [listFn]); return mapWith(function(itemLeft){ return mapWith(function(itemRight) { return [itemLeft, itemRight]; })(isFunction(listFn) ? listFn.call(this, itemLeft) : listFn); })(list); } var pairWith = rightCurry(pair);
我们基本上使用两个列表,在第一个列表中对每一项进行映射,对于每个项目,输出将该项与第二个列表中的每一项相结合的结果,并使用一个嵌套的映射。我们还允许一个函数返回一个列表作为第二个参数,它将从每个迭代的第一个列表中传递该项。
让我们用之前的过滤记录来试试这个。我们将使用柯里化的 getWith()
传递一个函数作为第二个参数,它将返回每条文章记录上的tags
数组,作为第二个集合来进行组合。
pair(filtered, getWith('tags')); // [ // [ [ { /* ... */ }, 'functional programming' ] ], // [ [ { /* ... */ }, 'es6' ], // [ { /* ... */ }, 'promises' ] // ], // /* ... */ // ]
有趣的是,那些是正确的 tag->post 对,但是它们嵌套在二维数组中,因为我们已经嵌套了 mapWith()
调用,每个都返回一个数组。
然而,我们可以使用另一个叫 flatten()
工具函数来使它变成一维数组,例如,将[[1,2],[3,4]]转换为[1,2,3,4]。
function flatten(list) { return list.reduce(function(items, item) { return isArray(item) ? items.concat(item) : item; }, []); }
我们在此处使用 reduce()
来构建新数组,将数组中的值直接连接到结果数组中,删除嵌套。给我们以下内容:
// [ // [ { /* ... */ }, 'functional programming' ], // [ { /* ... */ }, 'es6' ], // [ { /* ... */ }, 'promises' ], // /* ... */ // ]
现在,我们现有的数据结构让我们想开始进行分组!
但是,非常普遍的操作是,在我们映射它们时 flatten 嵌套列表 – 它通常被合成一个单独的函数flatMap(list, fn)
。
让我们创建一个 flatMap()
函数和柯里化的 flatMapWith()
。如图:
function flatMap(list, fn) { return flatten(map(list, fn)); } var flatMapWith = rightCurry(flatMap);
我们可以在我们的 pair()
函数中使用它,以确保正确的输出。如图:
function pair(list, listFn) { isArray(list) || (list = [list]); (isFunction(listFn) || isArray(listFn)) || (listFn = [listFn]); return flatMapWith(function(itemLeft){ return mapWith(function(itemRight) { return [itemLeft, itemRight]; })(isFunction(listFn) ? listFn.call(this, itemLeft) : listFn); })(list); }
现在我们可以将整个分组流程放在一起,其中包括:
- 使用
pairWith()
创建一个 tag -> post 多对多的列表 - 使用新列表作为
groupBy()
的输入,以通过其给定的 tag(每对中的第二项)对每个记录进行分组。
var bytags = pairWith(getWith('tags'))(records); // #1 var groupedtags = groupBy(getWith(1), bytags); // #2 // { // 'destructuring': [ // [ { /* ... */ }, 'destructuring' ], // [ { /* ... */ }, 'destructuring' ] // ], // 'es6': [ // [ { /* ... */ }, 'es6' ], // [ { /* ... */ }, 'es6' ] // ], // /* ... */ // }
清理
因此,我们将列表重新构建为了多对多的列表,然后按标签分组,我们最终得到的结构仍然不是我们想要的 —— 每个文章记录仍然嵌套在一个数组中,其中包含了它的分组关键字键。
我们需要一种方法来映射我们的输出对象的属性,然后映射到每个数组,并使用文章记录来替换每个数组。
我们可以使用 map()
及其变体映射已经存在的数组;但是如果我们将对象视为一个列表,其中每个项目是属性及其值,我们也可以对对象执行相同的操作。
我们称之为 mapObject()
,它也会返回一个对象。
function mapObject(obj, fn) { return keys(obj).reduce(function(res, key) { res[key] = fn.apply(this, [key, obj[key]]); return res; }, {}); } // A right curried version var mapObjectWith = rightCurry(mapObject);
传递给 mapObject()
的函数不仅传递项,还传递属性名。现在,我们可以使用它来映射一个对象来转换成我们想要的结构了:
//删除分组外部的关键字,替换为文章记录 var finalgroups = mapObjectWith(function(group, set){ return mapWith(getWith(0))(set); })(groupedtags); // { // 'destructuring': [ // { id: 2, title: 'ES6 Promises', ..., tags: ['es6', 'promises'] }, // { id: 4, title: 'Basic Destructuring in ES6', ..., tags: ['es6', 'destructuring'] }, // ], // 'es6': [ /*...*/ ], // /*...*/ // }
更具声明性
以上使用的操作,我们想从对象列表中提取一个特定属性值,mapWith(getWith(prop))
,这是一个相当常见的操作。因此,这通常被命名为pluck()
,你可以在许多函数库中找到它。
// 对于`list`中的每个对象,返回`prop`的值 function pluck(list, prop) { return mapWith(getWith(prop))(list); } // `pluck` 右柯里化版本 var pluckWith = rightCurry(pluck);
这是更声明性的,并提供了我们可以重用的另一个高阶函数。但是,我们希望我们的代码能够更详细地描述它实际执行的操作 —— 从每个嵌套对中获取文章记录。
我们先来看看我们传递给 mapObjectWith()
的函数:
function getPostRecords(prop, pair) { return pluckWith(0)(pair); }
啊,这样更具描述性。并结合我们原始的解决方案,我们实际执行的操作变得更具声明性。
var finalgroups = mapObjectWith(getPostRecords)(groupedtags);
完整的实现
满足第二项需求的最终实现:
// Step 1: 构建多对多的列表 var bytags = pairWith(getWith('tags'))(records); // Step 2: 按 tag是分组 (pair[1]): var groupedtags = groupBy(getWith(1), bytags); // Step 3: 在嵌套对中去掉额外的键值: function getPostRecords(prop, value) { return pluckWith(0)(value); } var finalgroups = mapObjectWith(getPostRecords)(groupedtags);
在这篇文章中,我们为我们的库添加了一些实用函数。我们还采用了一种迂回的方法,将初始数据转换为文章和标签之间的多对多关系。然后,我们可以为每个标签输出一个贴子列表。
我们还研究了一些常见的函数式编程和合成风格, pluck
, map
和 mapObject
。请浏览这个 gist ,以确保理解我们该系列文章第二部分完整的源代码。
在接下来的最后一篇博文中,我们将会发现,在讨论合成的时候,我们为什么会不断地对所有函数进行右柯里化;我们将完成最后的需求,就是对每一分组的文章进行排序。
JavaScript 函数式编程系列文章
- JavaScript 中的 Currying(柯里化) 和 Partial Application(偏函数应用)
- 一步一步教你 JavaScript 函数式编程(第一部分)
- 一步一步教你 JavaScript 函数式编程(第二部分)
- 一步一步教你 JavaScript 函数式编程(第三部分)
英文原文:http://www.datchley.name/getting-functional-with-javascript-part-2/
最新评论
写的挺好的
有没有兴趣翻译 impatient js? https://exploringjs.com/impatient-js/index.html
Flexbox playground is so great!
感谢总结。
awesome!
这个好像很早就看到类似的文章了
比其他的教程好太多了
柯理化讲的好模糊…没懂