阅读 1276

「函数式编程的实用场景 | 掘金技术征文-双节特别篇」

前言

在学习javaScript中你觉得最难的是什么?? 两链一包? 还是this? 个人认为最难的是面向对象,因为它不像你原型,闭包只要理解了其机制就能形成自己的理解从而去理解这块知识点,面向对象它是一个很抽象的大类,属于编程范式的一种,而本文要讲的函数式编程它也属于一种编程范式。函数式编程涉及的点比较多如果往全了的讲得扯到数学里的范畴论,本文主要还是从实际编码角度来理解一些比较晦涩难懂的点。

目录👇

在线卑微,如果觉得这篇文章对你有帮助的话欢迎大家点个赞👻

何叫函数式编程

函数式编程技术主要基于数学函数和它的思想所诞生的一种编程范式,本质上是一种数学运算。

个人理解: 在函数式编程中,函数就是一个流水线,传递进去的数据(参数)就像是要加工的产品,我们可以通过用不同的加工设备加工出不一样的产品,但是这条流水线始终不会出现其他产线的任务产品。

上面这句话其实就是函数式编程最重要的一个概念:纯函数

纯函数

纯函数的条件:

  • 函数内部不会依赖和影响外部的任何变量
  • 相同的输入,永远会得到相同的输出

来个🌰

条件一: 函数内部不会依赖和影响外部的任何变量

let percentValue = 5;
let calculateTax = (value) => { return value/100 * (100 + percentValue) }
复制代码

上面定义了一个计税函数在调用calculateTax传递一个参数会给我们返回计算后的税值,这个计税函数并没有达到条件,计算函数内部使用了全局变量percentValue。因此它就不能被称为纯函数

下面我们改动一下

let calculateTax = (value, percentValue) => { return value/100 * (100 + percentValue) }
复制代码

改动过后的函数接受数据加工后返回数据整个过程中并没有依赖和影响到外部的东西(函数内部不会依赖和影响外部的任何变量)

条件二: 相同的输入,永远会得到相同的输出

我们用数组内置实现的方法举个例子

let BeiGe = [1,2,3,4,5];

// 纯函数
BeiGe.slice(0,3); // => [1,2,3]
BeiGe.slice(0,3); // => [1,2,3]
BeiGe.slice(0,3); // => [1,2,3]

// 不纯的
BeiGe.splice(0,3); // => [1,2,3]
BeiGe.splice(0,3); // => [4,5]
BeiGe.splice(0,3); // => []
复制代码

从上面例子可以看出来,slice方法的实现就是一个纯函数,而splice方法就不是一个纯函数。多次调用它并没有输出相同的值。

纯函数的好处

  • 可缓存
  • 可测试
  • 可并发

可缓存

结果可以被缓存,因为相同的输入总会获得相同的输出

// 假设这是一个耗时获取数据的方法
let getData = (url) => { 
	// 一大段获取数据前的参数拼接
    // ..发送请求逻辑
} 

复制代码

上面函数就是一个获取数据的纯函数,对于给定的输入,它总会返回相同的输出那我们不就可以将请求的结果缓存下来嘛

let ret = getData(`api/detail/${id}`)
// 返回指定id的详情的数据: [{id: 1, name: Beige ...}]

retItem.hasOwnProperty(id) ? retIpItem[id] : retIpItem = getIpData(id)
// 当用户操作的是同一条数据我们就可以用之前缓存的数据,如果不是我们就可以再次发送请求来获取指定id的详情数据
复制代码

可测试

更加容易被测试,因为它们唯一的职责就是根据输入计算输出

let percentValue = 5;
let calculateTax = (value) => { return value/100 * (100 + percentValue) } 

// 测试代码假设全部通过,但是上面函数用到了外面环境中的数据,就会出现问题
calculateTax(5) === 5.25
calculateTax(6) === 6.3
calculateTax(7) === 7.3500000000000005


calculateTax(5) === 5.25
// percentValue 被其他函数改成 2
calculateTax(6) === 6.3 // 这条测试能通过吗?
复制代码

所以非纯函数代码是很难测试的,像下面的代码就可以很好的用于测试

let calculateTax = (value, percentValue) => { return value/100 * (100 + percentValue) } 
复制代码

可多并发

let global = 'globalVar'
let function1 = (input) => {
    // 处理 input
    // 改变 global
	global = "somethingElse"
}
let function2 = (global) => {
	if(global === "something"){
		// 业务逻辑
	}
}
复制代码

上面的代码在顺序执行的情况下是没有问题的,但是如果函数执行不一样的情况就会出现问题。

let global = 'globalVar'
let function1 = (input, global) => {
    // 处理 input
    // 改变 global
	global = "somethingElse"
}
let function2 = (global) => {
	if(global === "something"){
		// 业务逻辑
	}
}
复制代码

