函数式编程的理解和前端实践

函数式编程是一种特别的编程范式(paradigm),这种编程范式的主旨是把运算过程写成一系列函数的组合。
函数式编程强调了 “函数是一等公民”、“无副作用”、“无状态” 这些概念,也正因如此,函数式编程的代码,更容易编写单元测试,更容易在并发场景下应用,更容易管理。

如果是做后端开发,每天都要和数据打交道,可能很难体会到函数式编程的好处。但对于 JavaScript 和 Web 开发者而言,函数式编程已经是一个很普及的概念。对于 Web 开发者而言,数据的处理、展示本身就是一个函数式编程应用的场景,而尤其是 React、Vue 这类工具,它们好像天生就契合函数式编程的思想。

本文以 React 开发为出发点,从理念、工具、开发实践这些方面,提供一套函数式编程在 Web 开发中的理解和实践。

函数式编程的相关概念

可以通过阮一峰的这篇 函数式编程初探 来简单了解函数式编程,这篇文章写于 2012 年。

概念 1:函数是一等公民

函数式编程,顾名思义函数是作为开发最主要的载体,因此编程语言需要使函数类型的地位和其他类型保持平等,例如可以赋值、作为参数、作为返回值等。JavaScript 符合这个条件,所以 Web 开发可以较容易的接受和使用函数式编程。

JavaScript 中最简单的例子,就是 Array.prototype.forEach() 了:

// 函数可以赋给变量
const log = console.log

// 函数可以作为参数
[1, 2, 3, 4].forEach(log)
[1, 2, 3, 4].forEach(console.log)

对于 React 开发者而言,这也很好理解,React 的 FC 函数式组件便是将函数当做一等公民的最好体现。

概念 2:无副作用

无副作用指的是,在一个函数运行过程中,它不会修改传入的参数、不会修改外部参数,也不会产生额外的输出,它只是单纯的接受输入-进行计算-输出结果。

对于 Web 开发者而言,无副作用的函数还有一些额外的优点:如果你使用 Webpack 这类打包工具,无副作用的函数如果未被使用过,或被调用了但是其返回值未被使用,那这些代码调用可以被工具安全的删除,减少代码包的体积。

我们以 JavaScript 标准库中的函数举例:
无副作用的函数,例如 Array.prototype.slice(),代码如下:

let array = [1, 2, 3, 4, 5, 6]
const result = array.slice(1,3)

console.log(array)  // 这里可以发现 array 并没被修改
console.log(result)

而有副作用的函数,例如 Array.prototype.reverse(),代码如下:

const array = [1, 2, 3, 4, 5, 6]
array.reverse()

console.log(array) // 可以发现 array 已经被逆序了

这里的 Array.prototype.reverse() 很反直觉,它返回了已逆序的结果,但是它自身又把原来数组也变成逆序了。如果是新手编程,可能在这里就会弄错,导致出现 bug。

也正因如此,JavaScript 新的标准库提案中(自 2023 年开始可用),为 Array 原型上面一部分有副作用的方法提供了无副作用的版本:


对于 React 开发者而言,无副作用的概念也很好理解,函数式组件的代码一般都是纯净的,它一般不会修改外部变量,更不会修改参数,因为组件使用事件来通知调用方进行数据修改。
因此,React 函数式组件的单元测试代码较为容易书写,只要保证输入和输出之间的正确关系即可。

概念 3:无状态

无状态是指,函数运行的过程中,不会有可以被修改的状态,简单理解就是尽量避免使用中间变量,如果非得使用,那么需要避免修改中间变量的状态,比如使用 const 声明来保证中间状态无法被修改。

函数式编程追求 “无状态” 的原因是,可修改的状态其实也是一种 “副作用”,会导致其中某些步骤,数据上存在不确定性。

这里举个简单的例子:

const array = [1, 2, 3]

for (let i = 0; i < array.length; ++i) {
  doSomething(array[i])
}

用这种方式可以遍历一个数组。
但是,这里的代码中多出了 i 这个可变的状态,它存在被意外修改的可能。

修改为:

for(const item of array) {
  doSomething(item)
}

这样就消除了一个可变状态,更符合函数式编程的要求。
当然,这种写法也不够好,最好的办法就是使用 “纯粹的函数组合” 来达成需求,而不是使用循环等语法:

array.forEach(doSomething)

这种写法最纯粹,最符合函数式编程的思想。


对于 React 而言,如果一个组件以单纯展示内容为目的,例如常见的 <Card><Avatar> 组件,它只需要以特定格式显示数据,这种组件符合 “无状态” 的要求,React 称之为 “纯净组件”;

早期还在使用 class 组件时,对于这类组件,React 提供了 PureComponent 这个基础类,组件继承这个类进行开发时,无法访问 this.state,因为不允许有中间状态;而函数式组件就更好理解了,组件本身就是一个函数,接收参数并最终输出 JSX 即可。

但是,也有很多组件是带有状态的,例如一个折叠组件,可以展开或者收起,此时它便必须保存中间状态,否则无法正常工作。
如果使用 class 组件,那么此时就需要在 this.state 中储存并维护状态,通过 this.setState() 修改状态,这种组件不纯净,不符合函数式编程的要求。
如果是函数式组件开发,React 给出了 “Hooks” 这一概念,它提供了在组件外部维护状态的能力,这样便使得组件代码本身是纯净的。
例如:

import { useState } from 'react'

function Collapse() {
  const [isOpen, setIsOpen] = useState(false)

  const open = () => void setIsOpen(true)
  const close = () => void setIsOpen(false)

  return isOpen ? <div>已展开...</div> : <div>已折叠...</div>
}

