Ramda 有两个特性让它从其它工具库中脱颖而出:
这两个特征让我们可以很轻松利用 Ramda 写出 "point free" 风格的代码。所谓 point 就是指作为参数传进函数的数据。point free 就是脱离数据的代码风格。通过做到 point free,我们做到了行为和数据的分离,这利于我们写出更安全(组合行为时没有副作用),更易扩展(脱离数据的逻辑容易复用),和更易理解(读高阶函数的组合就像读普通英文一样)的代码。
如果你写过 React + Redux 项目,你可能经常写这种代码:
function mapStateToProps(state) {
return {
board: state.board,
nextToken: state.nextToken
}
}
我们可以用 Ramda 的 pick
函数改写一下:
import { pick } from 'ramda';
function mapStateToProps(state) {
return pick(['board', 'nextToken'], state)
}
继续改写成 point free 风格,可以写成这样:
const mapStateToProps = pick(['board', 'nextToken']);
是不是简洁了很多?
问题: 有这样一个数组:
const teams = [
{name: 'Lions', score: 5},
{name: 'Tigers', score: 4},
{name: 'Bears', score: 6},
{name: 'Monkeys', score: 2},
]
复制代码
要求找出分数最高的小组,并取到名字。
答案:
import { compose, head, sort, descend, prop } from "ramda";
const teams = [
{name: 'Lions', score: 5},
{name: 'Tigers', score: 4},
{name: 'Bears', score: 6},
{name: 'Monkeys', score: 2},
];
const sortByScoreDesc = sort(descend(prop("score")));
const getName = prop("name");
const findTheBestTeam = compose(
getName,
head,
sortByScoreDesc
);
findTheBestTeam(teams) // => Bears
复制代码
稍微感受一下。数据是在最后一步才提供的,提供目标数据之前一直在组合行为,没有改变数据,没有任何副作用。注意 compose
是从后往前组合函数,如果习惯从前往后组合函数,用 pipe
。
再来一个:
问题: 把下面这个查询字符串转成对象:
const queryString = "?page=2&pageSize=10&total=203";
复制代码
答案:
import { compose, fromPairs, map, split, tail } from "ramda";
const queryString = "?page=2&pageSize=10&total=203";
const parseQs = compose(
fromPairs,
map(split("=")),
split("&"),
tail
);
const result = parseQs(queryString); // => { page: '2', pageSize: '10', total: '203' }
复制代码
你可能会问,JS 原生都提供 map
方法了,为什么还有用 Ramda 的? 原因是文章开头提到的,Ramda 函数有两个特厉害的属性。这个例子里,给 map
传一个回调函数,它会返回一个新函数,等你给它传数据。
有时候会遇到这种情景,根据一个数据算出结果,再根据相同数据算出另一个结果,然后把两个结果进行某种运算。比如这个简单例子:
问题: 给定一个用户对象,根据用户 id 生成头像地址,并把地址合并到用户对象上。
// 合并前
const user = {
id: 1,
name: 'Joe'
}
// 合并后
{
id: 1,
name: 'Joe',
avatar: 'https://img.socialnetwork.com/avatar/1.png'
}
答案一:
const generateUrl = id => `https://img.socialnetwork.com/avatar/${id || 'default'}.png`;
const getUpdatedUser = user => ({ ...user, avatar: generateUrl(user.id) });
getUpdatedUser(user);
这个方案已经足够简洁,但是并没有达到 point free 的要求。数据 user 提前出现了,而我们期待的是在函数组合时不关心数据,哪怕是作为参数。但是,数据在计算过程中需要多次用到,怎样在没有数据(连代表数据的参数都没有)的情况下表达对未来数据的多次操作?Ramda 提供的 converge
函数可以解决这个问题:
答案二:
import { compose, converge, propOr, assoc, identity } from "ramda";
const user = {
id: 1,
name: "Joe"
};
const generateUrl = id => `https://img.socialnetwork.com/avatar/${id}.png`;
const getUrlFromUser = compose(
generateUrl,
propOr("default", "id")
);
const getUpdatedUser = converge(assoc("avatar"), [
getUrlFromUser,
identity
]);
getUpdatedUser(user)
converge
函数接受两个参数,第一个参数是最终执行的函数,第二个参数是由作用于传入数据的变形函数组成的数组。在这个例子里,user
数组先分别传给 identity
和 getUrlFromUser
函数,然后把这两个函数的计算结果分别传给 assoc("avatar")
。identity
可能是最无聊的函数,它长这样:
const identity = x => x;
我们要保留 user
数据不动,然后传给 assoc("avatar")
作为第二个参数,所以用了 identity
。
有些时候方法就在数据上。比如用 jQuery 选中 DOM 元素后,对 DOM 元素进行操作的方法。假设 DOM 上有个 ,用 jQuery 选中元素后,执行某个动画效果:
$('#el1')
.animate({left:'250px'})
.animate({left:'10px'})
.slideUp()
jQuery 的方法全在选中 DOM 元素后生成的对象上,方法是没法离开数据的。但这并不影响我们在数据还没给到之前组合行为。Ramda 提供了 invoker
函数解决类似问题:
import { invoker, compose, constructN } from "ramda";
const animate = invoker(1, "animate");
const slide = invoker(0, "slideUp");
const jq = constructN(1, $);
const animateDiv = compose(
slide,
animate({ left: "10px" }),
animate({ left: "250px" }),
jq
);
animateDiv("#el1");
animateDiv("#el2");
invoker
函数接受3个参数。第一个参数表示要在对象上执行的函数接受多少个参数,第二个参数表示要在对象上执行的函数的名字,第三个参数是目标对象。constructN
是用来实例化一个构造函数或者类。
lens
是从函数式编程语言借来的一个概念,它相当于对某个属性的聚焦。比如 lensProp('a')
,就是对 a 属性的聚焦,不管这个 a 属性由哪个对象提供。聚焦之后,我们可以很方便的读取属性(view
)和改变属性(over
)注意,Ramda 中所有改变值的操作都不是真的在原数据基础上改,而是返回改了指定属性的新值。
举个很简单的例子。
import {lensProp, view, over, toUpper} from 'ramda';
const person = {
firstName: 'Fred',
lastName: 'Flintstone'
}
const fLens = lensProp('firstName')
const firstName = view(fLens, person) // => 'Fred'
const result = over(fLens, toUpper, person)
// => {firstName: 'FRED', lastName: 'Flintstone'}
上面例子还不能看出 lens
有什么用。来看下实际使用场景:
问题:
用 React 写一个简单 counter demo,点击 + 和 — 按钮时,计数器对应加 1 和减 1。
lens
用在 React 的 setState
里非常方便:
import {inc, dec, lensProp, over} from 'ramda'
const countL = lensProp('count')
const transformCount = over(countL)
const incCount = transformCount(inc)
const decCount = transformCount(dec)
// ... 其它细节
state = {
count: 0
}
increase = () => {
this.setState(incCount);
};
decrease = () => {
this.setState(decCount);
};
// ... 其它细节
lens
与 React 的配合,最能发挥作用的情景是在写函数式组件的时候。有兴趣可以参考这个 Demo
前面提到的内容都是常规的函数组合,Ramda 还提供了 monad 的组合。抱歉要扔术语了,如果有时间,未来我可能会解释什么是 monad。大家常用的 Promise 就是个 monad,通过运行 Promise 得到值之后,你并不能在 Promise 外面操作值,而是必须在 then
方法里面处理。这就是 monad 的最大特征(它里层会返回同样的 Type,一层层嵌套,必须通过某种 flatMap 机制将里层的值取出)。
首先,最常见的是组合 Promise。来看例子。
假设我们先根据用户 email 地址请求得到用户信息,然后再根据用户 ID 得到用户粉丝数。
// 获取用户信息的异步函数
const ajaxForUserInfo = userEmail => fetch(/* post request */);
// 获取用户粉丝的异步函数
const ajaxForUserFollowers = id => fetch(/* post request*/);
const fetchUserFollowers = async userEmail => {
const userInfo = await ajaxForUserInfo(userEmail);
const userFollowers = await ajaxForUserFollowers(userInfo.id);
return userFollowers;
};
用 Ramda 提供的 composeP
,可以组合上面两个 Promise:
import {composeP} from 'ramda';
const ajaxForUserFollowers = composeP(
ajaxForUserFollowers,
ajaxForUserInfo
);
const fetchUserFollowers = async email => {
const userFollowers = await ajaxUserFollowers(email);
return userFollowers;
};
上面例子只有两个 Promise 需要组合。如果是多个的话,组合的优势更明显。
高能预警:
上面的内容已经足以覆盖大部分函数组合的需求。接下来要讲的一种函数组合算是比较硬核的函数式编程了,感兴趣的可以接着看,没兴趣的可以跳过了。
除了对 Promise 的组合,Ramda 还提供更泛的 monad 组合,叫 Kleisli Composition。暂时不用知道这玩意是什么,知道它是对各种 monad 的组合就行了。我们以 Maybe Monad 为例来看 Kleisli Composition:
可能读者在看到函数组合时会有疑问,如果某个函数有可能返回空值,还怎么组合?在每个后续函数前都做空值判断?那就真不优雅了。函数式编程提供了 Maybe Monad 进行空值处理,Maybe 可以和其它 monad 正常组合。
来看例子:
假设我们有这样一个 JSON 字符串需要解析
'{"user": {"address": {"state": "in"}}}'
我们需要取到用户的任何一个深度的地址,而每一层获取地址都可能失败。所以,每一次取值,我们都要做空值处理。
// 解析 JSON 的函数,做了错误处理
function parse(s) {
try {
return JSON.parse(s);
} catch (e) {
return null;
}
}
import Maybe from "folktale/maybe";
import { compose, composeK, toUpper } from "ramda";
// 解析 JSON 可能返回 null,所以把结果放到 Maybe 里面
const maybeParseJson = json => Maybe.fromNullable(parse(json));
// 获取属性也可能返回空值,把结果放到 Maybe 里面
const maybeProp = prop => obj => Maybe.fromNullable(obj[prop]);
// 传给 toUpper 的值可能不是字符串,也要把结果放到 Maybe
const maybeUpper = compose(
Maybe.of,
toUpper
);
// composeK 代表 Kleisli composition
const getStateCode = composeK(
maybeUpper,
maybeProp("city"),
maybeProp("state"),
maybeProp("address"),
maybeProp("user"),
maybeParseJson
);
const s = '{"user": {"address": {"state": "in"}}}';
getStateCode(s).getOrElse("Error ocurred");
// city 属性不存在,程序会返回 'Error ocurred'