js函数式编程

145 阅读12分钟

编程范式

这里所说的函数式编程是一种编程范式。常见的函数式编程有:

  • 过程式编程:用一系列流程去完成任务,例如c语言
  • 面向对象编程:将一系列的逻辑封装成一个类,例如java
  • 函数式编程:用一系列函数去完成任务,例如Scheme
  • 声明式编程:只声明目标不指定细节,例如mysql javascript同时支持函数式编程和面向对象编程。

js编程范式

js的面向对象编程

js的面向对象编程是基于原型链的。下面使用class建立一个数学运算类,该类封装了加减乘除四种运算方法。代码如下:

class MathObj {
  constructor(number) {
    this.number = number
  }

  plus(param) {
    this.number = this.number + param
  }
  
  subtract(param) {
    this.number = this.number - param
  }
  
  multiply(param) {
    this.number = this.number * param
  }
  
  divide(param) {
    this.number = this.number / param
  }
}

const mathObj = new MathObj(1)
mathObj.plus(2)
mathObj.subtract(1)
mathObj.multiply(3)
mathObj.divide(2)
console.log(mathObj.number)

要使用这些运算方法,首先得新建一个类的实例,通过类的构造函数初始化数字number。然后通过类的实例去调用这些运算方法进行计算。

js的函数式编程

同样的需求,再使用函数式编程来做一遍。代码如下所示

function plus(num, param) {
  return num + param
}

function subtract(num, param) {
  return num - param
}

function multiply(num, param) {
  return num * param
}

function divide(num, param) {
  return num / param
}

let num = 1
const result = divide(multiply(subtract(plus(num, 2), 1), 3), 2)
console.log(result)

通过比较发现: 使用类进行四则预算,必须得创建一个对象。必将初始值传递给对象属性。之后进行计算都是修改该对象的属性值。 使用函数进行四则运算,就显得更自由。定义的初始值可以被任何地方访问,调用计算方法后返回的值可以继续作为参数被下一个计算函数调用。 何时使用? 对于一系列逻辑相关且稳定的业务可以选择封装成类。类一般用来开发第三方库 对于需要划分更小的颗粒度,且方便后续修改的时候选择函数。函数一般用来写项目的工具。

为什么函数是一等公民

在 JavaScript 中函数是一等公民的说法,主要是基于以下两点

  • 因为 JavaScript 中函数也是对象,函数拥有对象的所有能力,也因此函数可被作为任意其他类型对象来对待。当我们说函数是第一类对象(一等公民)的时候,就是说函数也能实现对象的全部功能。
  • 同时 JavaScript 中的函数可以存储在一个变量中,可以从函数中返回一个函数,并且可以作为函数参数传递到另一个函数中。由此可见函数的地位,这是其它的一些面向对象的编程语言所不具备的。

为什么选择函数式编程

以上面向对象和函数的比较还不足以说明为什么要选择函数式编程。真正决定函数式编程成为主流的原因是 tree-shaking 机制。

什么是 tree-shaking

前端中的 tree-shaking 可以理解为通过工具"摇"我们的 JS 文件,将其中用不到的代码"摇"掉,是一个性能优化的范畴。具体来说,在 webpack 项目中,有一个入口文件,相当于一棵树的主干,入口文件有很多依赖的模块,相当于树枝。实际情况中,虽然依赖了某个模块,但其实只使用其中的某些功能。通过 tree-shaking,将没有使用的模块 code 摇掉,这样来达到删除无用代码的目的。

tree-shaking 演示

使用npm init -y新建一个npm工程。并配置好webpack,bundle.js作为输出文件,index.js作为程序主入口。代码结构如下:

tree-shaking目录

分别编写函数模块func.js、类模块class.js以及入口程序index.js,代码如下图所示。在主程序中我们只使用了函数模块和类模块中的plus方法。

tree-shaking代码

然后使用webpack对项目进行打包,完毕后查看输出文件bundle.js,其代码(格式化后的)如下所示。可以看到整个类MathObj都被打包进来了,即使只使用到了plus方法。而函数模块中只有plus方法被打包了进去。

(() => {
  "use strict";
  const s = (console.log("func plus"), 3);
  console.log("函数式编程", s);
  const n = new (class {
    constructor(s) {
      this.number = s;
    }
    plus(s) {
      this.number = this.number + s;
    }
    subtract(s) {
      this.number = this.number - s;
    }
    multiply(s) {
      this.number = this.number * s;
    }
    divide(s) {
      this.number = this.number / s;
    }
  })(1);
  n.plus(2), console.log("面向对象编程", n.number);
})();