此时这个组件就好像纯净组件一样,自身没有保留任何状态,所有的变量也都是 const 声明的。
React Hooks 允许我们使用它提供的函数,把组件的状态移出组件之外,并通过 useState() 等方式来访问;这样以来,对于组件而言,它自身的代码是 “纯净无中间状态” 的。

不可变数据

不可变数据是对 “无状态” 的一种延伸和实现。如果一定要使用中间变量,那么就采取一些方式避免变量被改变。
最简单的方式,就是把所有中间变量都使用 const 来定义,这样就无法被修改了。

不过,虽然使用 const 可以有效避免值变量被修改,但对于对象类型的变量,仅仅使用 const 是不够的,因为对象的属性值有可能被修改


原生不可变对象:

最简单的方式就是使用 Object.freeze() 来冻结对象。

但是,使用 Object.freeze() 这种方式其实也不符合函数式编程的原则,因为 Object.freeze() 本身是有副作用的,它会修改传入的对象,使它所有的属性变为只读。

并且,在实际开发中,我们或许会有修改对象属性的需求。直接修改,肯定不提倡,而且也违背了函数式编程的原则。最简单的方式就是创建一个新对象,复制原对象的所有值,但覆写某个属性。
代码如下:

const user = { id: 1234, name: 'PaperPlane' }

// 想要修改 name 属性
const newUser = { ...user, name: 'NewName' }

实际上,对于 React 开发者而言,这也是唯一正确的修改对象属性的写法,如果直接修改属性,对象的引用不变,极有可能无法触发相关子组件的更新;而这种写法声明出的 newUser 是新对象,引用不同,因此可以正确应用到 React 触发更新。

但是,如果对象嵌套深度过深,此时修改某个属性便会极为复杂。
例如:

const userList = [
  {
    name: 'PaperPlane',
    skills: [
      { id: 1, label: 'React' },
      { id: 2, label: 'Vue' },
    ],
  },
]

如果想修改这里的 'Vue' 的内容,这将非常困难。为此,我们可以使用更为先进的工具——不可变数据。


Immer.js 不可变对象:

对于通用对象和数组格式而言,比较常用的不可变数据工具有 Immutable.jsImmer

此处以 Immer 为例,使用它,我们可以简单的修改复杂对象中的任一字段:

import { produce } from 'immer'

const newUserList = produce(userList, draft => {
  draft[0].skills[1].label = 'Others'
})

这里的 newUserList 是一个新对象,除了被修改的字段以外,其他字段和原来的对象相同。


Day.js 不可变时间日期:

不可变对象的概念同样在时间日期库中有适用。

dayjs 发布时,它和 moment.js 相比除了优化了代码体积之外,最明显的一点就是:dayjs 的对象是不可变对象。dayjs 的修改时间属性的方法,全部都是返回一个新的对象,而原来的对象是绝对不会被改变的。
这里给出代码示例:

const date = dayjs('2020-01-01')
date.format('MM-DD')  // 输出 '01-01'

const date2 = date.month(3)
date2.format('MM-DD') // 输出 '04-01'

date.format('MM-DD')  // 输出 '01-01',原对象没变

可以看出,dayjs 的设置属性操作不会修改原对象,它是不可变的。
不过,在函数式开发中,更推荐的时间日期库是 date-fns,在下文会有提到。

Pointfree 无值风格和函数的组合

函数式编程的主旨就是在使用函数的组合来实现运算。
这里,主体是各种函数之间的组合,以完成某种计算为目的,而输入的数据是不重要的。

组合函数,也是实现函数式编程 “无状态” 的过程。因为可以直接把一个函数的返回值作为参数传给下一个函数,避免中间状态。
而最简单直接的避免中间状态的方式,就是使用链式调用。

例如以下代码:

const str = '-THIS-IS-A-TEST-'

str.toLowerCase().split('-').filter(Boolean).join(' ')
// 输出:'this is a test'

但是,并不是所有的数据都提供链式调用的。这种方法不通用。

为了实现链式调用的支持,数据本身需要扩展函数运算操作,对数据而言也是不纯净的。

如果不能使用链式调用,可以通过嵌套函数的方式来实现隐藏变量:

function handle(input) {
  return hanle1(handle2(handle3(input)))
}

这样写起来很累,每次增删流程修改起来都很麻烦,看上去也很难看清楚。
实际开发中,如果实在难以避免的产生中间状态,一般都是直接使用 const 暂存中间变量。


.then() 链处理数据:

还有一种方式,就是使用 .then() 调用数据处理函数,并连接起来,以此来实现函数的组合。
JS 中,Promise 就是原生的 thenable 对象,可以联想到这种代码:

fetch('https://example.com').then(res=>res.json())
  .then(handle3)
  .then(handle2)
  .then(handle1)

相比于嵌套调用,通过 .then() 链来应用转换函数的方式更直观也更明确,维护起来也更容易。

但是,我们不可能使用 Promise 来处理数据,因为这会导致代码变成异步,我们需要自行实现一个包装器:

class Wrapper {
  constructor(data) {
    this.data = data
  }

  then(func) {
    this.data = func(this.data)
    return this
  }

  done() {
    return this.data
  }
}

// 提供工厂函数,这样就不用写 new 了
function wrap(input) {
  return new Wrapper(input)
}

此后,就可以使用 .then() 链来处理处理数据:

function handle(input) {
  return wrap(input)
    .then(handle3)
    .then(handle2)
    .then(handle1)
    .done()
}

函数的组合:

除了上面的方式,我们还可以尝试 “Pointfree” 的函数式编程方式。“Pointfree” 是一种函数式编程的概念,可以理解为 “不使用所要处理的值,只合成运算过程”,用阮一峰的话来说,叫做 “无值风格”。点此查看 阮一峰的 Pointfree 编程指南

上文的 handle() 函数,它对输入值依次执行 handle3()handle2()handle1(),并将最终值返回,所以数据的处理流程实际上就是顺序调用这 3 个函数,因此:
我们可以设计这样一种函数:它接受一个输入,以及一组函数,此后它会用这一组函数依次对输入的数据进行处理,并返回最终结果。

实现一个 pipe 函数,实现以上需求:

function pipe(input, func = []) {
  let result = input
  for (const fn of func) {
    result = fn(result)
  }

  return result
}

这样我,我们就可以将之前的嵌套流程修改为:

function handle(input) {
  return pipe(input, [handle3, handle2, handle1])
}

这样就把嵌套调用函数的逻辑修改为了像 “管道” 一样按顺序调用函数,看上去一目了然。

不过,我们自行实现的这个 pipe() 函数自身的代码中使用了 let 声明中间状态,这不符合函数式编程的实现方式。
我们可以进一步改进它,实现彻底隐藏中间状态:

function pipe(input, func = []) {
  return func.reduce((acc, fn) => fn(acc), input)
}

注意这里的 reduce(),它的作用是将函数应用于一组值,不断计算并更新结果,但这个过程中不会产生中间变量,这个方法也是满足 “Pointfree” 的。

其实,这个函数也是 JS 标准库中,最接近于 “函数式编程” 理念的函数之一。

可以看到上面代码中,hanle1(handle2(handle3(input))) 使用 pipe() 优化后,第二个参数是 [handle3, handle2, handle1],这和嵌套调用时的顺序相反。

这是因为,pipe 本意是 “管道” 操作,即使用一系列函数作为管道,依次对数据进行加工,所以它的顺序遵循函数执行的顺序。
如果想让参数的顺序和嵌套调用函数时一致,我们可以这样修改:

function compose(input, funcs = []) {
  return funcs.reduceRight((acc, fn) => fn(acc), input)
  //               ↑ 这个函数不是 reduce 了
}

// 调用:
function handle(input) {
  // 此时第二个参数的顺序和之前嵌套调用时一致
  return compose(input, [handle1, handle2, handle3])
}

注意这时函数名就不推荐继续叫 pipe 了,建议使用 compose。此时调用顺序就可以写的和嵌套调用中函数的顺序一致了。


函数的“打包”:

如果你觉得,上面的 pipe() 只是一个 “依次转换” 的动作,谈不上 “函数的组合”,那么我们可以实现一个真正意义上的 “用于组合函数” 的函数。
我给它取名叫 package(),意思为 “打包”,以下是代码:

function package(funcs = []) {
  return function packagedFunction(initialValue) {
    return funcs.reduce((acc, fn) => fn(acc), initialValue)
  }
}

可以将多个函数传给这个 package(),此后它会返回一个新函数,这个新函数 “打包” 了传给它的所有函数;
此后对任意数据输入调用这个新函数,便会对其依次应用之前打包的函数,最终将结果返回。

上面的 pipe() 的用例可以改为:

const handle = package([handle3, handle2, handle1])

此时 handle 是一个新函数,它 “打包” 了 handle3handle2handle1,使用它处理数据,就像是依次用上面三个函数处理数据一样。


要表达式,不要语句:

Pointfree 还有一种思想,就是用表达式来取代语句。

最好理解的做法,就是用三目运算符取代 if else 语句;但是,如果流程较为复杂,三目运算符会显得比较乱,此时可以自行封装一个名为 ifElse 的函数:

function ifElse(condFn, whenTrue, whenFlase) {
  return condFn() ? whenTrue() : whenFlase()
}

但是,function() {} 也不是表达式,只有箭头函数才可以算表达式,而且箭头函数必须是 () => ... 的形式,不能是 () => { ... } 的形式,因为这样本质上还是 function

const ifElse = (condFn, whenTrue, whenFlase) => condFn() ? whenTrue() : whenFlase()

使用方式:

ifElse(
  () => new Date().getDay() % 6 === 0,
  () => void console.log('今天是周末!!'),
  () => void console.log('今天是工作日')
)

实际上,无论是 if elseswitch case 还是 forwhile 等流程控制,函数式编程均能给出其函数式的实现,甚至可以做到把所有方法的流程通过函数的组合来达成。

后续章节中会有讲到,ramda 工具提供的 cond() 函数、when() 函数、until() 函数等,便是函数式编程中用函数取代表达式的具体体现。

柯里化

上面的例子中,使用的函数均只能接受一个参数。但是,很多函数要接收多个参数,此时我们可能需要使用中间变量来传递参数,这样便不再是 “无状态” 的了,所以,我们需要一种为多参数函数减少参数的方法。

柯里化” 就是一种减少函数参数的方式:它允许多参数的函数一次只接受一部分参数,此时执行函数后返回的是一个新的函数,新的函数继续接受缺失的参数,直到传递的总参数数量达到需求,才计算并返回结果。

例如:函数 f(a, b, c) 需要接受三个参数,但如果它已被柯里化,以下调用方式和 f(a, b, c) 是等价的:

  • f(a)(b)(c)
  • f(a)(b, c)
  • f(a, b)(c)

通过这种方式,我们可以处理多参数函数,减少中间状态的出现。
注:部分编程语言可能只支持上面列表中的第一种用法,JS 支持列表中所有用法,此处不赘述。

