Ramda真的是在恶心使用者吗?
Ramdajs 是一个实现函数式编程的 Javascript 库, 阮一峰老师的 Ramda 函数库参考教程 中详细介绍了基本的使用方法。
因为老师的教程写的已经足够好,本文不会再详述 Ramda 的使用方法,而是注重于讨论使用 Ramda 带来的一些好处,并举出一些例子。
因为许多人接触这个库的第一想法是:我为啥子要这么写代码?比如原本我写的非常自然的取值和遍历操作:
js
const ids = students.map(student => student.id)
我为什么偏偏换成这种写法:
js
const R = require("ramda")
const getId = R.prop('id')
const getIds = R.map(getId)
const ids = getIds(students)
我这不是恶心自己吗?但先别急着下结论。你可能错过了老师的另一篇文章:Pointfree 编程风格指南 ,里面引用了 Favoring Curry 的一个例子,在这个例子中你能够直观的初窥函数式编程的好处,虽然这个例子已经被反复搬运过,但我不介意在这里再次引用一次这个例子(只要有一个人还没看过这张火星图,它就还有存在的意义.jpg :D),以此来吸引读者仍然对 Ramda 保留一丝兴趣。
这个例子的场景是:我们有一个 todo-list 系统,每个用户可以建立若干个待完成的任务,每个任务的数据结构为:
json
{
"tasks" : [
{
"id" : 104,
"completed" : false,
"priority" : "high",
"username" : "xiaoming",
"title" : "Do something",
"created" : "2023-09-22",
"dueDate" : "2023-10-12"
},
...
]
}
接下来我们希望对这些任务进行筛选并展示,比如我们想要找到用户 xiaoming 的所有未完成任务,并按到期日期 dueDate
升序排列,例如:
js
const showData = [
{
"id" : 104,
"completed" : false,
"priority" : "high",
"username" : "xiaoming",
"title" : "Do something",
"created" : "2023-09-22",
"dueDate" : "2023-10-12"
},
{
"id" : 104,
"completed" : false,
"priority" : "high",
"username" : "xiaoming",
"title" : "Do something else",
"created" : "2023-09-22",
"dueDate" : "2023-10-22"
}
]
于是我们可以写一个函数来实现这个功能:
js
getIncompleteTaskSummaries = function(membername) {
// 获取数据
return fetchData()
// 取出tasks任务
.then(function(data) {
return data.tasks;
})
// 过滤指定用户的任务
.then(function(tasks) {
var results = [];
for (var i = 0, len = tasks.length; i < len; i++) {
if (tasks[i].username == membername) {
results.push(tasks[i]);
}
}
return results;
})
// 过滤出未完成的任务
.then(function(tasks) {
var results = [];
for (var i = 0, len = tasks.length; i < len; i++) {
if (!tasks[i].complete) {
results.push(tasks[i]);
}
}
return results;
})
// 过滤出需要的字段
.then(function(tasks) {
var results = [], task;
for (var i = 0, len = tasks.length; i < len; i++) {
task = tasks[i];
results.push({
id: task.id,
dueDate: task.dueDate,
title: task.title,
priority: task.priority
})
}
return results;
})
// 按到期日期进行排序
.then(function(tasks) {
tasks.sort(function(first, second) {
var a = first.dueDate, b = second.dueDate;
return a < b ? -1 : a > b ? 1 : 0;
});
return tasks;
});
};
但如果你使用了 Ramda ,上面这一长串代码可以被简化为8行代码!如下:
js
var getIncompleteTaskSummaries = function(membername) {
return fetchData()
.then(R.get('tasks'))
.then(R.filter(R.propEq('username', membername)))
.then(R.reject(R.propEq('complete', true)))
.then(R.map(R.pick(['id', 'dueDate', 'title', 'priority'])))
.then(R.sortBy(R.get('dueDate')));
};
但虽然代码行数减少了,代码的可读性却减弱了,将各个 .then
函数提取出来单独命名或许是更好的选择:
js
// 提取 tasks 属性
var SelectTasks = R.prop('tasks');
// 过滤出指定的用户
var filterMember = member => R.filter(
R.propEq('username', member)
);
// 排除已经完成的任务
var excludeCompletedTasks = R.reject(R.propEq('complete', true));
// 选取指定属性
var selectFields = R.map(
R.pick(['id', 'dueDate', 'title', 'priority'])
);
// 按照到期日期排序
var sortByDueDate = R.sortBy(R.prop('dueDate'));
// 合成函数
var getIncompleteTaskSummaries = function(membername) {
return fetchData().then(
R.pipe(
SelectTasks,
filterMember(membername),
excludeCompletedTasks,
selectFields,
sortByDueDate,
)
);
};
根据这个例子我们可以看出 Ramda 的好处:
- 让代码更加精炼
- 代码更加易读 并且由于函数是由各个短小的子函数组合而成的,代码的耦合性大大降低了,在此基础上继续维护原有代码的成本大大降低了。
实战演习
EPUB是一种电子图书标准,由国际数字出版论坛提出。其本质上就是一个 zip 压缩包,github 上有一个 nodejs 的 epub解析库 ,它写于2017年,其代码比较简单,所有逻辑写在一个 epub.js 文件中,接下来我们试着将它用 ramda 进行重写,由于篇幅有限,这里只举出部分例子进行示例,完整代码可以参考: github.com/Lemonnnnnnn... .
以下是 epub.js 中的两个函数,附上笔者的添加一些注释:
js
/**
* EPub#getRootFiles() -> undefined
*
* 从 epub 包中找到名字为 meta-inf/container.xml ,xml文件中有rootfile的信息
* 从 xml 文件中找到类型为 application/oebps-package+xml 的文件的信息,该文件即为 rootfile
* 成功找到后将其传递给 rootFile 解析函数 handleRootFile
**/
getRootFiles() {
var i, len;
//查找名字为 meta-inf/container.xml 的文件
for (i = 0, len = this.zip.names.length; i < len; i++) {
if (this.zip.names[i].toLowerCase() == "meta-inf/container.xml") {
this.containerFile = this.zip.names[i];
break;
}
}
// 错误:未找到文件
if (!this.containerFile) {
this.emit("error", new Error("No container file in archive"));
return;
}
// 读取 meta-inf/container.xml 文件
this.zip.readFile(this.containerFile, (function (err, data) {
// 错误:读取失败
if (err) {
this.emit("error", new Error("Reading archive failed"));
return;
}
// 解析 meta-inf/container.xml
var xml = data.toString("utf-8").toLowerCase().trim(),
xmlparser = new xml2js.Parser(xml2jsOptions);
// 解析完成
xmlparser.on("end", (function (result) {
// 错误:未找到 rootfile 的信息
if (!result.rootfiles || !result.rootfiles.rootfile) {
this.emit("error", new Error("No rootfiles found"));
console.dir(result);
return;
}
var rootfile = result.rootfiles.rootfile,
filename = false, i, len;
// 如果 rootfile 的信息是数组
if (Array.isArray(rootfile)) {
// 遍历数组查找类型为 application/oebps-package+xml 的文件
for (i = 0, len = rootfile.length; i < len; i++) {
if (rootfile[i]["@"]["media-type"] &&
rootfile[i]["@"]["media-type"] == "application/oebps-package+xml" &&
rootfile[i]["@"]["full-path"]) {
filename = rootfile[i]["@"]["full-path"].toLowerCase().trim();
break;
}
}
// 如果 rootfile 的信息是对象
} else if (rootfile["@"]) {
// 查找类型为 application/oebps-package+xml 且具有 full-path 的文件
if (rootfile["@"]["media-type"] != "application/oebps-package+xml" || !rootfile["@"]["full-path"]) {
this.emit("error", new Error("Rootfile in unknown format"));
return;
}
filename = rootfile["@"]["full-path"].toLowerCase().trim();
}
// 错误:找到的rootfile信息是否为空
if (!filename) {
this.emit("error", new Error("Empty rootfile"));
return;
}
// 根据 container.xml 中的信息到 epub 包中查找 rootfile 文件
for (i = 0, len = this.zip.names.length; i < len; i++) {
if (this.zip.names[i].toLowerCase() == filename) {
this.rootFile = this.zip.names[i];
break;
}
}
// 错误:没有在 epub 包中找到 rootfile 文件
if (!this.rootFile) {
this.emit("error", new Error("Rootfile not found from archive"));
return;
}
// 解析 rootFile 文件
this.handleRootFile();
}).bind(this));
xmlparser.on("error", (function (err) {
this.emit("error", new Error("Parsing container XML failed in getRootFiles: " + err.message));
return;
}).bind(this));
xmlparser.parseString(xml);
}).bind(this));
};
/**
* EPub#handleRootFile() -> undefined
*
* 解析 rootfile Xml 文件并传递给 parseRootFile 方法进行二次数据处理
**/
handleRootFile() {
// 读取 rootFile 文件
this.zip.readFile(this.rootFile, (function (err, data) {
// 错误:读取文件失败
if (err) {
this.emit("error", new Error("Reading archive failed"));
return;
}
var xml = data.toString("utf-8"),
xmlparser = new xml2js.Parser(xml2jsOptions);
// 传递给 parseRootFile 函数
xmlparser.on("end", this.parseRootFile.bind(this));
// 解析失败
xmlparser.on("error", (function (err) {
this.emit("error", new Error("Parsing container XML failed in handleRootFile: " + err.message));
return;
}).bind(this));
// 解析 rootFile 文件
xmlparser.parseString(xml);
}).bind(this));
};
可以看到实际上上面的代码可以分为两个步骤:
- 从 epub 压缩包中找到并解析 container 文件。
- 从 container 文件中找到并解析 rootFile 文件。
我们可以先看一下最后的重写结果:
ts
const fn = async (zip: AdmZip) => {
...
console.log("get container xml file ...");
const container = await getContainer(zip, filesName);
console.log("get root files ...");
const { rootFileData } = await getRootFile(
zip,
xmlparser,
container,
);
}
这里没有直接用到 Ramda ,但这种函数式编程的结构比起刚才的代码清晰了许多。接下来我们实现 getContainer
和 getRootFile
两个函数。
getContainer
而查找并解析 container 可以分为三个步骤:
- 从 epub 包(上面代码中变量名为 zip)的所有文件(
this.zip.names
)中找到 container - 用
this.zip.readFile
读取 container.xml 文件。 - 用
xmlparser
解析 cpmtainer.xml 文件
ts
// getContainer.ts
import * as R from "ramda";
import {
readZipFile,
getXmlParser,
} from "./common";
import AdmZip from "adm-zip";
export const getContainer = async (
zip: AdmZip,
names: string[],
) => {
// 从 epub 包中获取 meta-inf/container.xml 文件
const containerFile = getContainerFile(names);
if (!containerFile) {
throw new Error("No container file in archive");
}
console.log("parsing container xml file ...");
// 读取 container.xml 文件
const containerData = await readZipFile(zip, containerFile);
// 解析 container.xml 文件
const xmlParser = getXmlParser();
return await xmlParser.parseStringPromise(containerData);
};
const getContainerFile = (names: string[]) => {
return R.find(eqContainer)(names);
};
const eqContainer = (name: string) => {
return R.pipe(
R.toLower,
R.equals("meta-inf/container.xml"),
)(name);
};
上面我们使用 Ramda 写了 getContainerFile
和 eqContainer
两个方法,以代替原代码中的:
js
//查找名字为 meta-inf/container.xml 的文件
for (i = 0, len = this.zip.names.length; i < len; i++) {
if (this.zip.names[i].toLowerCase() == "meta-inf/container.xml") {
this.containerFile = this.zip.names[i];
break;
}
}
pipe
接收多个函数,将一个函数的处理结果作为参数传给下一个函数,它在按步骤处理数据十分有用,比如先将数据转为小写再和目标值 meta-inf/container.xml
进行比对。
getRootFile
接下来编写 getRootFile
代码,其可以细分为:
- 从 container.xml 的解析结果中找到
result.rootfiles.rootfile
- 根据
rootfile
数据结构的不同,找到名为application/oebps-package+xml
且具有full-path
的对象 - 从 epub 包中找到这个对象指向的文件,并解析它得到结果。
第一步
查找对象指定路径上的数据,可以使用 R.path
来实现,我们这里将 result.rootfiles.rootfile
看作一个对象集合,将其命名为 rootFilePkg
,用下面的代码实现第一步:
ts
// 从 container 中查找 rootFile 的信息
const rootFilePkg = getRootFilePkg(container);
const getRootFilePkg = (result: ParserResult) => {
return R.path<EntityPkg>(["rootfiles", "rootfile"])(
result,
);
};
第二步
用 getRootFileMsg
方法,根据不同的数据结构,从 rootFilePkg
找到名为 application/oebps-package+xml
且具有 full-path
的对象。 用 getRootFileName
方法获取这个对象的名字:
ts
// 解析 container 中 rootFile 的信息
const rootFileName = R.pipe(
getRootFileMsg,
getRootFileName,
)(rootFilePkg);
// 找到
const getRootFileMsg = (
rootfilePkg: EntityPkg | Entity,
): Entity => {
const rootfile = R.ifElse(
Array.isArray,
R.find(R.allPass([mediaTypeEqXML, hasFullPath])),
R.identity,
// @ts-expect-error
)(rootfilePkg);
if (!rootfile) {
throw new Error("No rootfile in container file");
}
return rootfile as Entity;
};
// 获取名为 `application/oebps-package+xml` 的对象
const mediaTypeEqXML = (entity: Entity) => {
return R.pipe(
R.prop("media-type"),
R.equals("application/oebps-package+xml"),
)(entity);
};
// 获取具有 full-path 的对象
const hasFullPath = (entity: Entity) => {
return R.pipe(R.prop("@"), R.has("full-path"))(entity);
};
// 获取文件名
const getRootFileName = (rootfilePkg: Entity) => {
const fullPath = R.path<string>(["@", "full-path"])(
rootfilePkg,
);
if (!fullPath) {
throw new Error("full-path file is missing");
}
return R.trim(fullPath);
};
- 我们用
R.ifElse
处理rootfilePkg
是数组或对象两种不同的情况。 - 使用
R.allPass
找到同时满足 名为application/oebps-package+xml
且具有full-path
的两个条件的对象。 R.identity
返回传入对象的自身。
第三步
ts
// 从 epub 包中获取 rootFile 的数据
const rootFileData = await getRootFileEntity(
zip,
xmlParser,
rootFileName,
);
const getRootFileEntity = async (
zip: AdmZip,
xmlParser: Parser,
rootFileName: string,
) => {
const data = await readZipFile(zip, rootFileName);
return await xmlParser.parseStringPromise(data);
};
完整的 getRootFile.ts :
ts
// getRootFile.ts
import { readZipFile } from "./common";
import * as R from "ramda";
import AdmZip from "adm-zip";
import { Parser } from "xml2js";
import {
ParserResult,
Entity,
EntityPkg,
} from "@/types/container";
export const getRootFile = async (
zip: AdmZip,
xmlParser: Parser,
container: ParserResult,
) => {
// 从 container 中查找 rootFile 的信息
const rootFilePkg = getRootFilePkg(container);
if (!rootFilePkg) {
throw new Error("No rootfile in container file");
}
// 解析 container 中 rootFile 的信息
const rootFileName = R.pipe(
getRootFileMsg,
getRootFileName,
)(rootFilePkg);
// 从 epub 包中获取 rootFile 的数据
const rootFileData = await getRootFileEntity(
zip,
xmlParser,
rootFileName,
);
return {
rootFileData,
rootFileName,
};
};
const getRootFilePkg = (result: ParserResult) => {
return R.path<EntityPkg>(["rootfiles", "rootfile"])(
result,
);
};
const getRootFileMsg = (
rootfilePkg: EntityPkg | Entity,
): Entity => {
const rootfile = R.ifElse(
Array.isArray,
R.find(R.allPass([mediaTypeEqXML, hasFullPath])),
R.identity,
// @ts-expect-error
)(rootfilePkg);
if (!rootfile) {
throw new Error("No rootfile in container file");
}
return rootfile as Entity;
};
const getRootFileName = (rootfilePkg: Entity) => {
const fullPath = R.path<string>(["@", "full-path"])(
rootfilePkg,
);
if (!fullPath) {
throw new Error("full-path file is missing");
}
return R.trim(fullPath);
};
const getRootFileEntity = async (
zip: AdmZip,
xmlParser: Parser,
rootFileName: string,
) => {
const data = await readZipFile(zip, rootFileName);
return await xmlParser.parseStringPromise(data);
};
const mediaTypeEqXML = (entity: Entity) => {
return R.pipe(
R.prop("media-type"),
R.equals("application/oebps-package+xml"),
)(entity);
};
const hasFullPath = (entity: Entity) => {
return R.pipe(R.prop("@"), R.has("full-path"))(entity);
};
总结
函数式编程的优点有许多,对个人来说,函数式编程能让代码看起来更加清晰,看最外层的函数就能明白函数的大致逻辑,如果函数的命名比较友好的话,看一个函数的名字就能知道函数的功能,而不用费劲逐行阅读代码。而 Ramda 则可以提升在 JavaScript 中实现函数式编程的效率。最后希望本文可以可以让你稍微打消在 Javascript 中使用 Ramda 百害而无一利的想法。