如何函数式编程

  1. 保证纯函数 一个函数的返回结果只依赖于他的参数,同样的输入必定有同样的输出。 下面这段代码就不是纯函数,虽然它每次传入的都是a和1,但两次得到的结果是不同的。这是因为a一个全局变量作为参数随时都有被修改的可能。
// 非纯函数
function add(a, b) {
  return a + b;
}

a = 6;
const result_1 = add(a, 1);
console.log('result_1', result_1)

var a = 123;
const result_2 = add(a, 1);
console.log('result_2', result_2)

改进之后的纯函数如下所示,不要将全局变量作为函数饿的参数。

// 改为纯函数
function aPlus(c, d) {
  return c + d;
}
  1. 减少函数副作用 函数副作用就是函数,会影响外部的数据,比如全局变量。 下面这段代码就会产生副作用。参数obj如果是一个深层的对象,使用assign这种浅拷贝方法复制对象会导致后续修改影响到源对象。这里经过函数修改后。返回的对象和源对象的a不同,但是b.c是相同的。显然这是不合理的,经过函数处理后居然改变了入参的,这就是产生了副作用。
// 副作用
var obj = {
  a: 123,
  b: {
    c: 1,
    d: 2,
  }
};
// 改变了函数作用于外的对象值
function objPlus(obj, num) {
  var _obj = Object.assign({}, obj);
  _obj.a += num;
  _obj.b.c = 2
  return _obj;
}
const newObj = objPlus(obj, 1)
console.log(obj)
console.log(newObj)

为了避免产生副作用,我们应该使用深拷贝的方式复制对象类型的参数。比如JSON.parse(JSON.stringify(obj))。实际开发中更推荐使用lodash这种第三方库提供的方法进行深拷贝

// 无副作用
function objPlus_2(obj, num) {
  var _obj = JSON.parse(JSON.stringify(obj))
  _obj.a += num;
  _obj.b.c = 3
  return _obj;
}

高阶函数

高阶函数概念

JavaScript的函数其实都指向某个变量。既然变量可以指向函数,函数的参数能接收变量,那么一个函数就可以接收另一个函数作为参数,这种函数就称之为高阶函数。 高阶函数是对其他函数进行操作的函数,操作可以是将它们作为参数,或者返回它们。简单总结为高阶函数是一个接收函数作为参数或者将函数作为返回输出的函数。

常见高阶函数

比如数组提供的map方法就是一个高阶函数。map()(映射)方法最后生成一个新数组,不改变原始数组的值。其结果是该数组中的每个元素都调用一个提供的函数后返回的结果。 array.map(callback,[ thisObject]);

高阶函数的实现

forEach

Array.forEach() forEach的参数有两个,一个是回调函数,另一个是this的指向。 回调函数有三个参数:数组当前项的值、数组当前项的索引、数组对象本身。 forEach的实现代码如下,值得注意的是箭头函数和匿名函数作为回调函数是,thisArgs的值是不同的。箭头函数thisArgs指向的在node中是{},在浏览器中是window。匿名函数thisArgs指向的是传入的对象本身

Array.prototype.myForEach = function (callback, thisArgs) {
  const len = this.length;
  for (let i = 0; i < len; i++) {
    // callback(this[i], i, this);
    callback.call(thisArgs, this[i], i, this);
  }
};

const array = [1, 2, 3, 4, 5, 6];
// array.myForEach(
//   (ele, index, arrSelf) => {
//     console.log(ele, index, arrSelf, this);
//   },
//   // thisArgs 可选参数。当执行回调函数callback时,用作this的值。箭头函数和function的this指向是不同的
//   { a: 9 }
// );
array.myForEach(
  function (ele, index, arrSelf) {
    console.log(ele, index, arrSelf, this);
  },
  // thisArgs 可选参数。当执行回调函数callback时,用作this的值。箭头函数和function的this指向是不同的
  { a: 9 }
);

map

map的实现原理与forEach大致相同,区别在于map需要回调函数返回一个值,map将这些值存放到数组中,最后循环执行完毕后返回该数组。

// map
Array.prototype.myMap = function (callback, thisArgs) {
  let len = this.length;
  let result = [];
  for (let i = 0; i < len; i++) {
    const item = callback.call(thisArgs, this[i], i, this);
    result.push(item);
  }
  return result;
};

const result_2 = array.myMap((v, i, arrSelf) => {
  console.log(v, i, arrSelf);
  return i * 2;
});
console.log("the result of map is:", result_2);

reduce

reduce的实现原理稍显不同,它需要讲上一次回调函数返回的值作为下一次回调函数执行是的第一个参数。其代码试下如下所示:

// reduce
Array.prototype.myReducer = function(callback, initial) {
  let pre = initial
  const len = this.length
  let i = 0
  if (pre === undefined) {
    pre = this[0]
    i = 1
  }
  for (i; i < len; i++) {
    pre = callback(pre, this[i], i)
  }
  return pre
}
const result_3 = array.myReducer((pre, val, index) => {
  return pre + val
}, 1)
console.log("the result of reduce is:", result_3);

实现一个自定义高阶函数

实现一个高阶函数本身是比较简单的,就是在必要时刻去调用传递过来的函数参数。下面是一个对象筛选的高阶函数,第一个参数是对象,第二个参数是回调函数它实现了如何筛选。 值得注意的是编写自定义高阶函数是,需要对入参进行检验,同时规定好传给回调函数的参数。

// myCustomFunc 我的自定义函数: 对象筛选
function myCustomFunc(obj, fn) {
  if (Object.prototype.toString.call(obj) !== "[object Object]") {
    throw Error("myCustomFunc error: 第一个参数必须为对象")
  }
  const _obj = JSON.parse(JSON.stringify(obj))
  const result = {}
  for (const key in object) {
    if (Object.hasOwnProperty.call(object, key)) {
      const element = object[key];
      if (fn(element, key)) {
        result[key] = element
      }
    }
  }
  return result
}
const object = {
  a: 1,
  b: 2,
  c: 3,
  d: 4
}
const result_4 = myCustomFunc(object, (element, key) => element % 2 === 0)
console.log("the result of myCustomFunc is:", result_4);

函数柯里化

函数柯里化是一种技术,一种将多入参函数变成单入参函数。这样做会让函数变得更复杂,但同时也提升了函数的普适性。

什么情况下需要柯里化

下面这段代码演示了检验手机号的功能。该函数接受两个参数:手机号的正则匹配和待测试的手机号。在某一次测试中,需要对一批手机号按照某种规则进行匹配。结果每次调用这个函数都要传入两个参数,其中第一个一直是固定的。这看起来很不优雅。

//校验手机号
function validatePhone(regExp,phone){
  const reg = regExp;
  if (phone && reg.test(phone) === false) {
    console.log(`手机号${phone}验证通过`)
  } else {
    console.log(`手机号${phone}格式不符`)
  }
}

//调用校验
validatePhone(/^(13[0-9]|14[0-9]|15[0-9]|166|17[0-9]|18[0-9]|19[8|9])\d{8}$/,18712343311)
//调用校验
validatePhone(/^(13[0-9]|14[0-9]|15[0-9]|166|17[0-9]|18[0-9]|19[8|9])\d{8}$/,13756781234)
//调用校验
validatePhone(/^(13[0-9]|14[0-9]|15[0-9]|166|17[0-9]|18[0-9]|19[8|9])\d{8}$/,15939086204)
//调用校验
validatePhone(/^(13[0-9]|14[0-9]|15[0-9]|166|17[0-9]|18[0-9]|19[8|9])\d{8}$/,13731232125)
//调用校验
validatePhone(/^(13[0-9]|14[0-9]|15[0-9]|166|17[0-9]|18[0-9]|19[8|9])\d{8}$/,19109765236)

改造成柯里化函数

接下来,我们将之前的函数改造成柯里化函数。只需将原函数接受一个参数即正则匹配规则,然后返回一个函数,该函数接受的参数为手机号。这之后只需只需调用返回的函数传入手机号即可进行校验。相关代码如下所示:

function validateCurry(regExp) {
  const reg = regExp;
  return function (phone) {
    if (phone && reg.test(phone) === false) {
      console.log(`手机号${phone}验证通过`)
    } else {
      console.log(`手机号${phone}格式不符`)
    }
  }
}

const validate = validateCurry(/^(13[0-9]|14[0-9]|15[0-9]|166|17[0-9]|18[0-9]|19[8|9])\d{8}$/)

//调用校验
validate(18712343311)
//调用校验
validate(13756781234)
//调用校验
validate(15939086204)
//调用校验
validate(13731232125)
//调用校验
validate(19109765236)

函数的柯里化封装

上一节简单实现了一个柯里化函数,它接受正则匹配规则参数后返回一个函数。之后只用调用该函数传入手机号进行校验。 这样看似很不错。但是这个柯里化函数存在一个问题。它的柯里化程度是固定死的,它永远先接受一个参数,返回一个函数,再接受一个参数。 当函数的参数有三个或者更多以上时。需要接受几个参数后返回函数都是不固定的。如果我们针对每个情况去写一个柯里化函数显然是不现实的。当参数越多其组合也越多。 为了解决这个问题,我们需要有一个函数柯里化的封装。它可以针对任意数量的参数组合来返回柯里化函数,相关代码如下所示:

// 函数柯里化封装
function curry(fn, args) {
  // 获取待柯里化的原始函数有几个参数
  let length = fn.length
  let oldArgs = args || []
  return function() {
    // Array.prototype.slice.call将参数转换为数组
    newArgs = oldArgs.concat(Array.prototype.slice.call(arguments))
    if (newArgs.length < length) {
      // 可以继续柯里化
      return curry.call(this, fn, newArgs)
    } else {
      return fn.apply(this, newArgs)
    }
  }
  
}

// 需要被柯里化的函数
function multiFn(a, b, c) {
  return a * b * c;
}

// multi是柯里化之后的函数
var multi = curry(multiFn);
console.log(multi(2)(3)(4));
console.log(multi(2, 3, 4));
console.log(multi(2, 3)(4));

该函数接受一个函数,即需要被柯里化的函数。然后比较目前已经接受的参数和源函数的参数,如果参数未全部接收,则表示可以继续柯里化,进而递归调用该函数。

compose

compose函数可以将需要嵌套执行的函数平铺,嵌套执行就是一个函数的返回值将作为另一个函数的参数。可以参考如下的一个代码:

function multiplyTwo(num) {
  return num * 2;
}
function minusOne(num) {
  return num - 1;
}
function addTwo(num) {
  return num + 2;
}
function addThree(num) {
  return num + 3;
}

let num = 1
const result = minusOne(multiplyTwo(addThree(addTwo(10))))
console.log(result)

该代码需要一次执行一系列的运算,并将前一个计算的结果作为参数传到下一个计算中去。实现它本身并不难,但是上述写法看起来不优雅,而且可读性很差。因此我们可以创建一个compose函数,将这些计算方法组合起来,代码如下所示:

/**
 * 简单的compose函数,从右向左执行
 * @param  {...Function} fn 多个函数
 * @returns 
 */
function compose(...fn) {
  // 使用结构传值的fn是数组,使用内置的参数arguments是个对象
  const fns = Array.prototype.slice.call(arguments)
  const length = fns.length
  return function(num) {
    let result = num
    for (let i = length - 1; i >= 0; i--) {
      result = fns[i](result) 
    }
    return result
  }
}

console.log(compose(minusOne, multiplyTwo, addThree, addTwo)(10))

Compose函数可以理解为为了方便我们连续执行方法,把自己调用传值得过程封装了起来,我们只需要给compose函数我们要执行哪些方法,他会自动得执行。 为了和连续调用一系列的方法在直觉上保持一致,compose方法参数中的函数是从右向左一次执行的。

pipe

pipe函数跟compose函数的作用是一样的,也是将参数平铺,只不过他的顺序是从左往右。

function multiplyTwo(num) {
  return num * 2;
}
function minusOne(num) {
  return num - 1;
}
function addTwo(num) {
  return num + 2;
}
function addThree(num) {
  return num + 3;
}

/**
 * 简单的pipe函数,从左向右执行
 * @param  {...Function} fn 多个函数
 * @returns 
 */
function pipe(...fn) {
  // 使用结构传值的fn是数组,使用内置的参数arguments是个对象
  const fns = Array.prototype.slice.call(arguments)
  const length = fns.length
  return function(num) {
    let result = num
    for (let i = 0; i < length; i++) {
      result = fns[i](result) 
    }
    return result
  }
}

console.log(pipe(minusOne, multiplyTwo, addThree, addTwo)(10))

pipe可以理解为流水线执行的方法,当执行完当前函数后,再去执行下一个函数。它的顺序就是从左向右执行。 学习了compose和pipe函数后,我们可以实现一下koa的洋葱模型。所谓的洋葱模型就是值koa的中间件执行的模式。koa的中间件从左向右依次执行,但是在执行时遇到next()时将停止执行当前中间件,转而去执行下一个中间件。当后面的步骤执行完时,再回过头来执行完当前中间件后面的东西。用代码实现如下:

// 实现koa的洋葱模型
async function func_1(next) {
  console.log("func_1 start");
  await next();
  console.log("func_1 end");
}

async function delay(interval) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log(`定时器执行完毕,用${interval}s`);
      resolve();
    }, interval);
  });
}

async function func_2(next) {
  console.log("func_2 start");
  await delay(1000);
  await next();
  console.log("func_2 end");
}

async function func_3(next) {
  console.log("func_3 start");
  console.log("func_3 end");
}