简单一点说,柯里化为我们提供了 “为函数预填充参数” 这一能力,从而达成 “缩减函数需求的参数个数” 这一目的。

基础实现

实现柯里化,需要利用到 JavaScript 中 Function.prototype.length,它表示一个函数接受的参数数量。
代码如下:

function curry(fn) {
  const argc = fn.length

  return function curried(...args) {
    // 如果传入的参数数量足够,则直接调用原函数
    if (args.length >= argc) {
      return fn(...args)
    }

    // 否则返回一个新函数,继续接受剩余的参数
    return function (...nextArgs) {
      return curried(...args, ...nextArgs)
    }
  }
}

此后,将需要被柯里化的函数传给此函数,便会返回一个新的已经柯里化的函数。
用例:

const calc = (a, b, c) => a * b + c
const curriedCalc = curry(calc)
//     ↑ 已被柯里化的 calc 函数

// 下面的结果均为 45
calc(2, 13, 19)
curriedCalc(2)(13)(19)
curriedCalc(2)(13, 19)
curriedCalc(2, 13, 19)

这里的四个计算结果相同,说明我们正确的实现了柯里化。

带 “占位符” 的实现

上面的代码中,我们实现了函数的柯里化,但是这种柯里化只能以从左往右的顺序传参,我们必须从左往右依次提供已知参数,有任一参数无法确定时,它右侧的所有参数就都无法预填。
所以,还需要一种更高级的柯里化方式,支持 “占位符” 的方式来提供参数。

它的实现原理不复杂,简单的实现如下:

// 占位符变量,也可以使用 Symbol
const _ = {}

// 柯里化函数
function curry(fn) {
  const argc = fn.length

  return function curried(...args) {
    // 过滤掉占位符后的真实参数个数
    const validArgc = args.filter(arg => arg !== _).length

    // 如果传入的真实参数数量足够,则直接调用原函数
    if (validArgc >= argc) {
      return fn(...args)
    }

    // 否则返回一个新函数,继续接受剩余的参数
    return function (...nextArgs) {
      /**
       * 下面的实现如果使用函数式的写法,会太过复杂,不利于理解,所以使用有副作用的实现
       *   nextArgs 参数是这个新函数 “未来” 将接收到的参数
       *   newArgs  变量为下一次调用时提供的参数数组
       *
       * .map 语句含义是,此函数还有参数需求时,对已提供的参数从左往右依次判断:
       *   是占位符,则 “未来” 将接收到的最左侧的参数【取出】并放在此处,即 “预留空位”
       *   不是占位符,则直接使用参数本身,即 “填入参数”
       *
       * 执行完 .map 语句后,nextArgs 中剩余的参数就是以后要接收的剩余参数
       * 此时通过 .concat 将这些未来要接收的参数追加在参数列表尾部
       * 做这一步是因为,如果原始函数使用 REST 参数,此处 .concat 可以避免吞掉这些 REST 参数
       *
       * 例如,函数 f(a, b, c, d, e) 正被以此法调用 f(a, _, c),代码运行到此处时:
       *   args    为 [a, _, c]
       *   newArgs 为 [a, 未来的参数1, c, ...剩余的未来参数]
       *
       * 继续调用 (b, d),代码再次运行到此处时:
       *   args    为 [b, d]
       *   newArgs 为 [a, b, c, d, ...剩余的未来参数]
       */
      const newArgs = args
        .map(arg => (arg === _ && nextArgs.length ? nextArgs.shift() : arg))
        .concat(nextArgs)

      return curried(...newArgs)
    }
  }
}

此后,就可以使用占位符 _ 来将任意位置的参数标记为 “占位”,这些参数暂时 “留空”,以后接收到的参数,会优先用来填充之前 “留空” 的位置,填满之后再作为右侧的新参数。
简单的测试用例代码:

const calc = (a, b, c) => a * b + c
const curriedCalc = curry(calc)

// 以下语句结果均为 45
calc(2, 13, 19)
curriedCalc(2, 13, 19)
curriedCalc(2)(13, 19)
curriedCalc(2)(13)(19)

// 使用占位符的用例,结果也均为 45
curriedCalc(_, 13, 19)(2)
curriedCalc(2, _, 19)(13)
curriedCalc(_, 13)(2)(19)
curriedCalc(_, 13)(2, 19)
curriedCalc(_, _, 19)(2)(13)
curriedCalc(_, 13)(_, 19)(2)

可以这么说:支持占位符的柯里化,才是真正的柯里化。

简单用例:

// 这个 pow 就是柯里化的次方函数
const pow = curry(Math.pow)

// square 是计算某个数的平方的函数
const square = pow(_, 2)
square(4) // = 16
square(7) // = 49
[2, 3, 4].map(square) // [4, 9, 16]

// cube 是计算某个数的立方的函数
const cube = pow(_, 3)
cube(3) // = 27
cube(6) // = 216
[2, 3, 4].map(cube) // [8, 27, 64]

柯里化在对需要多个参数的函数进行组合或管道操作时非常实用。
这里以上面的链式调用的代码用例:

const str = '-THIS-IS-A-TEST-'
const result = str.toLowerCase().split('-').filter(Boolean).join(' ')

假设这些操作不是链式的,而是需要使用其他函数来完成,我们把这些函数实现出来:

const toLowerCase = input => String.prototype.toLowerCase.call(input)
const split = (input, splitBy) => String.prototype.split.call(input, splitBy)
const filter = (input, filterBy) => Array.prototype.filter.call(input, filterBy)
const join = (input, joinBy) => Array.prototype.join.call(input, joinBy)

