为什么要有Ramda类库

JavaScript是一个Lisp特点的语言,不过这也给使用者带来了很多麻烦。在经过多年的演变,JavaScript也用了自己的规范ECMAScript。在JavaScript的不断演变的过程中,出现了很多非常优秀的类库。众所周知,程序是数据+算法,Ramda函数库为我们提供了一组简化算法编写的工具,本文将为大家介绍RamdaJS,一个非常值得学习的Point Free Style的函数编程类库。

原文地址 Why Ramda?buzzdecafe向我们介绍Ramda时,在两组人群中产生了截然不同的反应。那些不管是在JavaScript还是其它语言中习惯了函数式编程的人会说:”哇哦,酷呀!“。他们会对此感到兴奋,或者注意到它是一个有前途的工具,但是不管怎么说,他们非常清楚它的用途。

而另外一组人的反应是:“这是什么?”

Rbudihaso Tweet

Rbudihaso Tweet

对那些没有尝试过函数编程的人群来说,Ramda显得毫无用武之地。因为它其中的功能绝大部分已经被UnderscoreLoDash这两个类库实现了。

这些人是对的。如果我们只是打算继续使用遗忘的命令式和面向对象的编程方式。Ramda对我们来说没什么太大的用处。

然而,它提供了一种完全不同的编程方式,一种借鉴了函数式语言中很常见的模式:Ramda让我们通过函数组合来创建复杂的业务逻辑。请大家注意,任何具有compose函数的库都可以让我们组合函数;这里真正的重点是:“_让事情变简单_”。

让我们看看Ramda是如何工作的吧。

“TODO列表”好像是所有Web框架用来进行比较的常见案例,那么也让我们用“TODO列表“来做案例吧:让我们假设一个需求,我们想过滤TODO列表中所有完成的项目并将这些完成项目删除。

如果使用Array的自带的功能,我们可以这样完成这件事情:

1
2
3
4
// Plain JS
var incompleteTasks = tasks.filter(function(task) {
    return !task.complete;
});

如果使用LoDash,这似乎可以更简单一些:

1
2
// Lo-Dash
var incompleteTasks = _.filter(tasks, {complete: false});

无论怎么样,我们都得到了我们想要的结果。

在Ramda中,我们需要这样做:

