😍Vue和React都在用的函数式编程,学起来~
函数式编程范式在很多框架和库里中使用十分广泛,目前React已经全面拥抱函数式组件,源码中处处体现了函数式编程的思想。Vue3里所有的响应式API都是函数式编程,可以用reactive
、watch
、computed
来组合出各种各样的可复用的逻辑。包括使用很广泛的lodash
工具库就是一个典型的函数式编程库,此外包括Redux,Koa,Express里面也有很多是函数式编程。所以学习了解函数式编程范式是很有必要的。
1.什么是函数式编程
什么是函数式编程呢?难道说我们写一个函数然后调用就是函数式编程吗?
其实函数式编程是一种编程范式,强调使用多个函数来组合和来处理数据。其核心是将运算过程抽象成函数,好处是可以复用。
上面有提到了函数式编程是一种编程范式,那么常见的编程范式有哪些呢?
1.1面向过程编程 PP(Procedural Programming
)
按照步骤来实现,将程序分解为过程和函数。这些过程和函数按顺序执行来完成任务。
举个例子:给一个arr数组求和,面向过程的代码一般是这样写的:
const arr = [1, 2, 3, 4, 5];
function calc() {
let sum = 0;
for (let i = 0; i < arr.length; i++) {
sum += arr[i]; // 运算过程
}
return sum;
}
console.log(calc());
上面的代码演示了一个简单的求和过程,很符合我们的日常思维习惯,一步一步将数值相加。但是这种代码很繁琐,很多时候相似的功能要重复的写,这时候人们就想到了封装和继承,其实它也有一个概念叫面向对象编程。
1.2面向对象编程 OOP(Object-Oriented Programming
)
它将程序整合成对象,每个对象都有自己的状态和行为。所以每次写代码的时候都要通过new
关键字来创建类的实例,实例上有自己的属性和方法,可以调用上面的方法修改其属性。
举个例子:给一个arr
数组求和,面向对象编程的代码一般是这样写的:
class Calc {
constructor() {
this.sum = 0;
}
add(arr) {
for (let i = 0; i < arr.length; i++) {
// 运算过程
this.sum += arr[i];
}
}
}
const calc = new Calc();
calc.add(arr);
console.log(calc.sum);
上面的代码演示了面向对象编程的求和过程,写一个Calc
,然后调用add()
得到最终的和sum
。面向对象的核心是类,面对复杂逻辑时,还可以写多个类进行继承,还会用到封装,多态等特性。但是在JS里面是单继承的,而且在类连续继承的时候很混乱,所以后面又出现了函数式编程。
1.3函数式编程 FP(Functional Programming
)
使用函数来组合和处理数据,描述数据之间的映射。函数指的并不是编程语言中的函数,指的是数学意义上的函数 y=f(x)
输入映射输出。
这么说可能有点抽象,还是举个之前求和的例子来感受一下:
// 高阶函 + 纯函数(输入相同输出就相同)
const total = arr.reduce((acc, cur) => acc + cur, 0);
console.log(total);
Array.pototype.reduce
是一个典型的函数式编程,本身它是一个函数,它的参数也是函数,组成了一个高阶函数,得到最终的结果。
2.函数式编程的优势
从上面三种写法的来看,函数式写法最简洁易懂的,没有歧义的,也没有this
。除此之外函数式编程还有哪些优势呢?
下面总结了一些函数式的有点:
- 可维护性:函数式编程的程序通常更加简洁和可读,因为它们避免了状态变化和副作用。这使得代码更易于理解和维护。
- 可测试性:由于函数式编程程序通常是无副作用的,所以可以很容易地对其进行单元测试。
- 并发性:函数式编程程序通常是无副作用的,不会更改其他状态,所以可以很容易地并行地执行,
- 扩展性:函数式编程程序通常是纯函数,可以很容易地组合和重用,便于代码复用。
- 可靠性:函数式编程程序通常是无副作用的,所以可以很容易地预测其行为。
- 函数式编程可以抛弃
this
,打包过程中更好的利用tree-shaking
过滤无用的代码 。
纯函数:输入参数相同得到的结果一定相同,不受外界影响。 无副作用:不依赖和更改外部变量。
3.纯函数
上面多次提到了纯函数,那么什么是纯函数呢?
纯函数的定义:相同的输入永远会得到相同的输出,而且没有任何的副作用(不会对外部环境产生影响.并且不依赖于外部状态)。
纯函数是函数式编程的基础,它满足y=fn(x)
这个表达式,每次执行只要x
的值相同,得到的y
值一定相同。
3.1 纯函数和非纯函数
这么说可能有点抽象,下面举个例子来感受一下:
纯函数:
function sum(a, b) {
return a + b; // 相同的输入得到相同的输出
}
上面这个例子中只要参数一样,sum
返回的结果肯定是一样的,而且其内部并没有引用和更改外部的状态,这样的函数就是纯函数。
非纯函数:
let count = 0;
function counter() {
count++; // 依赖外部状态,多次调用返回结果不同
return count;
}
// 没有参数,依赖外部状态,无法测试
let date = new Date();
function getTime() {
// 依赖外部变量,不同时间调用,返回值不同
return date.toLocaleTimeString();
}
函数counter
引用改变了外部状态,每次执行的结果不一样,所以它不是纯函数。getTime
函数每次调用获取的系统时间都不一样,所以它也不是纯函数。
3.2 函数的副作用
常见副作用:
- 对全局变量或静态变量的修改
- 对外部资源的访问 (如文件、数据库、网络 http 请求)
- 对系统状态的修改 (环境变量)
- 对共享内存的修改
- DOM 访问,打印/log等
副作用缺点:副作用使得方法通用性降低,让代码难以理解和预测,测试困难,导致静态问题等。
lodash 库中所有的方法都是纯函数。
4.函数是一等公民
我们再来了解另外一个概念:JavaScript函数是一等公民。
是什么意思呢?
First-class Function(头等函数)的定义:当一门编程语言的函数可以被当作变量一样用时,则称这门语言拥有头等函数。
下面总结一下头等函数的三个基本特点:
- 函数可以存储在变量中:
let fn=()=>{}
- 函数可以作为参数:
fn(f:()=>{})
- 函数可以作为返回值:
fn():()=>{}
在函数式编程里,把函数是一等公民体现的淋漓尽致。经常会把纯函数当成参数传入运算出不一样的逻辑,或者是函数执行组合成一个新的函数。由此我们引申出另一个非常重要的概念:高阶函数。
5.高阶函数(Higher-order function)
一个函数的参数是一个函数,或者一个函数的返回值是一个函数。则称这个函数是高阶函数,两个条件满足一个即可。
5.1函作为参数
上面用Array.pototype.reduce
求和的例子就是一个高阶函数,因为它的第一个入参是一个函数,下面我们手动实现reduce
函数来感受一下函数作为参数的高阶函数:
Array.prototype.reduce = function (callback, startVal) {
let arr = this; // 因为是[].reduce,所以this就是数组
let acc = typeof startVal === "undefined" ? arr[0] : startVal; // 没传初始值就取数组的第一项
let idx = typeof startVal === "undefined" ? 1 : 0; // 获取索引
for (let i = idx; i < arr.length; i++) {
acc = callback(acc, arr[i], i, arr); // 循环调用传入的callback处理每一项,且赋值累加值acc
}
return acc;
};
上面的代码中,因为因为调用时是[].reduce(...)
,js中谁调用this
就是谁,所以this
就是当前数组。累加值acc
在没有传startVal
时是数组的第一项,索引idx
在startVal
没传时是0
,否则是1
。然后循环调用传入的callback
处理每一项,且赋值累加值到acc
上,最后返回结果acc
。
通过函数的组合,抽象出运算过程,封装具体的实现。
把处理方式交给用户处理,这样写起来就十分灵活,便于复用。Array.pototype.reduce
的返回值是任意类型,所以它可以替代数组所有的上所有遍历的方法,例如map
,forEach
,filter
,some
,都可以用reduce来写。
举个filter
的例子,例如要过滤数组里所有小于3的元素用reduce
写就是这样的:
const res = arr.reduce((acc, cur) => cur > 3 ? acc.push(cur) : acc, []);
像上面这种将函作为参数传入的写法常称作切片编程(AOP),在不破坏原有逻辑的情况下,对已有逻辑进行扩展。
5.2函作为返回值
之前说过纯函数相同的输入一定会得到相同的输出,那么我们就可以用这个特性做结果缓存。把每次计算的结果缓存起来,如果输入的参数之前计算过的相同,则直接返回之前计算的结果即可。
lodash是一个函数式编程的工具库,lodash的_.memoize(fn):fn
的参数的函数,返回值也是函数,所以它是一个典型的高阶函数,而且内部做了缓存处理。
下面这个例子展示了他的用法:
function sum(a, b) {
console.log("runner~~");
return a + b;
}
const _ = require("lodash"); // 加载一个第三方模块
let memorizedSum = _.memoize(sum);
console.log(memorizedSum(1, 2));
console.log(memorizedSum(1, 2)); // 第二次拿到了第一次的缓存结果
执行结果:
runner~~
3
3
执行_.memoize
,返回了一个新函数memorizedSum
,后续执行memorizedSum
,我们可以看到sum
只执行了一次,只要参数一样,函数sum
就不会执行,直接取缓存结果,这样可以提高sum
函数的性能。
它还有别的用途,在一些只执行一次的逻辑中可以用到它,例如不能重复点赞,重复支付订单等等
下面我们来手动实现一下这个memoize()
高阶函数:
function memoize(fn) {
const cache = new Map(); // 缓存计算结果
return function (...args) { // 返回新函数
const key = args[0]; // 将传入的参数作为key
let result = cache.get(key); //获取缓存结果
if (result == undefined) { // 没获取到就计算
result = fn(...args);
cache.set(key, result); // 设置缓存结果
}
return result; // 返回结果
};
}
memoize()
函数的原理是:先声明一个Map
变量cache
,因为memoize
执行的返回值是函数,所以要return
一个函数出去,将传入的参数作为key
去cache
获取缓存结果,没获取到说明没计算过,就fn
进行计算,同时缓存计算结果。如果取到了就直接返回之前计算的结果。
5.3闭包
我们在聊一下函数作为返回值另一种常见的情况:闭包。
闭包也是一个老生常谈的概念,它比较抽象,每个人的理解都不一样。在我看来其实是词法作用域不同,而js又是函数作用域。一句话总结就是:函数声明时的词法作用域和调用时的词法作用域不一样。
举个例子来感受一下:
function a() {
let c = 100;
return function b() {
console.log(c);
};
// b() 同一作用域
}
let b = a();
b(); // 不同作用域
当前定义的函数a
记住了所在的词法作用域,b
函数不在当前词法作用域a
中执行,而是作为返回值在外面执行,但是b
函数又用到了a
函数作用域里的变量c
,此时就会产生闭包let c = 100
;
所以产生闭包的函数也是一个高阶函数,因为它的返回值是函数。
6.柯里化
函数式编程另一个非常重要的特点就是柯理化,柯里化是一种函数转换技术,它将一个多参数函数转换为一系列单参数函数。与之类似的偏函数是指对于一个函数,固定其中一些参数的值,生成一个新函数,这个新函数接受剩下的参数。
只看概念可能有点抽象,我们来举个例子感受一下:
例如我们在判断变量类型时,常用Object.prototype.toString.call
方法,每次要写这么长一串就很不优雅,于是封装成下面这个isType
函数:
function isType(typing, val) {
return Object.prototype.toString.call(val) === `[object ${typing}]`;
}
const isNumber = isType('Number', 1) // true
const isString = isType('Number', 'a') // false
但是这样还不够优雅,因为每次都要传入2个参数,有点麻烦,万一typing
参数拼错出了问题还不好排查,而且相同类型的参数是固定的。通过上面学的知识,我们可以想到,为什么不用闭包的机制将参数缓存起来呢?
于是有了下面这样改进的代码:
function _isType(typing) {
return function (val) {
return Object.prototype.toString.call(val) === `[objects${typing}]`;
};
}
const util = {};
["String", "Number", "Boolean"].forEach((typing) => {
util["is" + typing] = _isType(typing);
});
const isString = util.isString('abc') // true
const isNumber = util.isNumber('abc') // false
const isBoolean = util.isBoolean('abc') // false
可以利用高阶函数来实现参数的保留,就去掉每次执行传入typing
字符串参数,最终util
这个对象就有isString
, isNumber
, isBoolean
这三个方法,而且它们只需要传被检测的值,不需要再传类型了。这样就优雅多了。
从上面的例子就是柯理化常见的应用,核心目的是把多个参数的函数,转成单参数的函数。使用起来更加方便。
lodash中有一个专门的柯里化函数_.curry
。我们也可以用_.curry
来实现类似上面_isType
的效果:
const curriedIsType = _.curry(isType);
const isString = curriedIsType("String");
console.log(isstring("123")); // true
console.log(isString(123)); // false
_.curry
特性是:被柯理化的函数所需的参数都被提供则执行原函数,否则继续返回函数等待接收剩余的参数。
什么意思呢?我们来举个例子说明一下:
function sum(a,b,c) {
return a+b+c;
}
const curriedSum = _.curry(sum)
console.log(curriedSum(1)(2)(3)) // 6
我们可以看到被柯理化的函数参数一个个的传入执行,得到的结果也是一样的。
如果执行的参数没有达到原函数的参数,此时会返回一个新函数来继续等待接受剩余的参数,所以_.curry
也是一个高阶函数,它的入参是函数,返回值也是函数。
面试的时候我们常常会被问到如何实现上述_.curry
函数,借这个机会,我们来实现一下它:
// 柯里化的实现, (分批传入参数),
function curry(func) {
// 高阶函数
const curried = (...args) => {
if (args.length < func.length) {
// 传递的参数 不满足函数
return (...other) => curried(...args, ...other);
}
return func(...args);
};
return curried;
}
function sum(a, b, c) { // 多参数的函数
return a + b + c;
}
const curriedSum = curry(sum); // 转成单参数函数
console.log(curriedSum(1)(2)(3)); //6
上面代码中,函数curry
接收了函数func
,返回了一个新函数curried
,所以它是一个高阶函数,而在curried
函数中会判断参数args
的长度是否小于原来传入的函数func
的长度,如果小于的话则返回一个新的函数,新函数会执行curried
并合并参数,合并的参数会累积到下次的判断。如果参数相等,则直接返回函数func
执行后的结果。核心就是参数累加。
提示:某个函数的length属性的值是参数长度,如上面例子中的
sum(a, b, c)
函数,sum.length
返回的结果是3
。
通过上面的例子和描述我们不难得出,柯理化就是将大范围的函数缩小成具体用处的函数,将多参数的函数处理成单参数的函数。
7.函数组合
函数式里面还有另一个重要的思想就是组合,可以把逻辑通过函数的形式拆分,然后进行函数之间的组合,得到完整的逻辑。像redux中的组合函数compose
,包括koa,express中处理请求和响应的中间件也是用的组合函数,例如处理参数的函数,鉴权的函数等等,组合成一个函数进行计算最终响应用户的需求。这可以灵活的处理中间的过程,而不用关心整体流程是如何处理的。
我们可以举个例子进一步说明:
假如我们可以给自己定涨薪幅度,哈哈哈,这肯定是最开心的时候了。怎么定呢,首先让自己的工资翻倍,然后人民币变美刀,小数点后面的四舍五入一下。
用代码描述就是这样的:
// 双倍
function double(n) {
// 纯函数
return n * 2;
}
function toFixed(n) {
return n.toFixed(0);
}
function addPrefix(n) {
return "$" + n;
}
addPrefix(toFiexd(double(10000.1))) // $20000
每个函数各司其职,组合起来执行,这样我的工资就成10000rmb变成$20000了。其实上面这种写法其实就是洋葱模型,一层套一层,从里到外依次执行。像Vue里的过滤器和Angular的管道就是这样实现的,上一个函数执行的结果是下一个函数的入参,直到执行完所有的函数。
但是这样写有些问题,首先很不优雅,其次也不利于阅读。
有没有一个优雅一点的写法呢?有,我们可以用函数式编程可以组合的特点,将多个函数组合成一个函数即可,lodash中就有这样一个方法_.flowRight
。
代码如下:
function double(n) { // 纯函数
return n * 2;
}
function toFixed(n) { // 纯函数
return n.toFixed(0);
}
function addPrefix(n) { // 纯函数
return "$" + n;
}
const composed = flowRight(addPrefix, toFixed, double);
composed(10000.1) // $20000
我们可以看到_.flowRight
组合而成composed
函数执行的结果也是$20000,而且这样调用起来更加优雅易读。
那么这个_.flowRight
的组合原理是什么呢?其实它还是运用的函数式编程思想实现的,下面我们来简单实现一下:
function flowRight(...fns) {
return function (...args) {
const lastFn = fns.pop() // 取出最后一个函数在下面执行作为值
return fns.reduceRight(function (prev, current) {
return current(prev) // 执行当前函数它的参数是后一个的返回值,
// 如:执行length(str) str是sum的返回值
}, lastFn(...args))
}
}
flowRight
函数入参是一个或者多个函数,返回值也是一个函数,所以它是一个高阶函数。在返回的函数里面,我们用Array.prototype.reduceRight
方法一次取出函数从右往左执行,把上一次执行的结果当成下一个函数的入参,最终执行完所有的函数得到最终的结果。这样就有了组合的效果。
redux compose的实现
redux的组合函数实现起来就十分优雅,它的js实现就一行代码:
const compose = (...fns) => fns.reduce((a, b) => (...args) => a(b(...args)))
总结
通过上面的一些例子我们可以得出一些结论。函数式编程的根基是纯函数,然后运用柯理化,组合函数来进行数据的处理。可以将复杂的运算逻辑抽象成一个个函数,然后再组合起来得到最终的结果。而每一个处理函数又是可以复用的。看到这里咱们也能体会到,为啥很多前端框架和库里面用函数式编程来实现。
完结~ 撒花~🎉🎉
转载自:https://juejin.cn/post/7195883375995584569