JS的函数式编程范式
一、认识函数式编程
为什么学习函数式编程?学吧,不学干啥,js太原始了,得接收新事物,就很帅,里面的概念,学的晕乎乎,最直观的感受就是,套娃
- 函数式编程是随着React的流行受到关注的
- Vue3开始拥抱函数式编程
- 函数式编程可以抛弃this
- 打包过程中可以更好利用tree shaking过滤无用代码
- 方便测试,方便并行处理
- 有很多库可以帮助开发者进行函数式开发, lodash,underscore,ramda
函数式编程(Functional Programming, FP),FP 是编程范式之一,我们常听说的编程范式还有面向过程编程、面向对象编程。
- 面向对象编程的思维方式:把现实世界中的事物抽象成程序世界中的类和对象,通过封装、继承和多态来演示事物事件的联系
- 函数式编程的思维方式:把现实世界的事物和事物之间的联系抽象到程序世界(对运算过程进行抽象)
- 程序的本质:根据输入通过某种运算获得相应的输出,程序开发过程中会涉及很多有输入和输出的函数x -> f(联系、映射) -> y,y=f(x)
- 函数式编程中的函数指的不是程序中的函数(方法),而是数学中的函数即映射关系,例如:y= sin(x),x 和 y 的关系 相同的输入始终要得到相同的输出(纯函数) * 函数式编程用来描述数据(函数)之间的映射
1.函数式一等公民
我涉及到的前端知识,还停留在原生js上面,函数的调用涉及的入参,很少会考虑继续用函数当参数进行传递,
所以,学习函数式编程,首先要设定一个函数式一等公民的前提,函数可以传递,也可返回,优先级最高。
- 函数可以存储在变量里
- 函数可以作为参数
- 函数作为返回值
在javascript中函数是一个普通对象(可以通过new Function()),可以把函数存储在变量或数组中,他也可以最为另一个函数的参数和返回值,甚至在程序运行的时候通过new Function(alert(‘a’))来构造一个新的函数
2.高阶函数
高阶函数
- 可以把函数作为参数传递给另一个函数,
- 可以把函数作为另一个函数的返回结果
函数作为参数: 模拟foreach方法:
//高阶函数 function forEach (arr,fn) { for(let i = 0; i < arr.length; i++){ fn(arr[i]); } } //测试 let arr = [2,3,1,3,6]; forEach(arr,function(v){ console.log(v); });
模拟数组array的filter方法:
function filter (arr,fn) { let result = []; for(let i = 0; i < arr.length; i++){ if(fn(arr[i])){ result.push(arr[i]); } } return result; } let arr = [2,3,1,3,6]; let r = filter(arr,function(v){ return v % 2 === 0;//取偶数 }); console.log(r);
函数作为返回值:
//函数作为返回值 function makeFun(){ let msg = 'Hello World'; return function(){ console.log(msg); } } const fn = makeFun(); fn(); //也可以这样 makeFun()();
//模拟once,函数只执行一次 function once(fn){ let done = false;//是否执行 return function(){ if(!done){//没执行呢 done = true; //执行了 return fn.apply(this, arguments);//arguments,我的理解是function()里的参数,即使括号里没有,在调用传参,那么arguments就可以获取到 } } } let pay = once(function(money){ console.log('支付:' +money+ 'RMB'); }); pay(5); pay(5); pay(5); pay(5);
使用高阶函数的意义:
- 抽象可以帮助屏蔽细节,只需关注目标
- 高阶函数是用抽象通用的问题
3.常用的高阶函数模拟
对map,every,some方法的模拟

//模拟常用的高阶函数 map,every, some const map = (arr,fun) => { let result = []; for(let v of arr){ result.push(fun(v)); } return result; } // let arr = [1,2,3,4]; // arr = map(arr,v => v*v); // console.log(arr); const every = (arr,fun) => { let result = true; for(let i = 0; i < arr.length; i++){ result = fun(v); if(!result){ break; } } return result; } let arr1 = [11,12,14]; let r = map(arr1,v => v > 10); console.log(r); const some = (arr,fun) => { let result = false; for(let v of arr){ result = fun(v); if(result){ break; } } return result; } // let arr2 = [11,12,14]; // let r = map(arr2,v => v % 2 === 0); // console.log(r);
4.闭包
闭包(Closure):函数和其周围的状态的引用捆绑在一起形成闭包(说人话,好好的不学好),我理解,就是另一个作用域调用一个函数的内部函数并访问该函数作用域中的成员.
例如,在上述的例子,makeFun(); 里面的msg,在返回中用到, 还有once();中的done, 在返回函数中调用done,也就是外部作用域,对once的内部有引用,如果有引用,在once执行完之后,done不会被释放,延长了外部变量的作用范围
- 闭包的本质, 函数在执行的时候会放到一个执行栈上,当函数执行完毕后,会从栈上移除, 但是堆上的作用域成员,因为被外部引用不能释放,因此内部函数依然可以访问外部函数的成员
可以通过调试的方式,理解,闭包

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>closure</title> </head> <body > <script> function MakePower(power){ return function(number){ return Math.pow(number,power); } } let power2 = MakePower(2); let power3 = MakePower(3); console.log(power2(4)); console.log(power3(5)); </script> </body> </html>
开发者工具打开,对 letpower2=MakePower(2);打断点
Call Stack是调用栈,里面是匿名内部类,Scope是作用域,其中Closure就是闭包,跟参数相关的power为2依然存在,外部的Math.pow()调用外部的power
二、函数式编程基础
1.纯函数
纯函数:相同的输入永远会得到相同的输出,而且没有任何副作用
- 纯函数类似数学中的y = f(x)的关系
lodash是一个纯函数的功能库,提供了数组、数字、对象、字符串、函数等操作的一些方法
数组的slice和splice分别是:纯函数和不纯函数:
//纯函数和不纯 let arr = [4,5,6]; //纯函数 console.log(arr.slice(0,3)); console.log(arr.slice(0,3)); console.log(arr.slice(0,3)); //不纯函数 console.log(arr.splice(0,3)); console.log(arr.splice(0,3)); console.log(arr.splice(0,3));
结果
[ 4, 5, 6 ] [ 4, 5, 6 ] [ 4, 5, 6 ] [ 4, 5, 6 ] [] []
- 函数式编程不会保留计算中间的结果,所以变量是不可变
- 可以把一个函数的执行结果交给另一个函数去处理
2.lodash
简介和文档可以看官网,https://www.lodashjs.com/
演示一下lodash
//first last first reverse each includes find findIndex const _ = require('lodash');//引用lodash const arr = ["jack","tom","marry",'lucy']; console.log(_.first(arr)); console.log(_.last(arr)); console.log(_.toUpper(_.first(arr))); console.log(_.reverse(arr)); const r = _.each(arr,(v,ind) => { console.log(v,ind); }); console.log(r);
(1)纯函数
纯函数的好处:
- 可缓存 因为纯函数对相同的输入始终有相同的结果, 所以,可以将结果进行缓存 (可以提升性能,一个函数调用多次且耗时 , 可以使用纯函数在第一次执行的时候就缓存)
//缓存下来,可以使用lodash 的memoize方法 function getArea(r){ console.log(r); return Math.PI * r * r; } let getAreaWithMemory = _.memoize(getArea); console.log(getAreaWithMemory(4)); console.log(getAreaWithMemory(4)); console.log(getAreaWithMemory(4)); console.log(getAreaWithMemory(4));
结果:从结果可以看出,getArea方法只执行了依一次
4 50.26548245743669 50.26548245743669 50.26548245743669 50.26548245743669
模拟memoize方法
//模拟memoize方法 const memoize = function(fn){ let cache = {}; return function(){ let key = JSON.stringify(arguments); cache[key] = cache[key] || fn.apply(fn, arguments); return cache[key]; } } let getAreaWithMemory1= memoize(getArea); console.log(getAreaWithMemory1(4)); console.log(getAreaWithMemory1(4)); console.log(getAreaWithMemory1(4)); console.log(getAreaWithMemory1(4));
- 可测试; 纯函数让测试更方便
- 并行处理: 在多线程下并行操作内存数据很可能出现意外,而纯函数不需要访问共享内存数据,所以在并行环境下任意运行 纯函数
(2)副作用
纯函数: 对于相同输入永远会得到相同的输出, 而且没有任何可观察的副作用
通过下面可以看出,副作用让一个函数变得不纯,纯函数的根据相同输入返回相同输出,如果函数依赖于外部的状态就无法保证输出相同,就会带来副作用。例:配置文件,数据库,获取用户的输入等。。。
//不纯 let min = 18; function checkAge(age){ return age >= min; } //纯函数, (有硬编码,后续可以通过柯里化解决) function checkAge(age){ let min = 18; return age >= min; }
所有的外部交互都有可能带来副作用,副作用也使得方法通用性下降不适合扩展和可重用性, 同事副作用会给程序带来安全隐患以及不确定性,但是副作用不可能完全进制,尽可能控制在可控范围内
可以通过柯里化来减少副作用
(3)柯里化
通过柯里化的方式,解决上述问题
function checkAge(age){ return function(min){ return age >= min; } } let checkAge18 = checkAge(18); let checkAge19 = checkAge(19); console.log(checkAge18(20)); console.log(checkAge18(10));
柯里化(Currying)
- 当一个函数有多个参数的时候先传递一部分参数调用它(这部分参数以后不变)
- 然后返回一个新的函数接收剩余参数,返回结果
lodash中的柯里化函数
- _.curry(func)
- 功能:创建一个函数,该函数接收一个或多个 func 的参数,如果 func 所需要的参数都被提 供则执行 func 并返回执行的结果。否则继续返回该函数并等待接收剩余的参数。
- 参数:需要柯里化的函数
- 返回值:柯里化后的函数
function getSum (a,b,c) { return a + b + c ; } const curry_getSum = _.curry(getSum); console.log(curry_getSum(1,2,3)); console.log(curry_getSum(1,2)(3)); console.log(curry_getSum(1)(2,3));
结果发现,三次结果展示一样,这就是柯里化
柯里化案例
const match = _.curry((reg,str)=> str.match(reg)); const haveSpace = match(/\s+/g);//匹配空格 console.log(haveSpace("helloo")); console.log(match(/\s+/g,'helloworld')); const filter = _.curry((fun,arr) => arr.filter(fun)); console.log(filter(haveSpace, ['Jhon david', 'Jhonny_hha']));
柯里化模拟
//柯里化原理,重写lodash的curry()方法 function sum(a,b,c){ return a+b+c; } const curried = curry(sum); console.log(curried(1,2,3)); console.log(curried(1)(2,3)); console.log(curried(1,2)(3)); function curry(func){ return function curriedFun(...args){ //判断实参和形参个数 if(args.length < func.length ){ return function(){ //arguments是伪数组,需要转换一下 return curriedFun(...args.concat(Array.from(arguments))); } } return func(...args); } }
总结:
- 柯里化可以让我们给一个函数传递较少的参数,得到一个已经记住了某些固定参数的新函数
- 是对函数参数的一种缓存
- 让函数变得更灵活, 让函数的粒度更小
- 可以把多元函数转换成一元函数,可以组合使用函数产生强大的功能
(3)函数组合
纯函数和柯里化很容易写成洋葱代码,就像套娃一样,一层一层又一层, 而函数组合可以让我们把细颗粒的函数重新组合成一个新的函数
可以把函数组合,想象成一个管道, 我们把管道拆分成多个小管道, 多了中间运算过程, 不管要处理的函数对复杂, 最终得到结果.
fn = compose(f1, f2, f3);
b = fn(a);
函数组合compose: 如果一个函数要经过多个函数处理才能得到最终值,这个时候,可以把中间过程的函数合并成一个函数
- 函数就像是数据的管道, 函数组合将这些管道连接起来,让数据穿过多条管道,得到最终结果.
- 函数组合默认是从右往左执行的
组合函数的演示
//函数组合 洋葱代码 //可以想象成一个管道,很长,把他切成n份,依次传值,不在乎中间过程 const compose = function (f, g){ //默认从右往左执行 return function(value){ return f(g(value)) } } function first(array){ return array[0]; } function reverse(array){ return array.reverse(); } const last = compose(first,reverse); console.log(last([1,2,3,4]));
lodash的组合函数,flow()从左往右和flowRight()从右往左
function first(array){ return array[0]; } function reverse(array){ return array.reverse(); } const toUpper = s => s.toUpperCase();//使用箭头函数 const last = _.flowRight(toUpper,first,reverse); console.log(last(['one','two','three']));
对组合函数的原理进行模拟, 会使用reduce方法
var arr = [1, 2, 3, 4]; var sum = arr.reduce(function(prev, cur, index, arr) { //console.log(prev, cur, index); return prev + cur; }) //console.log(arr, sum); //开始模拟 const compose = function(...args){ return function(value){ //pre:上次值,value就是初始值,fn对应的当前要处理的 return args.reverse().reduce(function(pre,fn){ return fn(pre); },value) } } //使用箭头函数 模拟函数组合 const compose = (...args) => v => args.reverse().reduce((pre,fn) => fn(pre),v);
函数组合 要适合组合律 例如 a+b+c = a+(b+c) = (a+b)+c,最后结果要一致
下面是怎么对函数组合进行调试,
//NEVER GIVE UP 转换 never-give-up let str = 'NEVER GIVE UP'; //函数组合调试 const trace = _.curry((tag,v)=>{ console.log(tag, v); return v; }); const split = _.curry((seq,str) => _.split(str,seq)); //toLower const join = _.curry((seq,array) => _.join(array,seq)); //_.toLower执行后,返回的"never,give,up",字符串,而要求需要返回的数组 const map = _.curry((fn, array) => _.map(array, fn)); const f1 = _.flowRight(join('-'),trace('toLower之后'), _.toLower ,trace('split之后'),split(' ')); console.log(f1(str)); const f = _.flowRight(join('-'),trace('toLower之后'), map(_.toLower) ,trace('split之后'),split(' ')); console.log(f(str));
(4)lodash/fp模块
- odash的fp模块提供了使用的对函数式编程友好的方法,
- 提供了不可变的已经柯里化的, 函数优先,数据滞后的方法
案例,fp是引入的fp模块,对上述的函数 NEVER GIVE UP 转换 never-give-up 进行改造
//使用lodash/fp,执行上述的问题 let str = 'NEVER GIVE UP'; const f = fp.flowRight(fp.join('-'),fp.map(fp.toLower),fp.split(' ')); console.log(f(str));
(5)PointFree
- 不需要指明处理的数据
- 只需要合成运算的国成
- 需要定义一些辅助的基本运算函数
// 非Point Free 模式 // Hello World => hello_world function f(word) { return word.toLowerCase().replace(/\s+/g, "_"); } // Point Free 模式 const fp = require("lodash/fp"); const f = fp.flowRight(fp.replace(/\s+/g, "_"), fp.toLower); console.log(f('Hello World'));
案例:
//将每个单词的首字母提取出来,在大写,在用点作为分隔符 //never give up ---> N. G. U //[never, give, up] //[NEVER, GIVE, UP] const trace = _.curry((tag,v) => { console.log(tag,v); return v; }); // const firstWordtoUpper = fp.flowRight(fp.join(". "),trace("f之后"),fp.map(fp.first) ,trace("t之后"),fp.map(fp.toUpper),trace("s之后"),fp.split(" ")); //上述,需要执行两次map的遍历,影响性能 const firstWordtoUpper = fp.flowRight(fp.join(". "),fp.map(fp.flowRight(fp.first,fp.toUpper)),fp.split(" ")); console.log(firstWordtoUpper('never give up'));
三、函子Functor
什么是 Functor
- 容器:包含值和值的变形关系(这个变形关系就是函数)
- 函子:是一个特殊的容器,通过一个普通的对象来实现,该对象具有 map 方法,map 方法可以运行一个函数对值进行处理(变形关系)
下面 这就是函子:会对外提供一个map的方法
class Container{ constructor (value){ this._value = value; //_开头的成员都是私有的 } map(fn){ return new Container(fn(this._value));//返回新的函子 } } console.log('西西'); //每次用都要new 一次对象 let c = new Container(5) .map(x => x + 1) .map(x => x * x); console.log(c);
上面的案例发现,每次使用,都需要new 对象,可以把他封住一下
//改进一下,将对象封装一下 class Container{ //定义静态方法,对外提供of方法,调用就会new对象 static of(value){ return new Container(value); } constructor (value){ this._value = value; //_开头的成员都是私有的 } map(fn){ return Container.of((fn(this._value)));//返回新的函子 } }
- 函数式编程的运算不直接操作值,而是由函子完成
- 函子就是一个实现了map契约的对象
- 可以把函子想象成一个盒子,这个盒子里封装了一个值
- 想要处理盒子中的值,要给盒子的map方法传递一个处理值的函数(纯函数),在由这个函数对值进行处理
- 最终,map方法返回一个包含新值的盒子(函子)
那么如果出现传值为null或者undefined的情况出现,该怎么办?
1.MayBe函子
为了解决上述,传值为null或undefined的情况,mayBe函子作用就是可以对外部的空值情况做处理(控制副作用在允许的范围)
//MayBe函子,对可能存在null,undefined进行判断 class MayBe{ static of(value){ return new MayBe(value); } constructor (value){ this._value = value; //_开头的成员都是私有的 } map(fn){ return this.judge() ? MayBe.of(null) : MayBe.of(fn(this._value));//返回新的函子 } judge(){ return this._value === null || this._value === undefined; } } // let b = MayBe.of('hello world') // .map(x => x.toUpperCase()); // console.log(b); let b = MayBe.of(null) .map(x => x.toUpperCase()); console.log(b);
但是, 虽然不报错了,但是具体那个步骤null,不知道
2. Either函子
- Either 两者中的任何一个,类似于 if…else…的处理
- 异常会让函数变的不纯,Either 函子可以用来做异常处理
class Left { static of(value) { return new Left(value); } constructor(value) { this._value = value; } map(fn) { return this; } } class Right { static of(value) { return new Right(value); } constructor(value) { this._value = value; } map(fn) { return Right.of(fn(this._value)); } }
- 可以通过Either函子来处理异常,并且记录异常信息
function parseJSON(str) { try { return Right.of(JSON.parse(str)); } catch (e) { return Left.of({ error: e.message }); } } let r = parseJSON('{name: zs}').map((x) => x.name.toUpperCase()); console.log(r);
3.IO函子
- IO函子中的_value是一个函数,这里把函数作为值处理
- IO函子可以把不纯的动作存储到_value中, 延迟执行这个不纯的操作(惰性执行), 包装当前的操作
- 把不纯的操作交给调用者来处理
const fp = require('lodash/fp'); class IO { static of (value) { return new IO(function () { return value; }); } constructor (fn) { this._value = fn; } map (fn){ //将当前的value,和传入的fn组合一个新的函数 return new IO( fp.flowRight(fn,this._value) ); } } let r = IO.of(process).map(p => p.execPath); console.log(r); console.log(r._value());
4.Task异步执行
案例,获取package.json的带有version的字段
const fs = require('fs');//读取文件的 const { task } = require("folktale/concurrency/task"); const { flowRight,split, find } = require('lodash/fp'); function readFile (fileName) { return task(resolver => { fs.readFile(fileName,'utf-8',(err,data) => { if(err) resolver.reject(err); resolver.resolve(data); }) }); } readFile('package.json') .map(split('\n')) .map(find(v => v.includes('version'))) .run() .listen({ onRejected: err => { console.log(err); }, onResolved: value => { console.log(value); } });
5.Pointed 函子
- Pointed 函子是实现了 of 静态方法的函子
- of 方法是为了避免使用 new 来创建对象,更深层的含义是 of 方法用来把值放到上下文Context(把值放到容器中,使用 map 来处理值)
在上述提到的使用静态方法避免new对象
class Container { static of (value) { return new Container(value) } …… } Container.of(2).map(x => x + 5)
6.Monad(单子)
IO函子存在的问题.涉及多层嵌套,需要反复的.点.点方法
// IO函子 const fp = require("lodash/fp"); const fs = require("fs"); class IO { static of(value) { return new IO(function () { return value; }); } constructor(fn) { this._value = fn; } map(fn) { return new IO(fp.flowRight(fn, this._value)); } } // 模拟cat函数(先读取文件然后打印文件) let readFile = function readFile(fileName) { return new IO(function () { return fs.readFileSync(fileName, "utf-8"); }); }; let print = function print(x) { return new IO(function () { console.log(x); // 将接收到的IO函子再返回出去,从而可以进行链式调用 return x; }); }; let cat = fp.flowRight(print, readFile); // IO(IO(x)); cat实际上是一个嵌套的函子 // 第一个._value调用的print函数,因为调用由外部开始 第二个._value调用的是readFile let r = cat("package.json")._value()._value();
发现,如果想要获取文件中的内容,需要在.value()一次,才可获取
- Monad 函子是可以变扁的 Pointed 函子,IO(IO(x))
- 一个函子如果具有 join 和 of 两个方法并遵守一些定律就是一个 Monad
// monad 函子 class IO { static of(value) { return new IO(value); } constructor(fn) { this._value = fn; } map(fn) { return new IO(fp.flowRight(fn, this._value)); } join() { return this._value(); } flatMap(fn) { return this.map(fn).join(); } } let r = readFile("package.json").map(fp.toUpper).flatMap(print).join(); console.log(r);
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!