3万6千字爆肝,前端进阶不得不了解的函数式编程开发,含大量实例,手写案例,所有案例均可运行

Posted GoldenaArcher

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了3万6千字爆肝,前端进阶不得不了解的函数式编程开发,含大量实例,手写案例,所有案例均可运行相关的知识,希望对你有一定的参考价值。

还不了解React所提倡的,Vue最近也慢慢兼容的函数式编程风格吗?那看看这里,深入理解一下函数式编程及其好处,写出更加优雅的代码吧。

本章内容包含:

  • 理解函数式编程,如为什么要学习函数式编程,函数式编程所带来的好处,高阶函数的意义,以及手写实现一些常见的 Array 函数

  • 柯里化,包括概念讲解、案例以及手写实现柯里化

  • 组合函数,组合函数的讲解和理解,以及手写函数组合的处理方法

  • Lodash 中 fp 的使用,以及 fp 和 lodash 的一些对比

  • Point-free 的概念和相关案例

    包括 Function 函子,MayBe 函子,Either 函子,IO 函子, of 函子, Monad 函子

以上内容中都或多或少地带了一些 Lodash 的使用

前前后后总共学了差不多一周的时间,笔记零零碎碎地写了两三天。除了视频的内容之外,也结合了一些自己的开发经验补充了一些案例,希望能够共同进步,早日上岸 w

认识函数式编程

函数式编程(Functional Programming) 是一种编程思想,起源于 λ-calculus(Lambda Calculus),在 1930 年的时候就已经被用于功能性应用、定义及递归上。其核心思维就是抽象化过程,关注输入和输出。

应用函数式编程思想的编程语言家族成员有:

  • LISP,一个 1960 年就被设计出来的编程语言,至今仍然非常活跃
  • Meta Language(ML)
  • Scala
  • F#
  • Erlang
  • Haskell

最近随着 React 的流行,函数式编程的思想又再一次流行起来了。

它的特点有以下几个:

即:

  • 不可变数据(Immutable Data)

    React 和 Redux 的核心思维,Vue 也引进了这个概念

  • 闭包(Closure)

    javascript 中应用广泛的痛点之一

  • 函数是一等公民(First-class function)

    函数式编程是一种概念,既然早在编程语言出现之前就有了这种思想,那么这个函数指代的自然不会是编程语言中的函数。

    它所指代的是更纯粹的数学中的函数映射关系,如 y = s i n ( x ) y = sin(x) y=sin(x) 中, x x x y y y 的关系

  • 维护性(Maintainability)

    依赖于其纯函数的特性,即 相同的输入始终会得到相同的输出,这也是数学中幂等性的概念

  • 模块化(Modularity)

    三大主流框架之中 Angular 不了解,只知道是 MVC 的结构;但是 React 和 Vue 都在走模块化拆分的路线

    这么一算就是大半的主流前端框架都在使用模块化的思维进行开发,自然可以说明函数式开发有其自己的优点

  • 引用透明性(Referential Transparency)

    即函数的返回值只依赖其输入值的思想,这种思想可以使得代码更加模块化和易于测试。

    这也代表着函数式编程是用来描述 数据(函数) 之间的映射(关系)

从应用层的角度上来说,函数式编程也有以下几个优点:

  • 因为函数的返回值只依赖于其输入值,所以可以抛弃对 this 的依赖

  • 可以更好地利用 tree shaking 过滤无用代码

    即将不相关的代码「摇」掉,在打包的过程中不将不想关的代码打包的做法

  • 方便测试

    基于其幂等性,所以每一次给予相同的输入都应该会返回相应的输出。因为可预测性,也就使得测试变得更加简单

  • 众多的库可以帮助开发

    最常有的有 lodash,还有 underscore, rambda

综上所述,这也是为什么对于前端人员来说,学习和了解函数式编程开发俨然成了一个必修课。

函数相关复习

函数是一等公民

当一门编程语言的函数可以被当作变量一样用时,则称这门语言拥有 头等函数

这是来自 MDN 的定义,MDN 将 first-class function 翻译成了头等函数,本质上是一个东西。

在 JavaScript 中,除了原始值之外的其他值都是对象,包括函数,也因此,函数就可以被存储到变量/数组中去,也可以作为另外一个函数的参数和返回值。

函数作为变量在 JavaScript 是一个比较常见的写法了,也被称之为 函数表达式

const fn = function () {
  console.log('Function expression');
};

fn();

// 更加复杂的应用法
const BlogController = {
  index(posts) {
    return Views.index(posts);
  },
  show(post) {
    return Views.show(posts);
  },
  create(attr) {
    return DB.create(attrs);
  },
  update(post, attrs) {
    return DB.update(post, attrs);
  },
  destroy(post) {
    return DB.destroy(post);
  },
};

// 优化
// 将方法作为值 赋给另一个方法
// 注意,赋的是方法本身,而不是其调用,所以 () 在这里是不需要的
const BlogController = {
  index: Views.index,
  show: Views.show,
  create: DB.create,
  update: DB.update,
  destroy: DB.destroy,
};

上面的案例已经充分说明了 函数被当作变量一样用 的特性,而这个特性,就是高阶函数和柯里化的基础。