然后调用方式就变成了:

const str = '-THIS-IS-A-TEST-'
join(filter(split(toLowerCase(str), '-'), Boolean), ' ')

可以看出,因为这些处理函数需要接受多个参数,所以无法通过 pipe() 来进行组合,只能嵌套调用函数。

而柯里化可以令我们预填函数的参数,从而缩减函数需求参数的个数,我们可以把需求多个参数的函数预填参数,使其最终只需求一个参数——数据输入,这样便可以应用 pipe()

pipe(str, [
  toLowerCase,
  curry(split)(_, '-'),
  curry(filter)(_, Boolean),
  curry(join)(_, ' '),
])

// 输出:'this is a test'

得出结论:柯里化配合占位符,使我们能简便的调整函数参数的数量、位置,以及预填充参数,并以此来更容易的实现函数之间的组合。

函数式编程工具:ramda

上面提到的 pipe()curry() 等函数,它们都是函数式开发中很基本的概念,实际开发中都是直接从现成的库中引入并使用,并不需要我们自己来实现。

而这个函数式编程的库,就是 ramda,点此访问 中文文档,阮一峰也写过 一篇文章 介绍它。注意是 ramda 不是 rambda,别拼错了。

yarn add ramda
yarn add -D @types/ramda

ramda 是专为函数式编程而开发的工具,它具备以下特点:

  • 它提供的函数几乎都是已柯里化的,也支持占位符;
    部分使用 REST 参数的函数无法柯里化,例如 pipe() 等;
  • 它提供的函数都是无副作用的,绝不会修改原来的参数,这一点不同于 lodash
    同时它也默认提供 ES Module 导出,所以可以正确的应用 TreeShaking;
  • 它以函数为主体,函数类型的参数在左边,而输入数据参数始终在最右边,这和 lodash 正好相反。

上面提到的柯里化、占位符,ramda 已经提供了相关实现:

import * as R from 'ramda'

const calc = (a, b, c) => a * b + c
const curriedCalc = R.curry(calc)

// 结果都为 45
calc(2, 13, 19)
curriedCalc(2)(13)(19)
curriedCalc(R.__, 13, 19)(2)

本文已经全局部署 ramda,可以通过全局变量 R 来访问,所以上述代码你可以直接在浏览器 F12 控制台中复制粘贴运行(注意不要复制 import 语句)。

上面提到的 pipe() 函数,ramda 也实现了,和我们自己实现的 package() 函数相似:

import * as R from 'ramda'

// 数据操作函数
const add4   = n => n + 4
const treble = n => n * 3
const half   = n => n / 2

R.pipe(add4, treble, half)    // 得到一个组合了 3 个函数的新函数
R.pipe(add4, treble, half)(6) // 结果:15

lodash 的区别

ramda 以函数为主体,数据输入放在参数的最后;而 lodash 以数据输入为主体,函数操作放在参数最后。
以下代码展示出了二者的差异:

import * as R from 'ramda'
import _ from 'lodash'

// 结果相同,但参数的顺序正好相反
R.filter(n => n > 2, [1, 2, 3, 4])
_.filter([1, 2, 3, 4], n => n > 2)

而且,ramda 中只要不是 REST 参数的函数,默认都是已柯里化的,而 lodash 不是:

R.filter(n => n > 2)([1, 2, 3, 4]) // 正常工作
_.filter([1, 2, 3, 4])(n => n > 2) // 报错

此外,ramda 的函数都不会修改原有对象,是无副作用的,但是 lodash 不是:

import * as R from 'ramda'
import _ from 'lodash'

const obj1 = { name: 'PaperPlane' }
console.log('(lodash) obj1 = ', obj1)
_.merge(obj1, { id: 1 })
console.log('(lodash) obj1 = ', obj1)

const obj2 = { name: 'PaperPlane' }
console.log('(ramda)  obj2 = ', obj2)
R.mergeRight(obj2, { id: 1 })
console.log('(ramda)  obj2 = ', obj2)

查看输出的结果:

(lodash) obj1 =  { name: 'PaperPlane' }
(lodash) obj1 =  { name: 'PaperPlane', id: 1 }
(ramda)  obj2 =  { name: 'PaperPlane' }
(ramda)  obj2 =  { name: 'PaperPlane' }

可以看出 lodash 的方法可能会修改原始参数,但 ramda 不会修改参数,只会产生一个新对象返回。

lodash/fp 函数式 API

上面说了那么多,只是为了体现函数式 API 和非函数式 API 的区别。
实际上,lodash 也提供了函数式编程相关的方法,它们位于 lodash/fp 下,开发时可以配合 lodash fp 文档

lodash/fp 下导出的函数,它们和原版同名函数实现的功能一致,但是,这些函数也同样具备了 默认柯里化无副作用数据输入在参数的最末尾 这些特性,使用起来和 ramda 区别不大。

下面使用 lodashset() 的函数式版本,展示它与非函数式用法的区别:

import { set } from 'lodash/fp'

const obj = { a: 111 }
const newObj = set('a', 222)(obj)

console.log(obj)
console.log(newObj)

通过这个用例可以看出,lodash/fp 下的函数,其数据输入从参数列表的最左侧移到最右侧,支持柯里化,且函数不再具有副作用。

有一点需要注意,lodash/fp 指的是 lodash/fp.js,如果运行在 Node.js 端并使用 ES Module 的方式导入,不能直接写成 import _ from 'lodash/fp',导入来源必须完整写成 'lodash/fp.js'。你可以替换这个模块名,并将上面几个用例重试,可以发现函数式的方法其实和 ramda 是一样的。