1
var incomplete = R.filter(R.where({complete: false});

(更新: where函数已经被分成了两个函数wherewhereEq,上面的代码将会有点小问题)。

大家可能已经发现了似乎这里面缺少一些东西,对的,TODO列表的数组不见了,这里Ramda只给我们了一个函数。我们还需要在列表上调用该函数才能得到我们想要的结果。

这就是重点。

因为我们现在已经拥有了一个业务函数,我们可以很容易的将它和其它的函数进行组合,然后对我们的数据集进行操作。假如我们还有另外一个业务函数groupByUser,将TODO列表中的项目按照用户进行分组,我们可以非常简便的创造一个新的业务函数。

1
var activeByUser = R.compose(groupByUser, incomplete);

找到所有尚未完成的任务,并对这些任务按用户分组。

再一次,我们没有提供任何数据,因为这还是一个函数,如果我们想自己手写这个函数,那么它将会像下面这样子:

1
2
3
4
// (if created by hand)
var activeByUser = function(tasks) {
    return groupByUser(incomplete(tasks));
};

我们并没有必要自己去手写这样的函数。并且函数组合是函数语言的一个重要技术。让我进一步去发现它还有什么能力。如果我们需要按照每个用户的任务截止时间对用户的未完成列表进行排序,该如何做呢?

1
var sortUserTasks = R.compose(R.map(R.sortBy(R.prop("dueDate"))), activeByUser);

一步到位?

细心的读者可能已经发现,我们可以将上面所有的操作进行组合。因为compose函数,容许我们传入2个以上的参数,那么我们为什么不一步完成所有的组合呢?

1
2
3
4
5
var sortUserTasks = R.compose(
    R.mapObj(R.sortBy(R.prop('dueDate'))),
    groupByUser,
    R.filter(R.where({complete: false})
);

我的回答是,如果我们不需要中间的业务函数activeByUserincomplete的功能时,这么做是合理的。但是这会让调试变的比较困难,因为这会让代码有一点不可读。

但是,我们可以朝另外一个方向思考下。如果我们在实现一个非常复杂的功能室,它内部的一些功能本身就可能是可以被重用的。也许我们这样做会更好一些:

1
2
var sortByDate = R.sortBy(R.prop('dueDate'));
var sortUserTasks = R.compose(R.mapObj(sortByDate), activeByUser);

现在我们可以使用sortByData来按照截止日期对任务列表进行排序。(事实上,它比我们设计的更加灵活来;该函数可以对任何有“dueDate”属性的任何对象的集合进行排序。)

哦,等等,也许有人说,我们不应当对日期进行降序排列吗?

1
2
var sortByDateDescend = R.compose(R.reverse, sortByDate);
var sortUserTasks = R.compose(R.mapObj(sortByDateDescend), activeByUser);

如果我们非常确定我们只想按最近日期在最前的方式进行排序,我们可以这些一次性组合成一个sortByDateDescend函数。我个人一般会保留这两个定义,以防我同时需要降序或者升序排列。但是真正业务中,还是要取决于读者自身的需要,而非固定的模式。

数据在哪里?

到目前为止,我们依然没有使用任何数据。这是怎么回事那?没有数据的数据处理,呃,好吧,处理数据。我恐怕,读者们还需要更多耐心。当我们使用函数式编程时,我们只会得到函数组成的处理流水线。一个函数处理完数据并将其结果传递给下一个函数,下一个函数做个同样的事情,以此类推,直到整个流结束,我们就得到了结果。

到目前为止我们已经有了以下函数集合:

1
2
3
4
5
incomplete: [Task] -> [Task]
sortByDate: [Task] -> [Task]
sortByDateDescend: [Task] -> [Task]
activeByUser: [Task] -> {String: [Task]}
sortUserTasks: {String: [Task]} -> {String: [Task]}

虽然我们已经使用前面的函数构建了sortUserTasks,但是前面的函数依然有自己的用途。不过我们确实还没有说明一些事情。我只是想让大家想象一下,我们已经有了函数byUser,并且通过它来构建activeByUser;但是实际上我们并没有看到byUser函数。是我忘记了吗?或者你已经注意到了?我们是如何做的呢?

它在这里

1
var groupByUser = R.partition(R.prop('username'));

partition函数使用了Ramda版本的reduce函数,reduce函数和Array.prototype.reduce非常像。~~同样在很多函数语言里面它被称为foldl。~~(更新:Ramda已经删除掉所有的别称了。)我们会进一步讨论相关话题。当然我们可以通过这个搜索连接来进一步了解Web世界中的reduce。我们的partition函数就是使用reduce函数检查列表中的每一个对象,将有所有相同key直的对象放入一个子列表中,在这个例子里面就是prop('username'),它会简单的从每个对象中读取“username”这个属性。

(所以,我是否把读者你的注意力拉到了我们全新的函数上了?我依然没有讨论数据!抱歉,继续往下看,更多崭新的函数将要出现了!)

等一下,这里还有更多

我们只要喜欢,就可以源源不断的讨论下去。如果我们想获取列表中前五个元素,我们可以使用Ramda中的函数take。所以,我们取每个用户的前5个元素可以这样作:

1
var topFiveUserTasks = R.compose(R.mapObj(R.take(5)), sortUserTasks);

(是否有人想到了Brubeck and Desmond

然后我们可以取得返回值中个的一部分属性,例如标题和截止日期。在这个数据结构中用户名明显是个冗余信息,或者其它我们不想传递给其它系统的多余的字段。

这里我们可以使用Ramda中类似SQL的select函数,该函数名为project:

1
2
var importantFields = R.project(['title', 'dueDate']);
var topDataAllUsers = R.compose(R.mapObj(importantFields), topFiveUserTasks);

一路走来,我们创建很多函数,在我们的TODO应用中,其中有很多函数好像真的可以被重用。剩下的,就像占为符一样,我们会将它和其函数它组合成一个非常重要的函数。如果,我们回头再看这些函数,我们也许会这样组合代码:

1
2
3
4
5
6
7
8
var incomplete = R.filter(R.where({complete: false}));
var sortByDate = R.sortBy(R.prop('dueDate'));
var sortByDateDescend = R.compose(R.reverse, sortByDate);
var importantFields = R.project(['title', 'dueDate']);
var groupByUser = R.partition(R.prop('username'));
var activeByUser = R.compose(groupByUser, incomplete);
var topDataAllUsers = R.compose(R.mapObj(R.compose(importantFields, 
    R.take(5), sortByDateDescend)), activeByUser);

好了,好了,可以探讨下数据了?

是的,让我们来探讨数据了。

现在,是时候将数据传入我们的函数中了。目前,这些函数都只接受相同的类型的数据,一个TODO数据数组。我们并没有特别的去定义这些数据的结构,但是我们需要知道的是,这些数据必须拥有以下这些属性。

  • complete: 布尔类型
  • dueDate: 字符串,格式为YYYY-MM-DD
  • title: 字符串
  • userName: 字符串

好了,现在我们有了一个任务数组,我们该如何使用呢?简单:

1
var results = topDataAllUsers(tasks);

就这样吗?

前面这么多积累,就这样吗?

我像是的,我们将会得到下面这些对象:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
{
    Michael: [
        {dueDate: '2014-06-22', title: 'Integrate types with main code'},
        {dueDate: '2014-06-15', title: 'Finish algebraic types'},
        {dueDate: '2014-06-06', title: 'Types infrastucture'},
        {dueDate: '2014-05-24', title: 'Separating generators'},
        {dueDate: '2014-05-17', title: 'Add modulo function'}
    ],
    Richard: [
        {dueDate: '2014-06-22', title: 'API documentation'},
        {dueDate: '2014-06-15', title: 'Overview documentation'}
    ],
    Scott: [
        {dueDate: '2014-06-22', title: 'Complete build system'},
        {dueDate: '2014-06-15', title: 'Determine versioning scheme'},
        {dueDate: '2014-06-09', title: 'Add `mapObj`'},
        {dueDate: '2014-06-05', title: 'Fix `and`/`or`/`not`'},
        {dueDate: '2014-06-01', title: 'Fold algebra branch back in'}
    ]
}

但是,这里有个非常有意思的事情。我们可以将相同的任务列表传给incomplete,并且会得到我们想要的结果。

1
var incompleteTasks = incomplete(tasks);

或许结果如同下面这样子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
[
    {
        username: 'Scott',
        title: 'Add `mapObj`',
        dueDate: '2014-06-09',
        complete: false,
        effort: 'low',
        priority: 'medium'
    }, {
        username: 'Michael',
        title: 'Finish algebraic types',
        dueDate: '2014-06-15',
        complete: false,
        effort: 'high',
        priority: 'high'
    } /*, ... */
]

当然,我们也可以将任务列表传入sortByDatasortByDateDescendimportantFieldsbyUser,或者activeByUser。因为这些函数都可以将相同类型的数据作为参数-一个任务列表数组-我们可以构建一大堆工具,并将它们组合起来。

哦,新的需求

好了,在工程完成一半的时候,我们得知我们需要作一个新的特性。我们需要为那些特定的用户过滤他们的任务列表,然后执行和前面相同的过滤,排序,获取子集以及将用户名和任务列表关联起来。

当前这些逻辑都包含在了topDataAllUsers,这个函数向我们展示了,我们或许过渡组合我们的函数。但是我们很容易重构它。就如同我们必须经常面对的问题,最难的问题是,我们不知道该取一个什么样的名字为好。“gloss”也许不是一个好名字,但是夜已经深了,这是我能想到的最好的名字了:

1
2
3
4
var gloss = R.compose(importantFields, R.take(5), sortByDateDescend);
var topData = R.compose(gloss, incomplete);
var topDataAllUsers = R.compose(R.mapObj(gloss), activeByUser);
var byUser = R.use(R.filter).over(R.propEq("username"));

然后,当我们想使用它的时候,我们只需要这样

1
var results = topData(byUser('Scott', tasks));

嗯很好,拜托,我只想要我的数据

“好的”,或许你会说,“这也许很酷,但是我只想得到我需要的数据,我不想再知道那些或许某天可以返回我数据的函数了,或许,我也能使用Ramda?”

当然,毋庸置疑,你当然可以使用它。

让我们回到我们最开始的函数。

1
var incomplete = R.filter(R.where({complete: false}));

那么我们是如何使用这个函数来得到我们想要的数据呢?它非常简单:

1
var incompleteTasks = R.filter(R.where({complete: false}), tasks);

并且其它函数也是相同的:只要将tasks这个参数放在函数最后,我们就可以得到所想要的数据了。

这里发生了什么?

Ramda里面还有一个非常重要的特性。Ramda中所有核心函数都会自动柯里化。如果我们没有给目标函数提供它所需要的全部参数,函数是不会调用的,取而代之的它会返回一个全新的函数。虽然我们filter函数的参数为一个数组和一个用来判定函数。但是在最开始的版本中,我们并没有提供数组,所以filter函数就返回一个全新的函数,该函数将会接收数组作为它的参数。在第二个版本中,我们传入了数组,这样就满足了filter的触发条件,从而进行过滤操作。

Ramda的自动柯里化设计坚持着函数在前,数据在后的API设计,这使得Ramada非常容易使用,也非常容易进行函数组合。

关于这Ramda中柯里化的细节将是另外一篇博文的内容了(更新:爱不释手的柯里化)。此时此刻,有篇博文非常值得一读,它就是Hugh Jackson的为什么柯里化能帮助我们解决问题

译注

最后两段文字我并没有去翻译,其中一段主要是JSFiddle执行样例,但是JSFiddle的访问似乎有很多问题。另外一段就是非常常规的安装。

我带领的前端团队一直在使用LoDash,当React在16开始转向函数式编程后,我也逐步的将我的函数式编程理念和经验传递给我所在的团队。尤其是对于Functional Reactive Programming这一UI编程理念的实践,但是在实践的过程中,发现LoDash很难满足我们对业务抽离,组合重用的需求,其中需要增加很多额外的包装。最开始的想法是自己去实现一个更好的LoDash,但是在无意的阅读中发现了Ramda库。在Ramda的3年实践中,我们总结了很多经验,即便在没有测试用例的情况下,我们整个项目的Bug率也是非常低的,同时大量的使用函数组合和柯里化这两大工具,让我们大大的减少了工作量,提高了开发的速度。

无论前端和后端,绝大部分业务依然是CRUD为主,很多业务逻辑是具备通用性的,当我们的思维从面向过程转变为面向数据流时,我们的工作就会变得轻松而有趣。尤其是当我们面对中途的需求变更,也不再是手忙脚乱。