前言
函数式编程在前端日常工作中,随处可见,也变得尤为重要,它本身也成为FE的基本技能之一,比如一些常用库: Redux、Koa等,同时掌握在开发中,使用函数式编程,可以最小度避免未知错误,降低系统复杂度。废话不多说,Let's hit the road!
编程类型介绍
- 面向过程编程:想到哪写到哪
- 面向对象编程:将所有的属性和方法封装到一个类中(class或对象原型)
- 面向切面编程:IOC(装饰器模式),常用于BFF、SFF项目中
- 函数式编程:FP - Functional Programming,提纯无关于业务的纯函数,函数套函数产生神奇的效果
- 函数式编程不是单纯的用函数来编程,也不是传统的面向对象编程,主旨在于将复杂的函数合成简单函数,运算过程尽量写成一系列嵌套的函数调用
- 真正火热起来是因为 React 高阶函数的推广,Redux最能体现FP的编程思想
- 流行应用库:fp-ts,重要的事情说3遍!!!,这个库一定要掌握,它完全继承了FP的编程思想,不过在学习这个库前,一定要掌握FP高阶理论知识(容器、函子等)后,才可进行,否则,你可能【还没入门就放弃】我会在后面的文章介绍高阶理论和这个库
重新认识一下函数
函数是一等公民
函数与其它数据类型一样,出于平等地位,可以赋值给其他变量,也可以作为参数,传入另一个函数,或者作为别的函数的返回值
函数式编程中,函数的使用:
- 只用表达式,不用语句
- 函数不产生副作用
- 不可变性,不可修改状态
不可变性: 在函数式编程中,变量被函数代替了,变量仅仅代表某个表达式,这里说的变量是不能被修改的。所有变量只能被赋一次初值
- 引用透明
函数运行只依赖参数,相同的输入一定获得相同的输出
核心概念
纯函数(Pure)
前面介绍过,函数式编程,首先要做到的就是函数提纯
何为纯函数
- 对于相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用,也不依赖外部环境的状态
var xs = [1, 2, 3, 4, 5];
// Array.slice是纯函数,因为它没有副作用,对于固定的输入,输出总是固定的
xs.slice(0, 3);
xs.slice(0, 3);
xs.splice(0, 3); //不是纯函数,因为结果变为[4,5]了,变脏了
xs.splice(0, 3); //不是纯函数
纯函数特性
- 纯函数不仅可以有效降低系统复杂度,还有其他很棒的特性,如:可缓存性
- 抽象代码更方便单元测试
- 可缓存性
import _ from 'lodash';
var sin = _.memorize(x => Math.sin(x));
//第一次计算的时候会稍慢一点
var a = sin(1);
//第二次有了缓存,速度极快
var b = sin(2);
- 缺点:扩展性比较差,硬编码会降低灵活度
看下面的例子
// 不纯的
var min = 18;
var checkAge = age => age > min;
// 纯的
var checkAge = age > 18;
在不纯的版本中: checkAge不仅取决age,还有外部依赖的变量min
在纯的版本中: checkAge把关键数字18硬编码在函数内部,扩展性比较差,函数柯里化可以解决,但在这之前,我们先了解一下偏应用函数
偏应用函数(Partial application)
- 将函数的参数分批传递
- 先传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。
- 偏函数之所以“偏”,就在于其只能处理那些能与至少一个case语句匹配的输入,而不能处理所有可能的输入
// 带⼀个函数参数 和 该函数的部分参数
const partial =
(f, ...args) =>
(...moreArgs) =>
f(...args, ...moreArgs);
// 声明一个函数
const add3 = (a, b, c) => a + b + c;
// 偏应⽤ `2` 和 `3` 到 `add3` 给你⼀个单参数的函数 fivePlus
const fivePlus = partial(add3, 2, 3); // 等同于 add3.bind(null, 2, 3)
// 最终会调用,本质上还是在调用:(a, b, c) => a + b + c
fivePlus(4); // 2 + 3 + 4
上面的例子中,partial是一个闭包,暂存了args的值,然后返回给使用者包含这写args的另一个函数,而使用者只需要调用这个值,传递剩余的值
- bind的实现其实就是偏应用函数的应用之一
// const fn = add3.bind(null, 2, 3)
// fn(4);
Function.prototype.bind = function() {
const args = Array.prototype.slice.call(argumnets, 1); // 获取除null外的参数
const me = this;
return function () {
const _args = Array.prototype.slice.call(argumnets);
return me.apply(me, args.concat(_args)); // 将全部参数传入
}
}
函数柯⾥化(Curring)
- 函数柯⾥化是偏应用的实现,它是把一个多参数函数转换为一个嵌套一元函数的过程(与偏应用函数的区别:柯⾥化一次只能传递一个参数)
- 它可以记住参数,相当于对参数的一种缓存,它就是偏应用函数极致应用
- 通过函式编程解决纯函数的硬编码问题
- ???柯里化的参数列表是从左向右的
// 柯⾥化之前
function add(x, y) {
return x + y;
}
add(1, 2) // 3
// 柯⾥化之后
function addX(y) {
return function (x) {
return x + y;
};
}
addX(2)(1) // 3
var checkAge = min => (age => age > min);
// 让纯函数内部的变量 更加灵活 这个参数不依赖于外部的任何变量
var checkAge18 = checkAge(18);
checkAge18(20);
- 函数柯里化实现
funciton Currying(fn) {
return function curried(...args) {
if (fn.length === args.length) {
return fn.apply(this, args);
} else {
return function (...args2) {
return curried.apply(this, args.concat(args2));
}
}
}
}
const curryTest = Currying((a,b,c,d) => a + b + c + d);
curryTest(1)(2)(4)(3); // 10
// 详细解释
const currying = function(fn, ...args) {
// fn 参数个数, 比如 add(a, b),那么 add.length就为2
const len = fn.length
// 返回一个函数接收剩余参数
return function (...params) {
// 拼接已经接收和新接收的参数列表
let _args = [...args, ...params]
// 如果已经接收的参数个数还不够,继续返回一个新函数接收剩余参数
if (_args.length < len) {
return currying.call(this, fn, ..._args)
}
// 参数全部接收完调用原函数
return fn.apply(this, _args)
}
}
- 函数柯里化应用
// 柯⾥化之前
function checkByRegExp(regExp, str) {
return regExp.test(str)
}
// 校验手机号
checkByRegExp(/^1\d{10}$/, '15152525634');
checkByRegExp(/^1\d{10}$/, '13456574566');
checkByRegExp(/^1\d{10}$/, '18123787385');
// 校验邮箱
checkByRegExp(/^(\w)+(.\w+)*@(\w)+((.\w+)+)$/, 'fsds@163.com');
checkByRegExp(/^(\w)+(.\w+)*@(\w)+((.\w+)+)$/, 'fdsf@qq.com');
checkByRegExp(/^(\w)+(.\w+)*@(\w)+((.\w+)+)$/, 'fjks@qq.com');
// 柯⾥化之后
function checkByRegExp(regExp) {
return function(str) {
return regExp.test(str)
}
}
// 于是我们传入不同的正则对象,就可以得到功能不同的函数:
// 校验手机
const checkPhone = checkByRegExp(/^1\d{10}$/)
// 校验邮箱
const checkEmail = checkByRegExp(/^(\w)+(.\w+)*@(\w)+((.\w+)+)$/)
// 校验手机号
checkPhone('15152525634');
checkPhone('13456574566');
checkPhone('18123787385');
// 校验邮箱
checkEmail('fsds@163.com');
checkEmail('fdsf@qq.com');
checkEmail('fjks@qq.com');
- 柯⾥化的优缺点
优点:柯里化是一种"预加载"函数的方法,通过传递较少的参数得到一个已经记住了这些参数的新函数,某种意义上讲,这是一种对参数的"缓存",是一种非常高效的编写函数的方法
缺点:柯里化函数所最后生成的洋葱代码 h(g(f(x))),不易阅读
反柯⾥化
- 函数柯⾥化,是固定部分参数,返回一个接受剩余参数的函数,也称部分计算函数,目的是为了缩小适用范围,创建一个针对性更强的函数
- 而反柯⾥化,正好相反,为了接收更多参数,扩大适用范围,创建一个应用更广的函数,使原本只有特定对象才适用的函数,扩展到更多的对象
- 比如我们希望得到类数组对象,我们可以借鉴Array原型的push方法
我们希望可以类似这样使用
const obj = {};
Array.prototype.push.call(obj, 'one', 'two');
// {0: 'first', 1: 'two', length: 2}
假如,我们构造一个反柯⾥化函数,扩大对象的适用范围,可以如下操作
const push = Array.prototype.push.unCurrying();
push(obj, "first", "two");// {0: 'first', 1: 'two', length: 2}
接下来,我们来实现unCurrying这个函数
Function.prototype.unCurrying = function() {
const self = this;
return function () {
// 去掉arumengts的第一个参数,并返回第一个参数,因为我们要操作第一个参数,这就用到数组的shift 方法了
const obj = Array.prototype.shift.call(arguments);
return self.apply(obj, arguments);
}
}
const push = Array.prototype.push.unCurrying();
const obj = {};
push(obj, "first", "two");
函数组合(compose)
- 数组合是为了解决柯里化函数所最后生成的洋葱代码 h(g(f(x)))
- 组合函数相当于把一页页的洋葱贴起来,像拼积木
- 洋葱代码函数执行顺序是从里往外执行的,那么写成compse,那么执行顺序就为从右往左执行,看这洋葱代码,像不像Koa2的use执行
const compose = (f, g) => (x => f(g(x)));
const first = arr => arr[0];
const reverse = arr => arr.reverse();
const last = compose(first, reverse);
last([1, 2, 3, 4, 5]); // 5
-
特点:
-
- compose中只能组合接受一个参数的函数
-
- compose的数据流和参数顺序相反,比如自左而右的函数参数,调用的时候是从右至左。不喜欢这一点可以用pipe,它实现的功能和compose相同
-
compose实现
function compose(...funcs) {
let length = funcs.length;
if (length === 0) {
return (...args) => args;
}
if (length === 1) {
return funcs[0];
}
return funcs.reduce((a, b) => {
return (...args) => {
return a(b(...args));
}
})
}
- compose 满足结合律
let f = compose(f, g, h)
let associative = compose(compose(f, g), h) == compose(f, compose(g, h))
Point Free
把一些对象自带的方法转化成纯函数,不命名转瞬即逝的中间变量
例如:const f = str => str.toUpperCase.spit('');,使用了str做为中间变量,没有意义
- 不使用所要处理的值,只合成运算过程,可称为 "无值" 风格
const toUpperCase = str => str.toUpperCase();
const split = x => str.split(x);
const f = compose(split(''), toUpperCase);
f('abc def');
const addOne = x => x + 1;
const square = x => x * x;
const t = compose(square, addOne);
t(2); // 9
命令式代码和声明式代码
- 命令式代码的意思就是,我们通过编写一条又一条指令去让计算机执行一些动作,这其中一般都会涉及到很多繁杂的细节。
- 而声明式就要优雅很多了,我们通过写表达式的方式来声明我们想干什么,而不是通过一步一步的指示。
//命令式
let CEOs = [];
for(var i = 0; i < companies.length; i++)
CEOs.push(companies[i].CEO)
}
// 声明式
let CEOs = companies.map(c => c.CEO);
-
优缺点:
- 声明式代码,对于无副作用的纯函数,完全可以不考虑函数内部是如何实现的,专注于编写业务代码。
- 优化代码时,目光只需要集中在这些稳定坚固的函数内部即可
- 相反,不纯的函数代码会产生副作用或依赖外部环境,使用时,还要考虑这些不干净的副作用,容易脑抽
惰性求值、惰性函数
- 惰性函数就是比较懒得函数,求一次值,下一次不想再求值了
在命令式编程中,代码会按顺序执行,由于每个函数都有可能改动或依赖于外部的状态,因此必须顺序执行
假如同一个函数,未来在程序中会被大量调用,并且这个函数内部又有许多判断来检测函数,这样对于一个调用会浪费时间和浏览器资源,所有当第一次判断完成后,直接把这个函数改写, 比如:createXHR和事件绑定
export const addEvent = (() => {
if(document.addEventListener){
return function(type, element, func){
element.addEventListener(type, func, false);
};
}else if(document.attachEvent){
return function(type, element, func){
element.attachEvent('on'+type, func);
};
}else{
return function(type, element, func){
element['on'+type] = func;
};
}
})();
addEvent('click', divNode, handle);
高阶函数
- 函数当作参数,把传入的函数做一个封装,然后返回这个封装的函数,达到更高程度的抽象
- 高阶组件应用,HOC
//命令式
var add = function(a,b){return a + b;};
function math(fn, arr){return fn(arr[0],arr[1]);}
math(add, [1,2]);//3
闭包
- 函数式编程会充盈着大量的闭包
- 闭包是存在“堆”上的,因为当一个函数调用结束,栈上的调用帧被释放,但是堆上的作用域并不被释放
- 虽然栈的引用丢了,但是真正的函数对象还在,函数对象在的话,内部的的变量就还在,这些变量可能还会用的到。我们需要用一些技术手段拿到这些变量。这就是闭包要做的事情
- 闭包:就是用外层函数return出一个能访问到其变量的内层函数
尾声
函数式编程基础就先到这里,希望能先理解的基础上,再修习后面的进阶内容