好,在认识完纯函数之后我们对上面说的那句话就应该有所体会了

在函数式编程中,函数就是一个流水线,传递进去的数据(参数)就像是要加工的产品,我们可以通过用不同的加工设备加工出不一样的产品,但是这条流水线始终不会出现其他产线的任务产品。

对于这句话相信大家都可以理解一半了,但是加粗的这段文字是什么意思呢?

声明式编程

函数式编程就是属于声明式编程范式,这种范式会描述一系列的操作,但并不会暴露它们是如何实现的或是数据流如何传过它们

// 命令式方式: 强调做什么
let arr = [0, 1, 2, 3]
for(let i = 0; i < arr.length; i++) {
    array[i] = Math.pow(array[i], 2)
}

arr; // [0, 1, 4, 9]

// 声明式方式: 强调如何做, 通过将逻辑封装抽离出来, 达到抽象的目的
[0, 1, 2, 3].map(num => Math.pow(num, 2)) // [0, 1, 4, 9]
复制代码

可以看出声明式的代码并没有将内部的实现暴露出来,相反命令式的方式通过内部循环,而循环是一种重要的命令控制结构,但很难重用,并且很难插入其他操作中。而函数式编程旨在尽可能的提高代码的无状态性和不变性。也就是要使用纯函数的方式实现

引用透明

引用透明是定义一个纯函数较为正确的方法。纯度在这个意义上表示一个函数的参数和返回值之间映射的纯的关系。如果一个函数对于相同的输入始终产生相同的结果,那么我们就说它是引用透明

// 非引用透明
let counter = 0

function increment() {
    return ++counter
}

// 引用透明
let increment = (counter) => counter + 1
// => 上面的函数有了引用透明这个特性之后, 我们知道当我们传递一个数去它只会给我们返回这个数+1, 我们可以把它看做一个恒等式

let sum = 23 * 12 + increment(6) + increment(3) + increment(2)
// 上面的这个increment(6)、(3)、(2)  完全可以看成 -> x + 1
复制代码

不可变数据

对于数据的不可变主要还是对象类型的数据,因为在js中的基本类型在不进行二次赋值是改变不了原始数据的。但是对象就不一样了,它属于引用数据类型。

let Str = '123456789'

let changeStr = (str) => str.split('').reverse().join('') // '987654321'
log(Str) // 123456789
复制代码

上面这个纯函数在接受一个字符串之后对其做了一系列的“计算”但都没有去改变数据原有性。

那如果是对象类型呢?

let sortDesc = (arr) => arr.sort((a, b) => a - b);

var arr = [1, 3, 2]
sortDesc(arr) // [1, 2, 3]
arr // [1, 2, 3]
// => 上面这个"纯函数对数据加工后却改变了原有数据
复制代码

对于引用类型的操作,不应该改变原有对象,只需要映射一个新的对象用来承载新的数据状态。

onst field = Symbol(1)

let columnData = {
  label: '北歌',
  property: 'name',
  filters: 'xxx',
  filterMultiple: 'xxx',
  sortable: 'xxx',
  index: 'xxx',
  formatter: 'xxx',
  className: 'xxx',
  labelClassName: 'xxx',
  showOverflowTooltip: 'xxx',
  field: '111'
}

// => 假设我们对某个单独列操作加个样式
let addClass = (column) => {
  return newObj = Reflect.ownKeys(column).reduce((newObj, key) => (newObj[key] = key, newObj), {})
}
// 通过映射出新对象达到不去改变原有数据的目的
addClass(columnData)
复制代码

相信到了这里对于我前面讲的那段话大家也应该理解的差不多了吧。

补充:对于函数式编程的思想在数组的原生方法就有很好的体现

函数几种用法

接下来我们讲讲函数的几种用法

  • 高阶函数
  • 递归函数
  • 柯理化函数

高阶函数

  • 函数可以作为参数
  • 函数可以作为返回值

凡是达到上面两个条件的其中一个都叫高阶函数

例:从获取的数据从找到性别为男,年龄为18的学生

let list = [ 
  {sex: '男', age: 17 },
  {sex: '女', age: 17 },
  {sex: '女', age: 13 },
  {sex: '女', age: 23 },
  {sex: '男', age: 16 },
  {sex: '男', age: 18 }
]
复制代码

不使用高阶函数的情况

let student = [];
for (let i = 0; i < list.length; i++) {
    if (list[i].age === 18 && list[i].sex === 男) {
        student.push(list[i])
    }
}
复制代码

使用高阶函数的情况

list.filter(i => i.sex === '男' && i.age === 18)
复制代码

这只是一个简单的用法,其实在很多地方我们都用到了高阶函数,如节流函数,它就是高阶函数实现的