基于 ramda 的不可变数据

ramda 虽然没提供不可变数据类型,但是,它提供了一系列无副作用的操作和更新数据的函数,这些函数接收输入后,返回一个新的对象作为结果,不会修改原对象。如果不使用 Immer.js 等工具,可以使用 ramda 来维护数据。下面给出一些代码用例。

对象的修改操作:

import * as R from 'ramda'

const user = {
  name: 'PaperPlane',
  skills: [
    { id: 1, label: 'React' },
    { id: 2, label: 'Vue' },
  ],
}

/**
 * 修改对象的嵌套的子属性
 * 
 * R.lensPath 指定对象操作的属性路径
 * R.set 执行修改覆写属性操作
 */
const newUser1 = R.set(R.lensPath(['skills', 1, 'label']), 'Others')(user)
console.log(newUser1)
console.log(user) // 可以看到原对象并没有被修改

// 嵌套删除属性
const newUser2 = R.dissocPath(['skills', 1, 'label'])(user)
console.log(newUser2)

数组的修改操作:

import * as R from 'ramda'

const list = [111, 222, 333]

// 类似于 list.push(444),在尾部追加一个元素
R.append(444)(list)

// 修改第二个元素,类似于 list[1] = 222222
R.update(1, 222222)(list)

// 删除第二个元素,类似于 list.slice(1, 1)
R.remove(1, 1)(list)

此外,ramda 还提供了对象、数组操作相关的非常丰富的 API,远不止上面例子中的这些。这些函数通过组合,可以实现各种复杂的需求。

基于 ramda 的流程控制

对于常用的各种流程控制语句,ramda 都提供了对应的函数,使用 ramda 几乎可以做到 “无语句,只有表达式”。
这里提供一些示例,使用 ramda 模拟 JS 的流程控制语句。

函数式 switch case 用例,实现需求:
设计函数,输入一个数字表示今天周几(周日为 0),然后函数返回一个字符串,周一周三周五返回 “React”,周二周四返回 “Vue”,周末返回 “Angular”,不合法的输入返回 “Error”。

原始写法:

function whichTech(input = new Date().getDay()) {
  switch (input) {
    case 1: case 3: case 5:
      return 'React'

    case 2: case 4:
      return 'Vue'

    case 0: case 6:
      return 'Angular'

    default:
      return 'Error'
  }
}

函数式写法:

import * as R from 'ramda'

const whichTech = (input = new Date().getDay()) =>
  R.cond([
    // ↓ 满足此条件,               ↓ 执行此函数,使用返回值
    [R.includes(R.__, [1, 3, 5]), R.always('React')  ],
    [R.includes(R.__, [2, 4])   , R.always('Vue')    ],
    [R.includes(R.__, [0, 6])   , R.always('Angular')],
    [R.T                        , R.always('Error')  ],
  ])(input)

函数式 while do 用例,实现需求:
给定一个面积上限,使用循环代码,求出面积不超过上限的最大的正方形的整数边长。

原始写法:

function squareByLimit(limit) {
  let result = 0
  while (Math.pow(result + 1, 2) <= limit) {
    ++result
  }

  return result
}

函数式写法:

import * as R from 'ramda'

const squareByLimit = (limit) =>
  R.until(
    R.pipe(
      R.inc(), 
      R.curry(Math.pow)(R.__, 2), 
      R.gt(R.__, limit)
    ), 
    R.inc()
  )(0)

实践:HTTP 响应处理

考虑以下需求:
从 HTTP 请求的结果中获取数据,如果取不到某个内嵌的字段,则使用默认值;
此外,要求代码具备调试功能,仅在测试环境下将响应打印到控制台。

实现代码:

import * as R from 'ramda'

const handleResponse = R.pipe(
  R.when(R.always(R.equals(process.env.NODE_ENV)('development')), R.tap(console.log)),
  R.pathOr([], ['data', 'user', 'skills'])
)

fetch('https://example.com')
  .then(res => res.json())
  .then(handleResponse)

原理:
pipe() 将顺序操作的多个函数组合为一个;
equals() 对比它接受的两个参数是否相等,并通过 always() 包装为一个函数,并将它应用于 when()
when() 会执行第一个参数,若结果为真,则将数据输入应用第二个参数的函数并使用其返回值,否则直接返回数据输入;
tap() 将数据输入应用于其第一个参数的函数,但它仍返回原始的数据输入,这里用 console.log 打印了数据,然后直接将数据返回进行后续操作;
pathOr() 安全的按照属性路径访问嵌套字段,访问失败时会使用默认值。

实践:React 列表组件

ramda 函数式编程的能力,使得它很适合用在 React 中:

  • ramda 可以避免修改对象的状态,从而避免引起不必要的渲染、更新,造成性能浪费,避免产生数据不一致的情况;
  • ramda 也提供了类似不可变对象的操作,它可以保证修改操作完成后,返回新对象,避免组件数据更新不及时;
  • ramda 以函数为主体,大部分操作返回新的函数,这正好能应用于例如 setState() 等场景,当做 “dispatcher” 来使用。

代码示例:

import { useState } from 'react'
import * as R from 'ramda'

interface User {
  id: number
  name: string
}

