大前端进阶-函数式编程

最近在学习大前端的进阶,因此想把课程中学到的一些知识点结合这几年的工作所感记录下来。
漂亮的程序千千万,有趣的思想各不同

何为函数式编程

函数式编程是一种思想,可以和面向对象编程和面向过程编程并列。

  1. 面向过程,以过程为中心的编程思想,分析出解决问题所需要的步骤,然后用函数把这些步骤一步一步实现,使用的时候一个一个依次调用。
  2. 面向对象,以对象为中心的编程思想,通俗的讲,就是把现实世界中的某个或者一组事物抽象成为一个对象,通过属性和方法描述其应该具有能力,利用继承,多态等方法描述其变化。(个人觉得,面向过程编程的关键点是如何找到事物的相同点,并按照一定的规则将其设计为对象。)
  3. 函数式编程,顾名思义,以函数为中心的编程思想,但是需要注意的是,此函数非我们常规意义上写代码时写的函数,更趋向数学上的函数,即x => y的推导过程f(x),当f(x)固定后,一定有一个可推导且固定的y值与x值相对应。

函数式编程好处

函数式编程包含以下好处:

  1. 超级方便的代码复用(个人感觉现在公司中的部分前端开发将复制粘贴也当作了代码复用的一种,当和他们提出既然多个页面都用到,为什么不把这个处理逻辑提出来为一个公用的方法呢,得到的回答是粘贴一下就好了,为什么要提出来?额,其实逻辑的使用者不需要关心你内部逻辑是怎么实现的,只需要能保证我输入一组变量,得到我想要的结果就行了)。
  2. 无this(vue2.0对ts支持不是很友好就倒在了这个this上,vue3.0就提出了Composition API解决代码复用和ts支持)。
  3. 方便treeshaking(指的是代码打包过程中,通过分析,可以将无用代码剔除,只保留有用代码,从而减少打包体积,优化加载性能)。
  4. 方便测试(个人感觉在编写前端单元测试用例的时候,如果某段逻辑对外的依赖越强,那么测试用例越不好写,因此在开发的时候通过合理的拆分逻辑能够方便编写测试用例。那么,测试用例是否方便编写是不是衡量逻辑单元是否合理的标志呢?)。

函数式编程特性

函数是一等公民

所谓一等公民指的是函数与其他数据类型一样,处于平等地位,可以赋值给其他变量,也可以作为参数,传入另一个函数,或者作为别的函数的返回值。(一等公民 = 啥都可以 ?)

# 变量值
let handler = function () {}
# 参数
let forEach = function (array, handleFn) {}
# 返回值
let handler = function () {
    return function () {

    }
}
# 实例化
let handler = new Function()

高阶函数

高阶函数指的是可以传递一个函数作为参数的函数,通俗的讲,高阶函数也是一个函数,只不过它的参数中有一个是函数。

高阶函数的终极好处是:屏蔽实现细节,关注具体目标。

上文中的forEach就是一个高阶函数,屏蔽实现细节指的是使用者不用关心函数内部是如何对数组进行遍历,如何获取数组中的每个元素。关注具体目标指的是,使用者只关系在获取到数组中的每个元素后需要做什么操作。

闭包

函数和对其周围状态( lexical environment,词法环境)的引用捆绑在一起构成 闭包closure)。也就是说,闭包可以让你从内部函数访问外部函数作用域。在 JavaScript 中,每当函数被创建,就会在函数生成时生成闭包。
  1. 闭包是伴随js函数产生的。
  2. 闭包是在函数作用域内引入非其作用域内的外部状态。

以once函数展示基本的闭包

function once(fn) {
    let done = false
    return function() {
        // 在函数内部作用域内引入外部作用域的done状态,使得done不会随着once的执行完毕被销毁,延长其作用时间
        if(!done) {
            done = true
            fn.apply(fn, arguments)
        }
    }
}

闭包的本质:函数执行完毕之后会被执行栈释放,但是由于其作用域中的状态被外部引用,所以引用的状态不能被释放,还可以被使用。

纯函数