function throttle(fn, wait) {
   if (typeof fn != "function") {
        throw new TypeError("Expected a function")
   }

    let timer,
      lastTime = 0;

  return function(...arr) {
    let nowTime = Date.now();
    if (nowTime - lastTime < wait) { // 利用时间戳来判断函数的执行时机
      timer && clearTimeout(timer) // 通过定时器来控制函数的执行
      timer = setTimeout(() => {
        fn.apply(this, ...arr);
      }, wait)
    } else { // 在第一次执行的时候不做限制
      fn.apply(fn, ...arr);
      lastTime = nowTime;
    }
  }
}

// 限制快速连续不停的点击,按钮只会有规律的在每2s点击有效
button.addEventListener('click', throttle(() => {
  console.log('前端自学驿站')
}, 2 * 1000))
复制代码

上面这个节流函数就同时达到了高阶函数的两条件,将函数当做参数,将函数当做返回值。

递归函数

使用一个调用自身的函数来实现循环,递归一般情况下都会有打破循环的条件。

例:求一个数的阶乘

/**
 * @阶乘 从1到n的连续自然数相乘的积,叫做阶乘,用符号n!表示 
 * 
 * 例 5!: 1 x 2 x 3 x 4 x 5 所得的积就是5的阶乘
 *    4!: 1 x 2 x 3 x 4 所得的积就是4的阶乘
 * 
 */
function factorial(n) {
  function tailFactorial(n, total) {
    if (n === 1) {
      return total
    }
    return tailFactorial(n - 1, n * total)
  }
  return tailFactorial(n, 1)
}
console.log(factorial(5))
复制代码

柯理化函数

柯里化是将使用多个参数的一个函数拆分成一系列使用一个参数的函数(将多元函数转换为一元函数)

使用柯里化实现累加函数

function currying(fn, ...args1) {
  if (args1.length >= fn.length) {
    return fn(...args1);
  }
  return (...args2) => {
    return currying(fn, ...args1, ...args2);
  };
}

// 定义一个用于累加的函数
const add = (x, y) => x + y;

// 使用
const increment = currying(add, 1);
console.log(increment(2)); // 3
const addTen = currying(add, 10);
console.log(addTen(2)); // 12
复制代码

这里讲解的函数几种用法在函数式编程中都会体现,它们都有一个目的就是为了让函数变的更“纯”,通过将多元函数转换为一元函数。

函数式编程的体现

在真实项目开发中我们一般通过将纯函数组合,拆解繁多的业务逻辑,这就是函数式编程的一种体现

  • 组合:执行顺序是从右至左执行的
  • 管道:执行顺序是从左至右执行的

这两个本质上没有任何区别,就像是reducereduceRight一样

来个🌰

function fn1(a) {
  return a * 2
}

function fn2(b) {
  return b + 2
}
// 我们需要传递一个数让他先乘后加 如: x = 3  => 3 * 2 + 2  
复制代码

组合的方式

const pipe = (...fns) => (val) => fns.reduce((total, fn) => fn(total), val)
let myfn = pipe(fn1, fn2)
console.log(myfn(3)); // 8
复制代码

管道的方式

const compose = (...fns) => (val) => fns.reduceRight((total, fn) => fn(total), val)
let myfn = compose(fn2, fn1)
console.log(myfn(3)); // 8
复制代码

注意他们各自的执行顺序,对于管道是从右向左来执行函数的,组合反之。

组合

下面举个例子来看看组合(compose)函数,,假设我们需要从后台拿到了一堆数据,然后我们需要多次筛选出需要的一部分数据进行操作,下面我们用伪代码实现

// 数据
const dataList = [
    {
      id: 1,
      name: 'Beige2',
      time: 123123,
      content: 21312,
      created: 1233123
    },
    {
      id: 2,
      name: 'Beige',
      time: 123123,
      content: 21312,
      created: 1233123
    },
    {
      id: 3,
      name: 'Beige2',
      time: 123123,
      content: 21312,
      created: 1233123
    },
    {
      id: 4,
      name: 'Beige2',
      time: 1,
      content: 21312,
      created: 1233123
    },
]
// 封装好的请求方法
const http = require('@/src/http') 
复制代码

通过将多个筛选数据的方法组合起来,依次执行组合中的方法达到目的,在这个过程中我们可以随意减少筛选的条件(函数)

const compose = (...fns) => (args) => fns.reduceRight((ret, fn, index) => {
  return [fn.call(null, ...ret, args[index])]
}, args[args.length - 1])


let filterId = (arr, term) => arr.filter(c => c[term[0]] > term[1] )

let filterName = (arr, term) => arr.filter(c => c[term[0]] === term[1])

let post = (str) => str && dataList // 模拟获取数据

let cacheFn = compose(filterName, filterId, post)

