篇一:大前端之函数式编程笔记

1,943 阅读8分钟

文章输出主要来源:拉勾大前端高新训练营(链接)。
啊啊啊,没错,我又加入了拉勾的前端大课,这毕业一年,天天买课,钱也攒不下,那就都花了叭......以后再赚回来

1. 对前端来说函数式的优势

  1. React16.8+ 与 vue3 两大框架拥抱函数式编程,为我们展示了函数式在实际工程方面的一种应用形式,使得函数式在前端领域形成一股潮流势力
  2. 函数式编程使得代码单元相比面向对象来说更加独立,在tree shaking过程中更便于打包工具对未使用的代码进行过滤,对项目体积有一定的优化。
  3. 函数式编程以函数为代码单元,相比于面向对象的方式减少了对this的控制,对于js这种this指向可能会发生改变的语言,一定程度上减轻了开发人员对于this指向问题的困扰。
  4. 函数式有利于单元测试。
  5. JavaScript天生对函数式友好,社区很多库可以帮助我们进行函数式开发:lodash,underscore,ramda

函数式与面向对象的讨论

函数式与面向对象作为两种不同的开发风格,虽然奉行的理念有所差异,但两者并非非黑即白、有你无我的存在,在适合的场景可以使用更为适合的技术,两者也可以共同使用,相互取长补短。

2. 函数式编程(Functional Programming, FP)概念

函数式编程与面向对象、面向过程等方式都是使用代码解决现实问题的形式。

面向对象对现实中的类和事物进行抽象,通过封装、继承、多态表示事物之间的联系。在面向对象的世界中,一切皆对象,js作为一种支持面向对象的语言,当然也可以将一切作为对象,但在实际开发中,面向对象有时候并不是全能的,在tree shaking过程中,无用的方法不能被有效判断出来,从而无法达到最佳的无用代码优化效果。

函数式编程的思维方式:

  1. 函数式编程是对运算过程的抽象。
  2. 函数式编程中的函数并非程序中的函数或方法,而是数学中的函数映射关系
  3. 纯函数:相同的输入始终会得到相同的输出。

3. 函数是一等公民

JavaScript中的函数是一等公民,它作为跟其他数据同等的地位出现在任何地方.

  • 函数可以向数字一样存储在变量中
  • 函数可以被存储在数组的插槽中
  • 函数可以被赋值给对象的字段
  • 函数可以根据需要来进行创建
  • 函数可以被传递到其他函数中
  • 函数可以作为另外其他函数的返回值返回

4. 高阶函数(Higher-order function)

定义:

  1. 函数作为参数被传递到另外一个函数中
  2. 函数可以作为另一个函数的返回值被返回

**意义:**抽象通用问题,只需关注实现目标

常用场景:

  1. 作为参数:钩子函数
  2. 作为返回值:对函数执行进行预处理

##5. 闭包(Closure)

个人理解

闭包是函数作用域的产物,说起闭包便要先讨论函数作用域,js拥有函数作用域,内层函数可以使用外层作用域中的变量。

外层函数中定义变量在内层函数中进行使用,当调用完外层函数后,函数生命周期结束,但是其作用域定义的变量由于被秦勇,生命周期仍未结束,会对之前的值进行保存,调用内层函数时就可以访问之前外层作用域中定义的变量的值。

闭包的本质

函数执行完毕后会从执行栈中移除,其内部定义的变量如果此时还被外部所引用,则堆上的作用域成员由于外部引用不能被释放,因此依然存在内存中,内部函数也因此可以访问外部函数定义的成员。

闭包示例

// 1. 明确定义
function example1() {
  const hello = 'hello'; // 外层函数作用域变量
  return function(arg) {
    return `${hello} ${arg}`;
  }
}

const helloSombody = exapmle1(); // 此时hello变量未被销毁,即闭包

// 2. 通过参数隐式定义外层函数作用域参数
function example2(firstWord) { // firstWord为外层函数作用域变量
  return function(secondWord) {
    return `${firstWord} ${secondWord}`
  }
}

const hiSomething = example('hi'); // firstWord值为hi,被保存在内存中未销毁,即闭包

6. 纯函数

概念

  • 介绍过只能从它的参数的值来计算
  • 不能依赖于被外部操作改变的数据
  • 不能改变外部状态

特性:相同的输入永远会得到相同的输出,且没有可观察的副作用。 <===> 对应数学中的函数 y = f(x)

例:

  • array.slice:不改变数组,相同输入有相同输出 ---> 纯函数
  • array.splice: 改变数组,相同输入输出不同 ---> 非纯函数

函数式 编程不会保留计算的中间结果,变量是不可变的。

可以将一个函数执行结果交给另外一个函数处理。

纯函数优势:

  1. 可缓存: 相同函数有相同输出,可将函数结果缓存,在参数未发生更改时可以直接读取缓存,提高访问速度(空间换时间)。
  2. 方便进行单元测试
  3. 并能处理能力:纯函数不需要访问共享内存数据,在并行环境可任意运行纯函数。

7. 副作用

副作用会让一个纯函数变得不纯,副作用函数会依赖一些可变的变量等,无法保证相同的输入永远得到相同的输出。

副作用的来源:

  • 配置文件
  • 数据库
  • 用户输入
  • 其他外部状态...

副作用会使得方法通用性下降,不适合扩展和可重用性,同时副作用会给程序中带来安全隐患给程序带来不确定性,但是副作用不可能完全禁止,尽可能控制它们在可控范围内发生。

8. 柯里化

个人理解