高级函数

高阶函数(Higher-order Function)有以下两个特性:

  • 函数可以作为参数

    这个特性 React 里面的 HOC 用的贼 6

  • 函数可以作为返回值

函数作为参数

这个特点依旧是围绕函数的模块化来实行的,即将函数封装为一个模块。

y = f ( x ) y = f(x) y=f(x) 为例,对于 y y y 而说, f ( x ) f(x) f(x) 的具体实现过程是被抽象化的, y y y 只需要得到 f ( x ) f(x) f(x) 返回的结果,并且 f ( x ) f(x) f(x) 返回的结果是正确的即可。

注:函数名必须要有意义,需要自证其用。

案例 1,模拟 forEach

对于 forEach() 来说,这个函数的意义就是 对数组的每个元素执行一次给定的函数。至于函数内部是怎么完成数组的遍历的,调用者并不在乎。

// 接受一个函数去在遍历中执行
/**
 * 对数组的每个元素执行一次给定的函数
 *
 * @param {arr} arr
 * @param {func} fn
 */
function forEach(arr, fn) {
  for (let i = 0; i < arr.length; i++) {
    fn(arr[i]);
  }
}

// 测试部分
const arr = [1, 2, 3, 4];

forEach(arr, function (item) {
  console.log(item);
});
// 输出为
// 1
// 2
// 3
// 4
案例 2,模拟 filter

同样的,对于 filter() 来说,这个函数的意义就是 新建一个新数组, 其包含通过所提供函数实现的测试的所有元素。至于函数内部是怎么完成数组的遍历的,调用者同样不在乎。

/**
 * 新建一个新数组, 其包含通过所提供函数实现的测试的所有元素
 * 为了通用性,接受一个函数去在遍历中执行
 * @param {array} arr
 * @param {fn} fn
 * @returns
 */
function filter(arr, fn) {
  const results = [];
  for (let i = 0; i < arr.length; i++) {
    const el = arr[i];
    if (fn(el)) {
      results.push(el);
    }
  }
  return results;
}

// 测试部分
const arr2 = [10, 15, 20, 25];
const result = filter(arr2, function (item) {
  return item % 2 === 0;
}); // 结果为 [ 10, 20 ]

函数作为返回值

函数作为返回值就等于让一个函数生成另一个函数。

函数作为返回值使用案例

下面是一个初级案例,讲的是怎么使用函数去生成另一个函数:

/**
 * 用函数生成另外一个函数
 * @returns function
 */
function generateFn() {
  let msg = 'Hello World';
  return function () {
    console.log(msg);
  };
}

const fn = generateFn();
fn(); // Hello World

// 第二种调用方式
generateFn()(); // Hello World
once 案例

知道了怎么生成函数,接下来就要写一个有意义的案例了。

lodash 中的 once 就是只让函数执行一次,这种其实我觉得使用最多的应该是单例模式了,创建一个变量,然后让全局引用。

使用案例如下:

/**
 * 让一个函数只执行一次
 * @param {function} fn
 */
function once(fn) {
  let done = false;
  return function () {
    if (!done) {
      done = true;
      // arguments 是匿名函数中的 artuments
      return fn.apply(this, arguments);
    }
  };
}

const pay = once(function (amount) {
  console.log(`Has paid: $${amount}`);
});

pay(100); // 只会执行一次
pay(100); // 运行到这里的时候,done就已经是false了,所以后面不会再被执行
pay(100);
pay(100);
pay(100);

高阶函数的意义

有两点:

  • 对运算过程进行抽象处理通用的问题
  • 屏蔽掉抽象的部分,只需要关注目标,即运行结果

这也是函数式编程的核心思想,如上面写的 forEachfilter,就是抽象化了内部的实现过程,只关注其运行结果——对 forEach 来说是遍历,对 filter 是过滤。

常用的高阶函数

模拟了一部分部分数组的通用函数,鉴于都已经知道这些函数的目的了,注释就偷下懒了……

map

直接写实现了。

const map = (arr, fn) => {
  const results = [];
  for (let el of arr) {
    results.push(fn(el));
  }
  return results;
};

const arr = [1, 2, 3, 4];
console.log(map(arr, (el) => el ** 2)); // [ 1, 4, 9, 16 ]
array

直接写实现了。

const arr = [1, 2, 3, 4];

const every = (arr, fn) => {
  for (const el of arr) {
    if (!fn(el)) {
      return false;
    }
  }
  return true;
};

console.log(every(arr, (el) => el > 2)); // false
console.log(every(arr, (el) => el > 0)); // true
some

直接写实现了。

const arr = [1, 2, 3, 4];

const some = (arr, fn) => {
  for (const el of arr) {
    if (fn(el)) {
      return true;
    }
  }
  return false;
};

console.log(some(arr, (el) => el > 2)); // true
console.log(some(arr, (el) => el > 5)); // false

闭包

JavaScript 最大的痛点之一……

闭包的概念

闭包(closure) - 函数和其周围的状态(词法环境)的引用捆绑在一起形成闭包

翻译:可以在另一个作用域中调用一个函数的内部函数,并且访问到该内部函数的作用域中的成员