let ret = cacheFn([
  ['name', 'Beige2'],
  ['id', 1],
  '/post/data',
])
console.log(...ret);
复制代码

管道

上面讲过组合和管道其实本质上没什么区别,我们还是通过一个例子来看看管道(pipe)函数,实现一个功能,字符串变成大写,加上个感叹号,还要截取一部分,再在前面加上注释

const compose = (...fns) => (...args) => fns.reduce((res, fn) => [fn.call(null, ...res)], args)[0];

const toUpperCase = x => x.toUpperCase();
const exclaim = x => `${x}!`;
const head = x => `slice is: ${x}`;
const reverse = x => x.slice(0, 7);

const shout = compose(reverse, head, toUpperCase, exclaim)
console.log(shout('my name is Beige'))
// => SLICE IS: MY NAME
复制代码

这里提个问题,小伙伴们可以回想看看学过的内置方法中有那些方法使用到了函数式编程呢?

真实项目中实用函数式编程

记得之前做过的一个管理系统的项目中在数据表格中涉及到了很多了筛选条件且很多的条件其实是同样的逻辑,最后我通过函数式编程将之前写的代码进行重构,这里写个demo简单分享一下,重在思路。

需要直接看代码的可以去我的blog代码仓库https://github.com/it-beige/blog,欢迎大家点个start,日后的案列代码都会放这

上图这种情况相信大家都做过吧,对于这种筛选数据的逻辑其实大部分的情况下都是一样的,除了提取通用方法我们可以试试用函数式编程的方式来实现,废话不多说直接上代码

  • 结构比较简单大家可以直接去copy饿了吗的组件
// 三个select
<el-select
  v-model="filterTerm.value3"
  clearable
  placeholder="按照金额排序"
  @change="filterNum($event, 'amount')"
  @clear="this.value3 = null"
>
  <el-option
    v-for="item in options3"
    :key="item.value"
    :label="item.label"
    :value="item.value"
  >
</el-option>
复制代码
  • 数据部分
filterTerm: { // 用于保存筛选的条件
  value1: null,
  value2: null,
  value3: null,
},
filterFns: [], // 筛选的函数
tableData: [], // 数据
options1: [ // 可筛选的项- 大家可以根据实际业务场景进行随意配置
  {
    value: 0,
    label: "废除",
  },
  {
    value: 1,
    label: "正常"
  },
],
复制代码

重要的部分来了!

async getData() { // 获取数据的方法
  let list = []
  const res = await this.axios("/list")
  this.tableData = res.data.list;
  return res.data.list
},
复制代码
  • 筛选条件的方法
// 合同状态筛选
filterStatus(val) {
  if (this.isNull(val) || val === '') return
  let status = val ? "正常" : "废除";
  this.filterFns.push((data) => {
  return data.filter(i => i.status === status)
})
    
// 升降序的筛选 
filterNum(val, field) {
	if (this.isNull(val)) return
		this.filterFns.push((data) => {
 		return data.sort((a, b) => val ? a[field] - b[field] : b[field] - a[field])
	})
},
    
// 还可以配置跟多的筛选条件方法
复制代码
  • 两个工具函数
// utils
isNull(val) {
	if (Object.prototype.toString.call(val) === "[object Null]") return true;
	return false;
},

isOwnProperty(obj, key) {
	if (Object.prototype.hasOwnProperty.call(obj, key) !== "[object Null]") return true;
	return false;
},
复制代码
  • 点击筛选操作时的执行函数
async filterCompose() { // 组装函数
  if (this.filterFns.length > 0) {
    const filterFn = this.pipe(...this.filterFns)
    this.tableData = filterFn(await this.getData())
    this.filterFns.length = 0; // 执行后清空方法
  } else {
	this.getData()
  }
复制代码
  • 管道
pipe(...fns) {
   return (data) => {
     return fns.reduce((list, fn) => {
       return fn(list)
     }, data)
   }
}
复制代码

最后向大家推荐一个函数式编程比较常用的库ramda

  • gitHub仓库地址:https://github.com/ramda/ramda
  • 中文文档:https://ramda.cn/

🏆 掘金技术征文|双节特别篇

写在最后

如果文章中有那块写的不太好或有问题欢迎大家指出,我也会在后面的文章不停修改。也希望自己进步的同时能跟你们一起成长。喜欢我文章的朋友们也可以关注一下

我会很感激第一批关注我的人。此时,年轻的我和你,轻装上阵;而后,富裕的你和我,满载而归。

参考文章

《JavaScript ES6函数式编程入门经典》

函数式编程最佳实践

函数式编程初探

往期文章

自学前端拿到offer的心路历程

深入Vue-router最佳实践

深入Vuex最佳实践

【前端体系】从一道面试题谈谈对EventLoop的理解 (更新了四道进阶题的解析)

【前端体系】从地基开始打造一座万丈高楼