likes
comments
collection
share

😍Vue和React都在用的函数式编程,学起来~

作者站长头像
站长
· 阅读数 11

函数式编程范式在很多框架和库里中使用十分广泛,目前React已经全面拥抱函数式组件,源码中处处体现了函数式编程的思想。Vue3里所有的响应式API都是函数式编程,可以用reactivewatchcomputed来组合出各种各样的可复用的逻辑。包括使用很广泛的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时是数组的第一项,索引idxstartVal没传时是0,否则是1。然后循环调用传入的callback处理每一项,且赋值累加值到acc上,最后返回结果acc

通过函数的组合,抽象出运算过程,封装具体的实现。

把处理方式交给用户处理,这样写起来就十分灵活,便于复用Array.pototype.reduce的返回值是任意类型,所以它可以替代数组所有的上所有遍历的方法,例如mapforEachfiltersome,都可以用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一个函数出去,将传入的参数作为keycache获取缓存结果,没获取到说明没计算过,就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)))

源码地址:github.com/reduxjs/red…

总结

通过上面的一些例子我们可以得出一些结论。函数式编程的根基是纯函数,然后运用柯理化组合函数来进行数据的处理。可以将复杂的运算逻辑抽象成一个个函数,然后再组合起来得到最终的结果。而每一个处理函数又是可以复用的。看到这里咱们也能体会到,为啥很多前端框架和库里面用函数式编程来实现。

完结~ 撒花~🎉🎉