代码整洁之道——你为什么应该使用Ramda?

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

可以看到实际上上面的代码可以分为两个步骤:

  1. 从 epub 压缩包中找到并解析 container 文件。
  2. 从 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 ,但这种函数式编程的结构比起刚才的代码清晰了许多。接下来我们实现 getContainergetRootFile 两个函数。

getContainer

而查找并解析 container 可以分为三个步骤:

  1. 从 epub 包(上面代码中变量名为 zip)的所有文件(this.zip.names)中找到 container
  2. this.zip.readFile 读取 container.xml 文件。
  3. 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 写了 getContainerFileeqContainer 两个方法,以代替原代码中的:

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 代码,其可以细分为:

  1. 从 container.xml 的解析结果中找到 result.rootfiles.rootfile
  2. 根据 rootfile 数据结构的不同,找到名为 application/oebps-package+xml 且具有 full-path 的对象
  3. 从 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 百害而无一利的想法。

参考:

相关推荐
架构师ZYL4 分钟前
node.js+Koa框架+MySQL实现注册登录
前端·javascript·数据库·mysql·node.js
一只小白菜~1 小时前
实现实时Web应用,使用AJAX轮询、WebSocket、还是SSE呢??
前端·javascript·websocket·sse·ajax轮询
晓翔仔2 小时前
CORS漏洞及其防御措施:保护Web应用免受攻击
前端·网络安全·渗透测试·cors·漏洞修复·应用安全
GISer_Jing3 小时前
【前后端】大文件切片上传
前端·spring boot
csdn_aspnet3 小时前
npm 安装 与 切换 淘宝镜像
前端·npm·node.js
GHUIJS3 小时前
【Echarts】vue3打开echarts的正确方式
前端·vue.js·echarts·数据可视化
Mr.mjw3 小时前
项目中使用简单的立体3D柱状图,不用引入外部组件纯css也能实现
前端·css·3d
托尼沙滩裤3 小时前
【CSS】 Grid布局:现代网页设计的基石
前端·css
等你许久_孟然4 小时前
【webpack4系列】编写可维护的webpack构建配置(四)
前端·webpack·node.js
E___V___E4 小时前
vue part 11
前端·javascript·vue.js