函数式编程是一种特别的编程范式(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
原型上面一部分有副作用的方法提供了无副作用的版本:
toReversed()
是无副作用版本的reverse()
;toSorted()
是无副作用版本的sort()
;toSpliced()
是无副作用版本的splice()
。
对于 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.js 和 Immer。
此处以 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
是一个新函数,它 “打包” 了 handle3
、handle2
、handle1
,使用它处理数据,就像是依次用上面三个函数处理数据一样。
要表达式,不要语句:
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 else
、switch case
还是 for
、while
等流程控制,函数式编程均能给出其函数式的实现,甚至可以做到把所有方法的流程通过函数的组合来达成。
后续章节中会有讲到,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
区别不大。
下面使用 lodash
的 set()
的函数式版本,展示它与非函数式用法的区别:
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.callee
、arguments.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 组合子的构建方式、原理。