function UserList() {
  const [users, setUsers] = useState<User[]>([
    { id: 1, name: 'Alice' },
    { id: 2, name: 'Mike' },
    { id: 3, name: 'Patrick' },
  ])

  return (
    <ul>
      {users.map((user, index) => (
        <li key={user.id}>
          用户名:
          {user.name}
          ,操作:
          <button
            onClick={() => {
              /**
               * adjust() 对数组在 index 位置的元素,应用第二个参数的函数变换
               * modify() 对对象指定字段 'name' 应用第二个参数的函数变换
               * concat() 对字符串而言进行连接,此时需使用占位符将数据输入调整到左侧
               */
              setUsers(R.adjust(index, R.modify('name', R.concat(R.__, '-new') as () => string)))
            }}
          >
            更新名字
          </button>
          <button
            onClick={() => {
              // remove() 从数组指定索引处移去数个元素
              setUsers(R.remove(index, 1))
            }}
          >
            删除用户
          </button>
        </li>
      ))}
    </ul>
  )
}

上述代码中,点击 “删除用户” 按钮后,会将这条记录删除;点击 “更新名字” 后,会在用户名后面追加 “-new” 字符串。
因为 ramda 返回的都是函数,因此 setUsers() 收到的都是 Dispatcher 函数而不是数据,在多个状态更新并发的场合可以避免竞态问题;而且这些函数都会返回新的对象,可以确保 React 对渲染数据的更新。

函数式编程工具:ramda-adjunct

ramda-dajunct 也是一个的函数式编程工具,它基于 ramda 实现了更多的函数,可以满足日常开发的大部分需求。
注意,必须安装 ramda 才能使用 ramda-adjunct,因为前者是对等依赖(peerDependencies)。

yarn add ramda-adjunct

这个包基于 ramda 扩展了更多函数,这里给几个例子:

  • 对于 ramda 中不支持 Promise 的函数,ramda-adjunct 添加了支持 Promise 的函数,这些函数以 P 作为后缀,例如:
    reduceP() 提供了数组的异步折叠计算支持;allP() 可以等待数组中所有 Promise 类型已完成再返回结果;

  • 对于对象数据:
    函数 renameKeys() 提供了对对象键名的重命名能力,而 renameKeysWith() 提供了对对象键名的变换的能力;函数 omitBy() 提供了依判断条件删除属性的能力;函数 mergePaths() 提供了精确控制合并路径的能力;除此之外还有更多功能强大的函数;

  • 提供了 isDate()isArray() 等一系列类型判断函数,这些函数还有 isNotDate()isNotArray() 的反向版本;

  • 函数 Y() 提供了函数式编程的递归实现方式,也就说 lambda 推导中的 “Y 组合子”,它是 ramda-adjunct 中最重要的函数之一,请见本文最后一个章节,此处不再赘述。


查看 ramda-adjunct 的源码,可以发现它提供的一部分函数,实际上是由 ramda 的函数组合而来,这也是它需求 ramda 作为对等依赖的原因,同时这也是它能保持柯里化、无副作用的原因。

例如,ramda-adjunct 中的 omitBy() 函数,查看其源码:

import { complement, identity, pickBy, useWith } from 'ramda'

const omitBy = useWith(pickBy, [complement, identity])
export default omitBy

可以发现这里的 omitBy() 是用 ”反向“ 的 pickBy() 来组成的。

我们也可以循着这个思路,利用函数的组合,开发属于自己的函数式工具。
例如,开发一个针对数组数据的 findFirstAndModify() 函数,它接受一个用于寻找元素的判断函数、一个用于修改元素的函数,从数组中找到第一个匹配的元素并应用修改函数:

import * as R from 'ramda'

const findFirstAndModify = R.curryN(3)(
  (findFn: (p: any) => boolean, modifyFn: (p: any) => any, data: any[]): any[] =>
    R.adjust(R.findIndex(findFn)(data))(modifyFn)(data)
)

可以用以下用例测试这个函数:

const arr = [1, 2, 3, 4, 5]
const result = findFirstAndModify(
  t => t === 3,
  t => t + 1000,
  arr
)

console.log(arr)
console.log(result)

函数式时间日期工具:date-fns

函数式编程最合适的时间日期工具就是 date-fns
dayjs 类似,它提供的函数均为基于不可变对象、无副作用的。

yarn add date-fns

点此查看 官网文档,或者是 函数式 API 指南

dayjs 的区别

dayjs 不同的是,date-fns 基于原生的 Date 对象,它没有提供自行封装的时间日期对象,这也使得它更容易和其他工具相集成,例如 mui

参考以下代码示例:

import dayjs from 'dayjs'
import { format } from 'date-fns'

dayjs('2019-01-01').format('YYYY-MM-DD')

format(new Date('2019-01-01'), 'yyyy-MM-dd')
format('2019-01-01', 'yyyy-MM-dd')

从代码示例可以看出,因为 date-fns 没有提供封装过的对象,所以它的函数第一个参数要提供原生的 Date 对象,或是提供时间日期字符串,由 date-fns 自动创建 Date 对象;
dayjs 因为是使用了特殊的对象,所以可以直接 .format() 这样调用自身的方法。

你可能还看出,这两个 format() 使用的格式化字符串不同。
这也是很重要的一点,date-fns 基于一种特殊的格式化规则,和 dayjs 有所不同,具体可以参考 format() 使用文档,这里是它们的 官方介绍


因为 date-fns 没有自行封装对象,因此它也没提供 default 默认导出,这一点和 ramda 一样,需要注意。

也正因为没有提供自行封装的时间日期对象,所以 date-fns 不需要像 dayjs 通过插件的方式开启某些功能支持,因为它把功能拆分到各个函数中了,而且这些函数都是无副作用的,可以正确应用 TreeShaking,不会导致代码包的体积变得特别大。

通过以下两个代码示例,感受两个库使用上的差异。