理解一下,就是,JavaScript 是原型链继承的,所以正常的使用都是下克上——原型链底层调用上层。使用闭包完成了上克下——即调用原型链下层的变量。

以之前的案例来说,generateFnonce 都是使用了闭包的概念。例如说他们的调用都是在全局作用域中,可是在全局作用域中,它们能够访问 generateFnonce 中的变量。

generateFn 为例:

当闭包不存在的时候,因为 ES6 的新特性就是在 {} 之中新构筑了一个作用域,所以当 generateFn 执行完毕后,msg 的引用不存在了,从而被垃圾回收。

function generateFn() {
  let msg = 'Hello World';
} // 离开这个作用域之后,msg的引用就消失了

const fn = generateFn();
fn();

但是,当闭包存在滞后,情况就不一样了:

function generateFn() {
  let msg = 'Hello World';
  return function () {
    // 对 msg 还存在引用关系
    console.log(msg);
  };
} // 匿名函数被作为引用对象返回给了外层的对象,引用依然存在,msg无法被销毁

// 生成了对 generateFn() 中的匿名函数的引用
// 但凡 fn 没有被销毁,generateFn() 中的 msg 就 无法被释放
const fn = generateFn();

案例

案例假设:经常会调用数字的幂,为了减少变量的传输,因此会将 Math.pow(val, nthPow) 进行封装。

function generateNthPow(pow) {
  return function (num) {
    return Math.pow(num, pow);
  };
}

const square = generateNthPow(2);

console.log(square(2));

接下来分析一下上面这个案例的执行过程。

  1. 当执行到 const square = generateNthPow(2); 时:

    调用 generateNthPow(pow),并且返回 其中的匿名函数

    此时 square 的值为 function (num) {...} 的引用

  2. 当执行到 console.log(square(2))

    调用其中的匿名函数,并且返回 Math.pow(num, pow) 的值,注意一下现在的作用域:

    现在的局部作用于是匿名函数的作用域,因此能够看到 num: 2,而下面的闭包(closure),就是跟闭包相关的变量。

    再回忆一下现在的函数的声明时在 global 这个作用域之中,它访问到了一个 generateNthPow 的内部作用域中的内部函数,并且还能够访问到 generateNthPow 中的的变量,也就是 pow

适当地使用闭包可以减少函数的重复性,从而减少代码量,写出更加优雅的代码。

函数式编程基础

lodash

一个函数的功能库,提供了对数组、数字、对象、字符串、函数等操作的一些方法。

这里要重点注意 lodash/fp,这是一个 FP 样式友好的模块,它不会长生副作用。

lodash 的一些功能展示如下:

// 演示 lodash
// first / last / toUpper / reverse / each / includes / find / findIndex
const _ = require('lodash');

const array = ['jack', 'rose', 'tom', 'jerry'];

console.log(_.first(array)); // jack
console.log(_.last(array)); // jerry

console.log(_.toUpper(array)); // JACK,ROSE,TOM,JERRY

// 会改变 array 的顺序
console.log(_.reverse(array)); // [ 'jerry', 'tom', 'rose', 'jack' ]

_.each(array, (item, index) => {
  console.log(item, index);
});
// jerry 0
// tom 1
// rose 2
// jack 3

// includes, find, findIndex 是 ES6 之后新加的函数
// lodash的对应函数作用与ES6的函数是一样的
console.log(_.includes(array, 'jack')); // true
console.log(_.find(array, (el) => el === 'tom')); // tom
console.log(_.findIndex(array, (el) => el === 'rose')); // 2

效果截图:

纯函数

纯函数的概念和特点

纯函数的概念和特点有以下三点:

  • 相同的输入永远会得到相同的输出,并且没有任何可观察的副作用。即,保持了函数的幂等性.

    纯函数就像是数学中的函数,可以用 y = f ( x ) y = f(x) y=f(x) 来描述 输入 x x x 与 输出 y y y 之间的关系。

    数组中 slicesplice 分别对应了纯函数和不纯函数:

    • slice: 方法返回一个新的数组对象,这一对象是一个由 begin 和 end 决定的原数组的浅拷贝(包括 begin,不包括 end)。原始数组不会被改变。

    • splice: 方法通过删除或替换现有元素或者原地添加新的元素来修改数组,并以数组形式返回被修改的内容。此方法会改变原数组。

    let array = [1, 2, 3, 4, 5];
    console.log(array.slice(0, 3)); // [ 1, 2, 3 ]
    console.log(array.slice(0, 3)); // [ 1, 2, 3 ]
    console.log(array.slice(0, 3)); // [ 1, 2, 3 ]
    // 结果输出相同,证明slice是纯函数
    
    console.log(array.splice(0, 3)); // [ 1, 2, 3 ]
    console.log(array.splice(0, 3)); // [ 4, 5 ]
    console.log(array.splice(0, 3)); // []
    // 结果输出改变,证明splice不是纯数组
    
  • 函数式编程不会保留计算中间的结果,所以状态是不可变的(无状态的)

  • 刻意把一个函数的执行结果交给另一个函数去处理(高阶函数)

好处