使用 typescript 重写 Ramda 基于函数式编程思想的工具库 (一)

2,313 阅读7分钟

前言

函数式编程在前端已经成为了一个非常热门的话题。在最近几年里,有非常多的应用以及工具库在大量使用着函数式编程思想。比如 react 的高阶函数(HOC)、我们今天的主角 Ramda 等。

同时 typescript 在最近也是非常火的,众多的应用以及工具库都在使用 typescript 进行重构,vue3.0 全面拥抱 typescriptreact 也可以和 typescript 很好的结合在一起。

所以为了更好的学习 typescript 以及函数式编程,我们将使用 typescript 重构 Ramda 工具库。我们将从如何构建工具库开始,直到发布自己的 npm 包。

一、所涉及到的技术

你需要具备一定的 npmtypescript 的知识。

  • lerna

    我们将每一个函数都发布成一个单独的包,所以我们使用 lerna 做统一的管理。当然你可以选择全部发布成一个包, 使用 babel 或者 webpack 开始 treeshaking 来处理。这里稍后我们会详细讲到。

  • rollup 用来编译、打包我们的工具库。

  • jest 用来做单元测试

  • eslint 用来代码校验,结合 @typescript-eslint/eslint-plugin@typescript-eslint/parser 来校验 typscript , 代替 tslint

  • prettier 结合 eslint-config-prettiereslint-plugin-prettier 来美化我们的代码。

  • commitizenhuskylint-staged 等来规范我们的 commit 提交信息,便于我们生成 changelog

二、一些常见的概念

我相信一说到函数式编程很多同学都能说出一些概念出来,比如:纯函数、高阶函数、函数柯里化、函数组合等等。其实大家平常也会用到函数式编程,比如常见的 mapfilterreduce 等函数。那么到底什么是函数式编程呢?

函数式编程是一种编程范式(声明式、命令式)。主要是利用函数将运算过程封装起来,通过组合各种函数来计算结果。

其他的概念本文不在罗列,感兴趣的同学可以参考一下文章:

这里想重点讲一下 Pointfree, 不使用所要处理的值,只合成运算过程。 中文可以译为无值风格。 我们直接通过例子来理解 Pointfree, 部分例子拷贝自 Scott Sauyet 的文章 《Favoring Curry》,那篇文章能帮助你深入理解柯里化,强烈推荐阅读。

下面是一段服务器返回的 JSON 数据。

var data = {
    result: "SUCCESS",
    interfaceVersion: "1.0.3",
    requested: "10/17/2013 15:31:20",
    lastUpdated: "10/16/2013 10:52:39",
    tasks: [
        {id: 104, complete: false,            priority: "high",
                  dueDate: "2013-11-29",      username: "Scott",
                  title: "Do something",      created: "9/22/2013"},
        {id: 105, complete: false,            priority: "medium",
                  dueDate: "2013-11-22",      username: "Lena",
                  title: "Do something else", created: "9/22/2013"},
        {id: 107, complete: true,             priority: "high",
                  dueDate: "2013-11-22",      username: "Mike",
                  title: "Fix the foo",       created: "9/22/2013"},
        {id: 108, complete: false,            priority: "low",
                  dueDate: "2013-11-15",      username: "Punam",
                  title: "Adjust the bar",    created: "9/25/2013"},
        {id: 110, complete: false,            priority: "medium",
                  dueDate: "2013-11-15",      username: "Scott",
                  title: "Rename everything", created: "10/2/2013"},
        {id: 112, complete: true,             priority: "high",
                  dueDate: "2013-11-27",      username: "Lena",
                  title: "Alter all quuxes",  created: "10/5/2013"}
        // , ...
    ]
};

现在的要求是,找到用户 Scott 的所有未完成的任务,并按照日期的升序排序, 并且只返回必要的数据。

[
    {id: 110, title: "Rename everything", dueDate: "2013-11-15", priority: "medium"},
    {id: 104, title: "Do something", dueDate: "2013-11-29", priority: "high"}
]

过程式编程如下

const getIncompleteTaskSummaries = function (membername) {
    return fetchData()
        .then(data => {
            const tasks = data.tasks;
            // 这里我们就不使用filter等函数了毕竟filter等函数也属于函数式(哈哈)
            const results = [];
            for (let i = 0; i < tasks.length; i++) {
                if (tasks[i].username === membername && !tasks[i].complete) {
                    results.push({
                        id: tasks[i].id,
                        dueDate: tasks[i].dueDate,
                        title: tasks[i].title,
                        priority: tasks[i].priority
                    });
                }
            }
            
            return results.sort((a, b) => a.dueDate - b.dueDate);
        });
}