前提: 函数必须有输入输出。
要求: 相同的输入永远会得到相同的输出(输入输出的数据流是显式的),没有可观察的副作用。

副作用是指当调用时,除了返回值之外,还对主调用产生附加的影响。副作用的不仅仅只是返回了一个值,而且还做了其他的事情。通俗的讲就是函数依赖了外部状态或者修改了外部状态。
函数式编程要求函数无副作用,但是副作用是无法完全消除,只能将其控制在可控的范围内。

为什么会有纯函数(纯函数有哪些好处)?

  • 由于输入输出可以相互对应,因此可以针对纯函数的计算结果做缓存。
function momerize(fn) {
    let cache = {}
    return function(...args) {
        let key = JSON.stringify(args)
        cache[key] = cache[key] || fn.apply(fn, args)
        return cache[key]
    }
}
  • 由于纯函数没有副作用,所以方便测试。
  • 由于纯函数没有副作用,所以可以在多线程中调用,可以并行处理。
    js虽然是单线程的,但是最新的标准中添加了WebWork API,支持异步操作。
# 创建者
let worker = new Worker('test.js')
// 向执行者传递数据
worker.postMessage(1)
worker.onmessage = function(evt) {
    // 执行者返回的数据
    console.log(evt.data)
}
# 执行者 test.js
onmessage = function(evt) {
    postMessage(evt.data + 1)
}

## 感觉和electron中主窗口和其他窗口之间通信很相似

柯里化

柯里化(Currying)是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数且返回结果的新函数的技术。

柯里化是对纯函数的封装,将一个多元(包含多个参数)纯函数变为可连续调用的多元或一元函数。也可以理解为,通过柯里化,可以将函数细粒化,达到最大限度的代码重用。

// 简单的柯里化函数
function curry(fn) {
    return function curriedFn(...args) {
        // 如果传入的实参个数和fn的形参个数不一样,那么返回一个函数
        // 调用fn.length可以获取形参个数
        if (args.length < fn.length) {
            return function () {
                return curriedFn(...args.concat(Array.from(arguments)))
            }
        }
        // 如果相同,则调用fn返回结果
        return fn.apply(fn, args)
    }
}

// 多元函数
function sum(a, b, c) {
    return a + b + c
}
// 正常调用
console.log(sum(1, 2, 3))
let curried = curry(sum)
// 柯里化调用
console.log(curried(1)(2)(3))

函数组合

现实编程的过程中,会出现这样的情况,一个数据需要经过多个函数的处理之后才能成为你想要的数据,那么调用时可能会出现类似y = n(g(f(x)))这样的“洋葱“式代码,这种代码既不利于理解,也不利于调试。想要避开这种写法,就可以利用函数组合。函数组合就是将多个细粒化的纯函数组装成一个函数,数据可以在这个组装后的函数中按照一定的顺序执行。

// 简单的组合函数
function compose(...args) {
    return function () {
        // reverse 反转是为了实现从右到左一次执行
        return args.reverse().reduce(function (result, fn) {
            return fn(...result)
        }, arguments)
    }
}
// 下面三个纯函数是为了实现获取数组的最后一项并大写
function reverse(array) {
    return array.reverse()
}

function first(array) {
    return array[0]
}

function toUpper(str) {
    return str.toUpperCase()
}

const arr = ['a', 'b', 'c']

// 原始调用
console.log(toUpper(first(reverse(arr))))

const composed = compose(toUpper, first, reverse)
// 组合后调用
console.log(composed(arr))

从上例中可以看出,如果想要函数组合,那么有个必要前提:被组合的函数必须是有输入输出的纯函数。

函子

函子是函数式编程中最重要的数据类型,也是基本的运算单位和功能单位。

函子是两个范畴之间的一种映射(关系)

什么是范畴?
范畴是一个数学概念,通俗的讲,当某组事物之间存在某种关系,通过这种关系可以将事物组中的某个事物转变为另一个事物,那么这组事物和他们之间的关系就可以构成一个范畴。两个范畴之间可以相互转换,函子就是描述范畴之间如何转换(通过函子可以将一个范畴转换为另一个范畴)。

