为什么要使用函数式编程
- 越来越多的前端框架都在使用函数式编程,学习函数式编程可以更好的学习他们的源码
- 使我们代码更简洁,看起来更高级一些。
- 函数式编程可以抛弃
this
什么是函数式编程
函数式编程是一种编程范式之一,是一种编程风格,它和面向对象编程是并列的关系。
常见的编程范式还有面向过程编程,面向对象编程
- 面向过程编程
按照步骤一步一步实现功能 - 面向对象编程思维模式
将现实的事物抽象成程序中的类和对象,通过封装、继承、多态来演示事物事件的联系 - 函数式编程思维模式
把现实世界中的事物和事物之间的联系抽象到程序世界(对运算过程进行抽象)- 程序的本质:根据输入 通过某种运算获得相应的输出。
函数相关概念
函数是一等公民
- 函数可以存储在变量中
const fn = function() {} - 函数可以作为参数
forEach(function() {}) - 函数可以作为返回值
function Foo() { return function() {} }
在 JavaScript 中,函数就是一个普通的对象,所以可以存储在变量中,可以作为参数,可以作为函数的返回值
高阶函数
高阶函数就是 函数作为参数、函数作为返回值 的体现
闭包
- 闭包:函数和其周围的状态(词法环境)的引用捆绑在一起形成闭包
- 可以在另一个作用域中调用一个函数 fn 的内部函数 gn,并访问到 fn 的内部成员
// 函数作为返回值function foo() {let msg = 'hello world'return function() {console.log(msg)}}const fn = foo();fn();
- 闭包的本质:函数在执行的时候会放到一个执行栈上,当函数执行完毕之后会从执行栈上移除;但是堆上的作用域成员因为被外部引用不能释放,因此内部函数依然可以访问外部函数的成员
函数式编程基础
lodash
lodash 是一个一致性、模块化、高性能的 JavaScript 实用工具库
lodash 中的 fp 模块
lodash 中的 fp 模块提供了对函数式编程友好的方法
- 提供了 不可变的 自动柯里化
const fp = require('lodash/fp')const f = fp.curry(function(a, b, c) {return a + b + c});f(1, 2, 3)f(1, 2)(3)f(1)(2)(3)
- 如果需要柯里化的函数有多个参数,遵循 函数优先 数据在后
纯函数
对于纯函数,相同的输入始终会得到相同的结果
它不是程序中的函数,而是数学中的函数,它是一个值与另一个值的映射关系的体现
function getArea(r) {return Math.PI * r * r;}getArea(2)
柯里化
函数柯里化可以对函数 fn 进行降元,把多元函数(多个参数)降为一元函数(一个参数),并返回一个函数 f。
柯里化函数会对先传入的参数进行‘缓存’,当调用函数f 时传递的参数等于 函数fn 所需参数时,才会执行函数 fn
// 函数柯里化原理// 函数柯里化:它是一个函数,这个函数接收一个函数 func 作为参数,// 并返回一个函数,返回的函数接收 func 所需的参数,// 直到将 func 的所需参数接收齐后,执行 func 函数function curry(func) {// 不确定参数个数。可用 resetreturn function curried(...args) {// 参数个数小于 func 所需的参数个数(func.length: 代表这个函数形参的个数)if (args.length < func.length) {// 将参数合并,拆分后继续调用return function() {return curried(...args.concat(Array.from(arguments)));};}// 参数个数等于 func 所需参数的时候,执行 func,并将结果返回return func(...args);};}const add = curry(function(a, b, c) {return a + b + c;});console.log(add(1, 2, 3));console.log(add(1, 2)(3));console.log(add(1)(2)(3));
管道
把函数看成一个管道,传入x,经过管道处理后得到结果y
函数组合
函数组合,把多个一元函数进行组合,返回一个新的函数。
函数组合中的函数,默认从右到左执行
// 函数组合:将多个细粒度较小的纯函数组合成一个函数,组合是默认从右到左执行function compose(...args) {// 返回一个函数,该函数接收用于操作所需的参数return function(value) {// 返回执行结果return args.reverse().reduce(function(acc, fn) { // acc: 上一次的执行结果 fn: 当前函数return fn(acc);}, value); // value: acc 的初始值};}// 纯函数:反转数组function reverse(array) {return array.reverse();}// 纯函数:取出数组第一个元素function getFirst(array) {return array[0];}// 组合函数let comp = compose(getFirst, reverse);const array = [1, 2, 3, 4];console.log(comp(array)); // 4
函子
函子是一个实现了 map 方法契约的对象,
它可以处理函数式编程中运算过程的问题(函数式编程就是对运算过程进行抽取);
函数式编程不直接操作值,而是由函子进行操作。
也就是说我们可以使用 函子来帮助我们进行 函数式编程中,对值进行操作的过程
函子中存储一个值,这个值就是我们要处理的一个值, 如果想要处理这个值,只能通过 map 方法传入一个处理函数来处理(这个处理函数必须是纯函数);
并返回一个包含处理完成的 函子 对象
我们可以把函子看成一个容器,这个容器中存放了一个值,和值的变化关系(函数)。这个函数是 map,它会执行一个函数来进行对这个值的操作
Functor 函子:
// 一个容器class Container {constructor(value) {// 一个值,这个值是私有的。不对外公开this._value = value;}// 一个操作值的方法,该方法接收一个函数,使用这个函数对值进行操作map(fn) {// 返回一个包含了操作后的值 的一个容器,以便后续的操作return new Container(fn(this._value));}}// 使用函子let r = new Container(5).map(v => v + 2) // 这个v就是函子中的值,我们对其进行操作; v=7.map(v => v * v) // 上一个 map 操作后会返回一个包含处理过的值的容器,所以可以继续调用 map 操作值 v=49console.log(r); // 最终的结果就是 包含了最终map处理过的值 的容器(函子)// Container { _value: 49 }
为了方便调用 Container 函子,可以在 Container 中创建一个 静态方法,用来返回 Container 实例
class Container {static of(value) {return new Container(value);}// ...map(fn) {// return new Container(fn(this._value));return Container.of(fn(this._value));}}const r = Container.of(5).map(v => v + 2).map(v => v * v);console.log(r); // Container { _value: 49 }
上面的函子,如果传入的是 null,就会报错
// 这个 null 可能是接口数据const r = Container.of(null).map(v => v.toUpperCase()); // v是null,对 null 进行属性调用,就会报错// TypeError: Cannot read property 'toUpperCase' of null
MayBe 函子:将函子的副作用(当传入空时(null、undefined等))控制在允许范围内
class MayBe {static of(value) {return new MaBe(value);}constructor(value) {this._value = value;}map(fn) {return this.isEmpty() ? MayBe.of(null) : MayBe(fn(this._value));}isEmpty() {return this._value === null || this._value === undefined;}}const r = MayBe.of(5).map(v => null) // 直接返回一个 null.map(v => v.toString()); // 对 null 进行处理console.log(r); // 输出: MayBe { _value: null }
可以看到,经过空值判断将副作用控制在允许范围内。
但是这样会有问题,如果出错了,我们不知道是哪一个步骤出的错。
所以我们需要对这种异常的情况进行处理
Either 函子:两者之一,类似 if…else 的情况
比如我们要对一串字符串数据进行 JSON 转化。有可能这个数据不是 JSON 格式的,转化的时候就会出错
function parseJson(str) {// 对转化过程进行一场捕获try {return JSON.parse(str);} catch(err) {console.log(err);}}
我们将这种转化过程 使用函子来抽取
- Either 函子需要声明两个对象,一种用于处理正常情况,一种用于处理异常情况
// Left: 用于处理异常情况的函子class Left {static of(value) {return new Left(value);}constructor(value) {this._value = value;}map(fn) {// 直接返回自身对象,方便查看对象信息return this;}}// Right: 用于处理正常情况的函子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 {Right.of(JSON.parse(str));} catch(err) {Left.of({ error: err.message })}}const r = parseJson('{ name: zs }');// Left { _value: { error: 'Unexpected token n in JSON at position 2' } }const r2 = parseJson('{"name": "zs"}');// Right { _value: { name: 'zs' } }
前面提到的函子都是需要纯函数来处理的,如果遇到不是纯函数的情况要怎么处理呢?
IO 函子: 用于处理不纯的用户操作
- IO 函子跟前面的函子不一样:它的 value 是函数,这里是把函数当作 value 来处理的
- IO 函子可以把不纯的动作存储到 _value 中,_value 中存储的是函数,在函子内部并没有调用这个函数
通过 IO 函子,我们可以延迟 执行这个不纯的操作(相当于惰性操作), - 把不纯的操作交给调用者处理
// 组合函数,将多个函数组合成一个全新的函数const fp = require('lodash/fp');class IO {// IO 函子的 of 方法跟其他函子不一样,它接收一个数据 valuestatic of(value) {// 并声明返回一个 IO 实例,return new IO(function() {// 最终 IO 函子返回一个值, 但是这个值已经被 _value 包装过, 并不是直接返回, 所以需要手动调用 _value() 来获取这个 valuereturn value;})}// IO 函子的构造器接收一个函数, 把 函数当成 value 来处理constructor(fn) {// 将 函数 保存到 _valuethis._value = fn;}// map 方法接收一个函数map(fn) {// 将 _value 和 fn 组合之后的新函数传递给 IO 进行实例化, 并返回一个全新的 IO 实例.return new IO(fp.flowRight(fn, this._value));// this._value 是一个函数, 它包装了一个数据 value// 它会被作为 fn 的参数传递到 fn 函数中// 由 fn 处理这个数据 value}}const r = IO.of('hello world').map(function(x) { // map中 传入的函数 它接收到的参数x 就是of传入的'hello world'// 将数据转为大写return x.toUpperCase();});console.log(r); // IO { _value: [Function] }// 执行 _.value()console.log(r._value()); // HELLO WORLDconst r2 = IO.of(process) // process: node 环境中 进程的意思.map(p => p.execPath); // 这里的p: 就是我们前面传入的 process. 他是经过 IO 中的 _value 返回出来的.console.log(r2); // IO { _value: [Function] }console.log(r2._value()); // C:\Program Files\nodejs\node.exe
IO 函子与其他函子最大的区别就是:
- 它的 _value 是一个函数。它把不纯的操作(比如IO、网络请求、DOM)包裹到一个函数内,从而延迟这个操作的执行。
所以我们认为,IO 包含的是被包裹的操作的返回值 - IO其实也算是惰性求值
- IO负责了调用链积累了很多很多不纯的操作,带来的复杂性和不可维护性
IO 函子不纯的操作的例子
// 调用 IO.of 并传入 hello worldconst r = IO.of('hello world')// 第一次进行 map 操作, 接收到的参数x: hello world.map(x => {// 返回一个 Promisereturn new Promise(resolve => {// 在两秒后 对 x 拼接字符串, 然后 resolvesetTimeout(() => {resolve(x + ' world wilde web');}, 2000);});})// 第二次 map, 接收到的参数x: 是上一次 map 返回的 Promise 状态.map(x => {// 只有等待 上一次 map 操作 resolve 了数据, 才进行下一步操作return x.then(res => { // res 就是上一次 map 操作过后的结果// 对结果进行大写转换return res.toUpperCase();// 也可以使用 Promise.resolve() 返回数据// return Promise.resolve(res.toUpperCase());});});// r 是 IO 对象, 其 _value 中存储着对数据处理后的结果.// 而r._value().then(res => {console.log('最终结果', res);});// 最终结果 HELLO WORLD WORLD WILDE WEB
IO 函子可以有效的处理 不纯操作
task 函子: 用于处理异步执行的操作
使用 falktale 中的 task 来执行异步操作.
task 是函数, 它接收一个操作函数, 并返回一个 Task 函子
var task = function task(computation) {return new Task(computation);};
使用 task 来执行文件读取任务
// 引入文件模块const fs = require('fs');// 引入 task 模块const { task } = require('folktale/concurrency/task');// 声明一个方法, 用于读取文件function readFile(filename) {// task 函数接收一个 操作函数 computation, computation 接收一个参数: 执行器对象.// 如果执行成功 可以调用 执行器.resolve(); 失败调用 执行器.reject();return task(resolver => {// 读取文件fs.readFile(filename, 'utf8', (err, data) => {// 异常if (err) resolver.reject(err);// 成功. 将结果返回resolver.resolve(data)})});}// 读取 package.json文件readFile('package.json')// 并没有立即执行读取文件的操作. 因为 readFile 函数返回的是一个 Task 函子, 需要手动触发读取文件操作// run() Task 函子中启动任务的执行器.run() // 执行 读取文件操作// 对于执行结果, 需要在 run 后面使用 listen 进行监听// listen 接收一个对象参数, 该对象涵盖了操作事件.listen({onResolve: value => {console.log(value);},onReject: err => {console.log(err);}});
如果需要对读取到的文件进行操作. 可以在 map 方法中执行操作
const { split, find } = require('lodash/fp');// 读取 package.json 文件, 并获取 version 的值readFile('package.json')// map 方法返回一个新的 Task 函子// 这个函子先执行了 run 方法, 执行 去读取文件的操作, 然后再对数据进行处理// 使用 lodash 中的 分割函数, 将数据 以换行符进行分割.map(split('\n'))// 经过前面的分割操作, 次数数据已经是数组了// 使用 find 查找数组中含有 version 的元素.map(find(x => x.includes('version'))) // find 函数接收一个参数, 这个参数就是数组中的每一个元素, find 寻找包含 version 的元素.run() // run, 开始执行 task 任务. 只在需要的地方执行一次即可// map 方法只能在 run 前面执行// .map(res => { // TypeError: readFile(...).map(...).run(...).map is not a function// console.log('map2', res);// return res;// });// listen 监听执行事件, 里面包含成功失败等事件. 它只能在 run 后面执行.listen({onResolve: value => {console.log(value);},onReject: err => {console.log(err);}});// "version": "1.0.0",
pointed 函子
pointed 函子其实就是 声明了 of 方法的 函子.
of 的实现是为了避免我们经常使用 new 去创建对象, 使代码看起来像面向对象
of 方法是用来把 值 放在 context 上下文中(把值放到容器中, 然后使用 map 方法来处理值)
class Container {// 有 of 方法, 所以这个函子是 pointed 函子// of 方法的作用是 帮我们把值包裹在一个新的 函子 里面, 并返回. 这个返回的结果就是上下文static of(value) {return new Container(value);}...}// 调用 of 的时候, 获得一个上下文Container.of(2)// 在上下文中处理数据.map(x => x + 5)
IO 函子的调用问题
const fs = require('fs');const fp = require('lodash/fp');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));}}function readFile(filename) {return new IO(function() {console.log('readFile');return fs.readFileSync(filename, 'utf-8');});}function print(x) {return new IO(function() {console.log('print');return x;});}// cat 读取文件const cat = fp.flowRight(print, readFile);// cat此时是一个嵌套的 IO, IO(IO(x)). 假设 readFile返回的IO为 IO1, print返回的IO为 IO2// 因为限制新了 readFile, 它返回了一个IO1, 并将这个 IO1 传递给了 print// 所以 print 函数中的参数 x 就是 IO1. 而 print 也返回了一个 IO// 这个 IO 的回调函数里面return IO1// 所以嵌套 IO 中, 外层的 IO 是 IO2, 里面的 IO是 IO1// const r = cat('package.json');// console.log(r); // IO { _value: [Function] }/*IO2 里面嵌套了 IO1. 可以看成一个嵌套的 IO 对象{tag: 'print 返回的 IO'_value: function() {return {tag: 'readFile 返回的 IO',_value: function() {return fs.readFile(...)}}}}第一次调用 _value() 就是执行了 print 返回的 IO 函子第二次调用 _value() 就是执行了 readFile 返回的 IO 函子*/const r = cat('package.json')._value()._value();console.log(r);
控制台打印结果
print # 调用第一个 _value() 的输出readFile # 调用第二个 _value() 的输出{ # 输出读取文件的结果"name": "demo","version": "1.0.0","description": "","main": "01-函数式编程.js","scripts": {"test": "echo \"Error: no test specified\" && exit 1"},"keywords": [],"author": "","license": "ISC","dependencies": {"folktale": "^2.3.2","lodash": "^4.17.19"}}
函子出现了嵌套, 调用起来特别不方便
比如想要执行开始执行 读取文件操作, 需要 一直 ._value()._value()
Monad 函子: 解决函子嵌套问题
Monad 函子是可以 变扁 的 Pointed 函子
变扁: 可以将解决嵌套函子问题
如果函数发生了嵌套, 可以使用 函数组合来解决; 如果函子发生了嵌套, 可以使用 Monad 来解决
如果一个函子同时具有 join 和 of 两个方法, 并遵守一些定律, 这个函子就是 Monad 函子
如果想要合并一个函数, 这个函数返回一个值, 使用 map 方法
如果想要合并一个函数, 这个函数返回一个函子, 使用 flatMap 方法
of
静态方法,返回 IO 函子,便于使用
static of(value) {return new IO(function() {return value;});}
join
当使用 IO 函子的时候,需要传入一个函数, 如果传入的函数 返回了一个函子, 这个时候可以使用 Monad 函子,
通过调用 join 方法调用 this._value() 可以返回一个函子, 这样就可以解决函子嵌套的问题
join() {return this._value()}
flatMap
在使用 Monad 函子的时候,经常将 map 和 join 联合起来一起使用
因为 map 方法接收一个函数 fn,map 的作用就是将这个函数和当前的 _value 组合起来,它返回一个新的函子。
map 在组合函数的时候,它最终也会返回一个函子。
const fs = require('fs');const fp = require('lodash/fp');// 如果一个函子中 同时具有 of 和 join 两个方法, 就是 Monad 函子class IO {// of 方法: 返回一个函子static of(value) {return function() {return value;};}constructor(fn) {this._value = fn;}map(fn) {// 返回一个函子, 并将 当前的 _value 和 fn 组合成一个新的函数传递进去// 此时 constructor 中 this._value 就是(this._value 和 fn)合并后的 新函数return new IO(fp.flowRight(fn, this._value));}join() {// 返回对 this._value 的调用// 因为在 Monad 中, 我们传入的函数(constructor 中传入的函数), 最终会返回一个函子// 所以 调用 this._value 会返回一个函子// 比如: function a() { return new IO(function(x) { return x }) }// 此时 函数 a 中返回了一个函子// 所以在这里, 执行了 this._value() 之后, 会返回一个函子.return this._value();// 此时的 _value 是 map 方法中合并后的函数,fp.flowRight(fn, this._value)// 调用 this._value(), 会先执行 fp.flowRight 中的 this._value, 然后再将执行结果传给 fn, 并执行 fn}// 如果传入的函数返回的是函子,则调用 flatMap// flatMap 调用 map,所以需要一个函数flatMap(fn) {// 1. 调用 map 的时候,将 _value 和 fn 进行合并// map 在组合这些方法的时候,这个函数最终也会返回一个函子,就会形成一个嵌套的 IO 函子// 2. map 返回了一个 新的IO函子// 新的 IO 函子中包裹的函数 _value,最终也会返回一个函子,// 4. 将 join 后的结果(函子)返回return this.map(fn)// 3. 调用 join 方法,把 _value 变扁.join();}}// 读取文件的函数,它依赖外部环境,所以是不纯的操作function readFile(filename) {// 为了保证当前操作是纯的,// 不直接去读取文件// 而返回一个 IO 函子// 根据相同的输入,获得的是一个固定的内容:一个 IO函子return new IO(function() {// 将读取文件的操作封装到 IO函子里面去// 读取文件, 并将结果返回return fs.readFileSync(filename, 'utf-8');});}function print(x) {// 返回一个 IO 函子return new IO(function() {return x;});}// 调用 readFile 函数,这个函数返回一个 IO 函子// IO 函子中包裹着读取文件的操作const r = readFile('package.json') // 返回了一个 IO 函子,里面包裹了读取文件的操作// 如果要合并的函数返回的是值,则调用 map 方法.map(fp.toUpper)// 将读取文件的操作 和 打印的操作合并// 因为要合并的函数中返回的是函子,所以使用 flatMap 方法.flatMap(print) // 由 readFile 返回的IO函子调用 flatMap,并传入 print 会和前面包裹的 读取文件的操作 合并.join(); // 执行 _valueconsole.log(r);