上面的代码不仅可读性差而且是脆弱的,很容易出错。

我们使用 Ramda 提供的函数,使用 Pointfree 风格改写一下:

const getIncompleteTaskSummaries = function (membername) {
    return fetchData()
        .then(R.prop('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.prop('dueDate')));
}

另外一种写法就是利用函数组合把各个 then 的函数组合在一起:

// 获取 tasks 属性
const selectTasks = R.prop('tasks');
// 过滤指定的用户
const filterMember = member => R.filter(R.propEq('username', member));
// 排除已经完成的任务
const excludeCompletedTasks = R.reject(R.propEq('complete', true));
// 选取指定的属性
const selectFields = R.map(R.pick(['id', 'dueDate', 'title', 'priority']));
// 根据日期升序排序
const sortByDueDate = R.sortBy(R.prop('dueDate'));

const getIncompleteTaskSummaries = function (membername) {
    return fetchData().then(
        R.pipe(
            selectTasks,
            filterMember(membername),
            excludeCompletedTasks,
            selectFields,
            sortByDueDate
        )
    );
}

上面的代码也很一目了然。这里有的同学会有一些疑问,函数组合不是 compose 吗,这里怎么使用 pipe,其实他们的原理是一样的。只不过 compose 组合的函数的执行顺序是从右到左的:

// 超级简单版 compose
const compose = (f, g) => x => f(g(x));
const add1 = x => x + 1;
const mul5 = x => x * 5;
compose(mul5, add1)(2)  // => 15
compose(add1, mul5)(2)  // => 11

这样的话多少有一点不好理解,pipe 让组合函数的执行顺序变成从左到右,增强可读性。

有的同学又可能会说我可以使用 ES6 写出更简单的方法:

const getIncompleteTaskSummaries = function (membername) {
    return fetchData().then(data => {
        const tasks = data.tasks;
        const filterByName = tasks.filter(t => t.username === membername);
        const filterIncomplete = filterByName.filter(t => !t.complete);
        const selectFields = filterIncomplete.map(m => ({
            id: m.id,
            dueDate: m.dueDate,
            title: m.title,
            priority: m.priority
        }));
        
        return selectFields.sort((a, b) => a - b);
    });
}

但其实你有没有发现这里面也用到了函数式编程的思想,但他还是有一些问题的,它的可读性还是不够好,同时如何我们的需求有变动不在是获取 Tasks 了,那么下面的所有的代码都会有问题,因为他们每一步都用到了上一步的变量。而函数式则不同,只需要改动函数组合的部分就可以了。

从上面的例子中我们也可以看到函数式编程,需要定义很多单一功能的函数,然后通过函数组合来满足不同的需求,在工作中频繁定义这些函数也是不现实的,Ramda 为我们提供了很多这些的函数,方便我们的日常开发。

好了,回到主题,我们将一步步使用 ts 重构 Ramda 工具库。

使用 lerna 初始化工程

一般我们初始化一个工程的话是这样的:

mkdir lib-demo
cd lib-demo
npm init

然后根据命令一步一步执行,如果我们要把 Ramda 的每个函数都发布成一个 npm 包的话,那就要重复上面的过程。但这里其实是有一些问题的:

  • issue 管理混乱
  • changelog难以整合,需要人工梳理变动的 repo, 在加以整合。
  • 版本更新麻烦,需要同步依赖

这其实就是 multirepo 传统的做法, 即按模块分成多个代码库。与之对应就是 monorepo, 将所有的模块放在同一个 repo 中,每个模块单独发布,但所有的模块使用与该 repo 统一的版本号(例如 ReactBabelvue-cli)。

Lerna is a tool that optimizes the workflow around managing multi-package repositories with git and npm.

多模块管理工具,用来帮助维护monorepo。

如何使用 lerna 来初始化一个工程呢?

// 安装 lerna
npm i lerna -g
// or
yarn global add lerna

mkdir lib-demo
cd lib-demo
lerna init

根据命令一步一步即可。lerna 初始化后的目录结构是

├── lerna.json # lerna配置文件
├── package.json
└── packages # 包存放文件夹

如果可以得到上面的结构,说明你已经初始化完成了。

下一篇文章我们将继续学习更多 lerna 的用法,如何创建一个模块、如何处理依赖、如何下载依赖(全局依赖,各个模块不同的依赖)、如何发布等等。

参考文章