下面是 dayjs 的常用示例:

import dayjs from 'dayjs'

// 下面是 i18n 配置方式

import 'dayjs/locale/zh-cn'
dayjs.locale('zh-cn')

dayjs('2019-01-01').format('YYYY-MM-DD dddd') // 输出: 2019-01-01 星期二

// 下面是插件使用方式

import duration from 'dayjs/plugin/duration'
import relativeTime from 'dayjs/plugin/relativeTime'

dayjs.extend(duration)
dayjs.extend(relativeTime)

dayjs.duration({ months: 3 }).humanize() // 输出:3 个月

使用 date-fns 的示例:

import { format, setDefaultOptions } from 'date-fns'

// 下面是 i18n 配置方式

import { zhCN } from 'date-fns/locale'
setDefaultOptions({ locale: zhCN })

format('2019-01-01', 'yyyy-MM-dd EEEE') // 输出: 2019-01-01 星期二

// date-fns 不需要插件

formatDuration({ months: 3 }) // 输出:3 个月

date-fns/fp 函数式 API

date-fns 也像 lodash 一样,提供 date-fns/fp 这个目录,这个目录下导出的函数适用于函数式编程。点击查看 函数式 API 指南
date-fns/fp 导出的函数,同原始函数相比,有以下区别:

  • 这些函数均为已柯里化的,且参数的顺序与原本是相反的

  • 这些函数默认是不接受配置参数的,如果需要传入配置参数(例如 format() 的第三个参数),请使用这些函数带有 WithOptions 名称后缀的版本(例如 formatWithOptions()),此时配置参数位于最左侧第一个位置。

import { format } from 'date-fns/fp'

// 以下用法返回结果相同

format('yyyy-MM-dd', '2019-01-01')   // 参数是反过来的

format('yyyy-MM-dd')('2019-01-01')   // 柯里化
format()('yyyy-MM-dd')('2019-01-01')
format()('yyyy-MM-dd', '2019-01-01')

// 如果想使用原版 format 的第三个配置参数
// 需要使用 formatWithOptions,配置参数放在最左侧

import { formatWithOptions } from 'date-fns/fp'
import { zhCN, enUS } from 'date-fns/locale'

formatWithOptions({ locale: zhCN }, 'EEEE', '2019-01-01') // 输出:星期二
formatWithOptions({ locale: enUS }, 'EEEE', '2019-01-01') // 输出:Tuesday

进阶:尾调用优化和尾递归优化

尾调用优化和尾递归优化,与函数式编程的相关性不大。
但本文的后续内容会给出部分函数的尾递归优化版本,此内容仅作为前置知识。

关于什么是尾调用优化(TCO)和尾递归优化(TRO),请先阅读 阮一峰的文章,也可以阅读阮一峰在 ES6 指南中的 示例代码
因篇幅问题,本文不解释这两种优化的原理,也不提供优化的教程。

请注意,阮一峰的文章中,存在一处错误:ES6 严格模式下,函数中的 arguments 不可用。
实际上这个变量是可用的,但 arguments.calleearguments.caller 无法使用。

注意,尾递归优化是尾调用优化的一种特例。非递归场景下,函数的调用即使嵌套很多层,也不太会导致占用太多内存。

进阶:函数式编程实现递归

函数式编程的一大难题,就是如何实现递归(除了改写成循环这种方式以外)。

例如,我们写一个通过递归实现求阶乘的函数:

const factorial = n => n <= 1 ? n : n * factorial(n - 1)

可以发现,这里函数体中使用到了函数自身的变量名 factorial,这使它无法被当做表达式来使用,因为纯函数式编程中不会去专门声明一个变量或函数叫 factorial 的,很有可能这个函数是被 IIFE 直接调用的。

为了实现函数式编程,数学家和计算机科学家发明了一种叫做 “Y 组合子” 的计算方式,我们称之为 “Y 函数”,它可以在函数式编程中实现递归。
实际开发中,我们从 ramda-adjunct 中导出 Y 函数并使用即可。

有关 Y 组合子的诞生过程,以及详细的使用说明和拆解,可以参考我的 这一篇博文,它是互联网上为数不多的使用纯 JS 推导而不是使用 lambda 演算推导的文章。

使用 Y 函数之前,要对我们的递归函数进行改造:
第一步,将它写成高阶形式,左边添加一个 f =>

const factorial = f => n => n <= 1 ? n : n * factorial(n - 1)

第二步,将函数中所有自身变量名都替换为 f

const factorial = f => n => n <= 1 ? n : n * f(n - 1)

这样,我们的改造就完成了。此时,这个函数 factorial 已经完全消除了对自身变量名的使用,它可以直接被当做表达式来用,完全符合函数式编程的要求。

不过,这个函数还不能直接使用。将这个函数作为参数传给 Y(),即可得到一个递归函数,执行这个递归函数,就能得到结果:

import * as RA from 'ramda-adjunct'

const factorial = RA.Y(f => n => n <= 1 ? n : n * f(n - 1))

本文已经全局部署 ramda-adjunct,可以通过全局变量 RA 来访问,所以上述代码你可以直接在浏览器 F12 控制台中复制粘贴运行(注意不要复制 import 语句)。

测试用例:

factorial(5)
// 输出:120

具体流程可以总结为:通过对一个函数进行改造,消除其对自身变量名的使用,这样它就可被当做表达式了,然后使用 Y 函数对其加工,这样就得到了一个纯函数式实现的递归函数。

强烈建议,阅读我的 这一篇博文,了解 Y 组合子的构建方式、原理。