函数式编程中最基本的一个函子如下:函子可以看作是一个盒子,盒子中保存一个数据,调用者可通过map方法操作盒子中的数据。

class Functor {
    // 通过提供静态的of方法,可以使调用者避开new(new更趋向面向对象)
    static of(value) {
        return new Functor(value)
    }
    // 存储外部传递的数据,将数据封闭,不对外开放
    constructor(value) {
        this._value = value
    }

    // 外部通过map方法传递如何处理存储的数据,并将结果变为一个新的函子(可以实现链式操作)
    map(fn) {
        return Functor.of(fn(this._value))
    }
}

Maybe函子

函子可以接受任意函数,用于处理内部的数据,但是当函子内部数据为null的时候,map处理时会抱错。
Functor.of(null).map(x => x.toUpperCase())
Maybe函子就是为了解决这种问题,在其map处理数据的时候会判断数据是否为空。

class Maybe extends Functor {

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

    map(fn) {
        return this.isEmpty() ? Maybe.of(null) : Maybe.of(fn(this._value))
    }
}

Either函子

Either函子用来描述if...else...,因此它内部需要两个值right和left,右值是正常情况下使用的值,左值是右值不存在时使用的默认值。

Either常见的使用场景有两个:

  1. 添加默认值
  2. 替代try...catch
class Either {
    static of(left, right) {
        return new Either(left, right)
    }
    constructor(left, right) {
        this._left = left
        this._right = right
    }
    isEmpty() {
        return this._right === null || this._right === undefined
    }

    map(fn) {
        return this.isEmpty() ? Either.of(fn(this._left), this._right) : Either.of(this._left, fn(this._right))
    }
}
const user = {}
// 提供默认值
Either.of({ name: 'zs' }, user.name).map(
    u => {
        console.log(u.name)
        return u.name
    }
)
// 替代try...catch
function toUpper(str) {
    try {
        Either.of(null, str.toUpperCase())
    } catch (e) {
        Either.of(e, null)
    }
}

IO函子

在纯函数一节中提到过函数的副作用,我们应该将副作用控制在一定范围之内,IO函子就是为了解决这一问题,通过将有副作用的数据包装起来,让调用方决定如何使用这部分数据。

class IO {
    // value 是指有副作用的数据
    static of(value) {
        return new IO(function () {
            return value
        })
    }
    constructor(fn) {
        // fn方法用于包装有副作用的数据,在调用者真正使用数据的时候返回数据
        this._value = fn
    }

    map(fn) {
        // 用到了组合函数compose,将fn和value组成一个新的函数
        return new IO(compose(fn, this._value))
    }
}

// 调用: 获取node的执行路径
const io = IO.of(process).map(x => x.execPath)
console.log(io._value())

Monad函子

函子是一个盒子,内部包含了一个数据,函子也可以看作一个数据,那么就会出现函子内部的数据也是一个函子,即出现了函子的层层嵌套。Monad函子就是为了解决此问题,通过join和flatMap方法解嵌套。

上文中的IO函子上面加上join,flatMap方法,其也可以称为Monad函子。

class IO {
    // value 是指有副作用的数据
    static of(value) {
        return new IO(function () {
            return value
        })
    }
    constructor(fn) {
        // fn方法用于包装有副作用的数据,在调用者真正使用数据的时候返回数据
        this._value = fn
    }

    map(fn) {
        // 用到了组合函数compose,将fn和value组成一个新的函数
        return new IO(compose(fn, this._value))
    }

    join() {
        return this._value()
    }

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

const fs = require('fs')
let readFile = function (filename) {
    return new IO(function () {
        return fs.readFileSync(filename, 'utf-8')
    })
}
let print = function (x) {
    return new IO(function () {
        console.log(x)
        return x
    })
}

let result = readFile('test.html')
    // 将读取到的内容转为大写
    .map(x => x.toUpperCase())
    // 由于print函数返回一个函子,那么flatMap可以揭开第一层嵌套,返回print返回的函子
    .flatMap(print)
    // 获取函子的结果
    .join()
console.log(result)

你可能感兴趣的:(前端,javascript,函数式编程)