您的位置:首页 » 分类: JavaScript & ES2015 (ES6) » 文章: 一步一步教你 JavaScript 函数式编程(第二部分)

一步一步教你 JavaScript 函数式编程(第二部分)

小编推荐:掘金是一个高质量的技术社区,从 ECMAScript 6 到 Vue.js,性能优化到开源类库,让你不错过前端开发的每一个技术干货。各大应用市场搜索「掘金」即可下载APP,技术干货尽在掌握..

上一篇关于函数式编程的文章 中,我们通过处理典型的 JSON 响应数据 的需求介绍一些函数式编程的主题。

以下是我们的需求:

  • (已经完成) 过滤掉一个月前发布(比如说,30天)的文章。
  • (本文讨论) 通过文章的标签(tags)对文章进行分组(这意味着如果文章有多个标签,那么该文章会出现在多个分组中)。
  • (下一篇文章讨论) 按发布日期(published)降序排序每个标签文章列表。

上一篇文章 专注于我们上述第一项需求 – 过滤掉发布超过30天的文章。

我们还创建了一个有用的 函数式实用工具库 ,我们将在本文中继续添加它。您可以查看该 gist 的完整源代码。

在这篇文章中,我们将讨论第二个需求,它在新过滤出来的列表中按标签(tags)对我们的文章记录进行分组。

数据分组

在 JavaScript 中,我们可以使用 Array.prototype.reduce() 对列表中的元素进行分组,我们在 之前的文章 中介绍过。当然如果你不熟悉的话,现在可以去看看;但基本思想是,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);  

如图:

groupby

group()groupBy() 按列表中的每个对象中的 prop 属性名分组。如果 prop 是一个函数,它将使用通过 prop 传递每个值的结果。
这中工作方式类似于 LodashUnderscore.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 响应数据;换个角度看,它就像一个典型的数据库表,如图:

json数据的table格式

我们需要一种方法来拆分我们的列表,以便我们输出一个列表,在该列表中,每个标签和文章记录都一一对应。该输出列表非常类似于数据库中使用的链接或连接表,具有多对多关系的表 – 在本例中为标签到文章(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()。如图:

flatMapWith

function flatMap(list, fn) {  
  return flatten(map(list, fn));
}
var flatMapWith = rightCurry(flatMap);  

我们可以在我们的 pair() 函数中使用它,以确保正确的输出。如图:

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);
}

现在我们可以将整个分组流程放在一起,其中包括:

  1. 使用 pairWith() 创建一个 tag -> post 多对多的列表
  2. 使用新列表作为 groupBy() 的输入,以通过其给定的 tag(每对中的第二项)对每个记录进行分组。
var bytags = pairWith(getWith('tags'))(records);  // #1  
var groupedtags = groupBy(getWith(1), bytags);       // #2  
// {
//    'destructuring': [
//         [ { /* ... */ }, 'destructuring' ],
//         [ { /* ... */ }, 'destructuring' ]
//    ],
//    'es6': [
//        [ { /* ... */ }, 'es6' ],
//        [ { /* ... */ }, 'es6' ]
//    ],
//    /* ... */
// }

清理

因此,我们将列表重新构建为了多对多的列表,然后按标签分组,我们最终得到的结构仍然不是我们想要的 —— 每个文章记录仍然嵌套在一个数组中,其中包含了它的分组关键字键。

我们需要一种方法来映射我们的输出对象的属性,然后映射到每个数组,并使用文章记录来替换每个数组。

我们可以使用 map() 及其变体映射已经存在的数组;但是如果我们将对象视为一个列表,其中每个项目是属性及其值,我们也可以对对象执行相同的操作。

我们称之为 mapObject(),它也会返回一个对象。

pair

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);  

在这篇文章中,我们为我们的库添加了一些实用函数。我们还采用了一种迂回的方法,将初始数据转换为文章和标签之间的多对多关系。然后,我们可以为每个标签输出一个贴子列表。

我们还研究了一些常见的函数式编程和合成风格, pluckmapmapObject 。请浏览这个 gist ,以确保理解我们该系列文章第二部分完整的源代码。

在接下来的最后一篇博文中,我们将会发现,在讨论合成的时候,我们为什么会不断地对所有函数进行右柯里化;我们将完成最后的需求,就是对每一分组的文章进行排序。

JavaScript 函数式编程系列文章

英文原文:http://www.datchley.name/getting-functional-with-javascript-part-2/

正文完。下面还有一个推广让最好的人才遇见更好的机会!

互联网行业的年轻人,他们面对着怎样的职业瓶颈、困惑与未来选择?过去,这鲜有人关心。资深的职场人,也多半优先选择熟人去推荐机会。

100offer致力于改变现状,帮互联网行业最好的人才发现更好的机会。使用 100offer.com 或 100offer App ,可以一周内获得中国、美国等数千家优质企业的工作机会。

马上去遇见更好的机会
推广结束

关注WEB前端开发官方公众号

关注国内外最新最好的前端开发技术干货,获取最新前端开发资讯,致力于打造高质量的前端技术分享公众号

发表评论

电子邮件地址不会被公开。 必填项已用*标注