function koaCompose(middlewares) {
  return function () {
    return dispatch(0);
    function dispatch(index) {
      const fn = middlewares[index];
      if (!fn) {
        console.log("执行完毕");
        return Promise.resolve();
      }
      return Promise.resolve(
        fn(function next() {
          return dispatch(index + 1);
        })
      );
    }
  };
}

const koaFunc = koaCompose([func_1, func_2, func_3]);
koaFunc();

代码运行结果如下:

func_1 start
func_2 start
定时器执行完毕,用1000s
func_3 start
func_3 end
func_2 end
func_1 end

链式调用

链式调用是一种很常见的函数调用方式。大名鼎鼎的jquery和d3等第三方库都采用了链式调用。其好处是,整个调用过程看起来非常的清晰,利于读者明白代码的执行逻辑。 接下来实现一个简单的链式调用。对于链式调用最重要的点就是在调用玩函数后要返回正确的上下文,通过该上下文可以找到接下来要调用的函数。代码如下所示:

// 创建一个链式调用函数
function Person(name, age) {
  this.name = name
  this.age = age

  console.log(`${this.name}今年${this.age}岁`)

  this.getUp = function(time) {
    console.log(`${time}点起床`)
    return this
  }

  this.eat = function(time) {
    console.log(`${time}点吃饭`)
    return this
  }

  this.work = function(time) {
    console.log(`${time}点吃饭`)
    return this
  }

  this.sleep = function(time) {
    console.log(`${time}点睡觉`)
    return this
  }
}

const person = new Person("小明", 29)
person.getUp(8).eat(9).work(10).sleep(23)

防抖

为什么要防抖

有的操作是高频触发的,但是其实触发一次就好了,比如我们短时间内多次缩放页面,那么我们不应该每次缩放都去执行操作,应该只做一次就好。在比如监听输入框输入,不应该每次都去触发监听,应该是用户完成一段输入后,再进行触发。简而言之,就是等用户高频事件完了,再进行事件操作。

防抖要怎么做

防抖的步骤如下所示:

  1. 事件触发,开启一个定时器
  2. 如果再次触发,则清除上一次的,重写开一个
  3. 定时到,触发操作 用代码来表述就是:
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <input id="inputRef"></input>
  <p id="textRef">输入的文字为:</p>
  <script>
    const inputDom = document.getElementById("inputRef")
    function debounce(callback, time) {
      let timer = null;
      return function () {
        clearTimeout(timer)
        timer = setTimeout(() => {
          callback.apply(this, arguments)
        }, time)
      }
    }
    // 不设置防抖的事件
    // inputDom.oninput = function(event) {
    //   const textDom = document.getElementById("textRef")
    //   textDom.innerHTML = `输入的文字为:${event.target.value}`
    // }
    inputDom.oninput = debounce(function (event) {
      const textDom = document.getElementById("textRef")
      textDom.innerHTML = `输入的文字为:${event.target.value}`
    }, 1000)
  </script>
</body>
</html>

节流

为什么要节流

防抖存在一个问题,事件会一直到等到用户完成操作后一段事件再操作。如果一直操作,会一直不触发。如果这是一个按钮,点击就发送请求。如果一直点,那么请求就会一直不发出去。这里的正确思路应该是第一次点击就发送,然后上一个请求回来后,才能再发。简而言之,就是某个操作希望上一次的完成后再进行下一次,或者说希望隔一定时间触发一次

节流要怎么做

  1. 事件触发时,操作执行并关闭阀门
  2. 阀门关闭,后续触发无效
  3. 一定时间后,阀门打开,这时操作可再次触发
  4. 然后从第1步开始继续执行步骤 用代码描述如下:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
<input id="inputRef"></input>
<p id="textRef">输入的文字为:</p>
<script>
  const inputDom = document.getElementById("inputRef")
  function throttle(callback, time) {
    let valid = true;
    return function () {
      if (valid) {
        callback.apply(this, arguments); // 立即执行一次
        setTimeout(() => {
          // callback.apply(this, arguments); // 延迟执行一次
          valid = true;
        }, time)
        valid = false;
      } else {
        return false;
      }
    }
  }
  inputDom.oninput = throttle(function (event) {
    const textDom = document.getElementById("textRef")
    textDom.innerHTML = `输入的文字为:${event.target.value}`
  }, 1000)
</script>
</body>
</html>
  • 防抖和节流都是为了阻止操作高频触发,从而浪费性能。
  • 防抖是让你多次触发,只生效最后一次。适用于我们只需要一次触发生效的场景
  • 节流是让你的操作,每隔一段时间才能触发一次。适用于我们多次触发要多次生效的场景