利用闭包保存变量,返回函数的方式,将本需要传入的多个参数分为多次进行传递。返回的函数可以接收剩余指定的参数(看分为多少层,每层规定了多少个参数)

柯里化(currying):

当函数又多个参数时候,先传递一部分参数调用它,这部分参数之后不变。然后返回一个新的函数接收剩余参数。

简单的通用柯里化可以如下定义

function curry(fn) {
  let length = fn.length;
  return function curriedFn(...args) {
    if (args.length < length) {
      return function(...laterArgs) {
        return curriedFn(...args.concat(laterArgs))
      }
    }
    return fn(...args);
  }
}

补充:偏函数

柯里化是将一个多元函数转换为多个m元函数,用于延迟计算。

偏函数是柯里化的一种特殊应用,偏函数是固定x个函数,生成另一个函数,调用时传入其他需要的参数。

偏函数可以如下定义:

function partial(fn, ...presetArgs) {
	return function partiallyApplied(...laterArgs) {
    let allArgs = presetArgs.concat(laterArgs);
    return fn(...allArgs);
  }
}

柯里化与偏函数的主要用途:

  1. 动态生成函数
  2. 减少参数
  3. 延迟计算

9. 函数组合

函数组合解决的问题:

纯函数与柯里化导致了各种小函数在使用的时候可能需要嵌套调用,如获取数组的最后一个元素再转换为大写字母,代码可能为_.toUpper(_.first(_.reverse(array))),这样的嵌套结构类似洋葱结构,写法复杂,函数组合就是解决这种嵌套调用的写法问题。

compose

使用: const resFn = compose(fn1, fn2); 组合完后,函数从右向左一次调用,前面的代码会先执行fn2,再执行fn1

lodash中对应的方法有_.flowRight()从右向左执行, _.flow()会从左向右执行

函数组合要满足结合律

compose(compose(fn1, fn2), fn3) == compose(fn1, compose(fn2, fn3))

10. Point Free

Point Free: 将数据的处理过程定义为与数据无关的合成运算,不需要用到代表数据的那个参数

,只要把简单的运算步骤合成到一起,在使用这种模式前需要定义一些辅助的基本运算函数。

  • 不需要指明处理的数据
  • 只需要合成运算过程
  • 需要定义一些辅助的基本运算函数

11. 函子(Functor)

Functor:

  • 容器: 包含值和值的变形关系(即函数)
  • 函子: 是个特殊容器,通过普通对象实现,该对象具有map方法与一个值(永远不对外公布),map方法接收一个函数,可以对容器中的值进行操作,然后返回一个新的函子

例:

class Container {
  constructor(value) {
    this._value = value;
  }

  map(fn) {
    return new Container(fn(this._value))
  }
}


const res = new Container(5)
  .map(x => x + 1)
  .map(x => x * x)

console.log(res); // Container { _value: 36 }

总结:

  • 函数式编程运算不直接操作值,而由函子完成
  • 函子是一个实现了map契约的对象
  • 可以将函子想象为一个盒子,里面封装了一个值
  • 想要处理值,只能给盒子中的map传递一个处理值的函数(纯函数),由这个函数对值进行处理
  • map方法返回一个包含新值的函子

11.1 MayBe函子:处理传入值为null或undefined的情况

class MayBe {
  static of (value) {
    return new MayBe(value);
  }

  constructor(value) {
    this._value = value;
  }

  map(fn) {
    return this.isNothing() ? MayBe.of(null) :MayBe.of(fn(this._value));
  }

  isNothing() {
    return this._value === null || this._value === undefined;
  }
}

11.2 Either函子:用来做异常处理。

Either两者中的一个,类似if...else逻辑

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))
  }
}

function parseJSON(str) {
  try {
    return Right.of(JSON.parse(str))
  } catch (exp) {
    return Left.of({ error: exp.message })
  }
}

11.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) {
    return new IO(fp.flowRight(fn, this._value));
  }
}

let res = IO.of(process).map(p => p.execPath)
let res1 = IO.of(process).map(p => p.execPath).map(path => 'path is ' + path);

console.log(res)
console.log(res._value())
console.log(res1._value())

11.4 Task函子(folktale库提供的处理异步任务的函子)

const fs = require('fs');
const { task } = require('folktale/concurrency/task');
const { 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(item => item.includes('version')))
  .run()
  .listen({
    onRejected: (err) => {
      console.error(err)
    },
    onResolved: data => {
      console.log(data);
    }
  })

11.5 Pointed函子:实现了of静态方法的函子

of方法可以避免使用new创建对象,深层含义为of方法可以用来将值放到上下文Context中,便于map方法处理

11.6 Monad(单子)函子:是可以变扁的Pointed函子

一盒函子如果具有join和of两个方法并遵守一些定律就是一个Monad

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));
  }

  join() {
    return this._value();
  }

  flatMap(fn) {
    return this.map(fn).join();
  }
}

const readFile = function(filename) {
  return new IO(() => {
    return fs.readFileSync(filename, 'utf-8');
  })
}

const print = function(x) {
  return new IO(() => {
    console.log(x);
    return x;
  })
}

readFile('./package.json')
  .map(fp.toUpper)
  .flatMap(print)
  .join()

总结:

  • 具有静态of方法,且具有join方法的函子
  • 当一个函数返回一个函子,就需要想到使用Monad
  • Monad解决了函子嵌套的问题
  • 当需要合并一个函数,且函数返回一个值,需要使用map方法
  • 当需要合并一个函数,且函数返回一个函子,需要使用flatMap方法