学习笔记之函数式编程+异步

270 阅读13分钟

一、函数式编程

1.1 函数式编程优势

  • 可以不使用this
  • 打包过程可以利用tree shaking过滤无用代码
  • 方便测试及并行处理

1.2 函数式编程概念

  • 函数式编程,即Functional Programing,是编程范式之一,与面向过程编程、面向对象编程为平级关系。

  • 函数式编程用来描述数据之间的映射关系

函数式编程的起源,是一门叫做范畴论(Category Theory)的数学分支。

理解函数式编程的关键,就是理解范畴论。它是一门很复杂的数学,认为世界上所有的概念体系,都可以抽象成一个个的"范畴"(category)。

"范畴就是使用箭头连接的物体。"(In mathematics, a category is an algebraic structure that comprises "objects" that are linked by "arrows". )

也就是说,彼此之间存在某种关系的概念、事物、对象等等,都构成"范畴"。随便什么东西,只要能找出它们之间的关系,就能定义一个"范畴"。

范畴论是集合论更上层的抽象,简单的理解就是"集合 + 函数"。

我们可以把"范畴"想象成是一个容器,里面包含两样东西。

  • 值(value)
  • 值的变形关系,也就是函数。

在函数式编程中,函数就是一个管道(pipe)。这头进去一个值,那头就会出来一个新的值,没有其他作用。

函数式编程有两个最基本的运算:合成和柯里化。

如果一个值要经过多个函数,才能变成另外一个值,就可以把所有中间步骤合并成一个函数,这叫做"函数的合成"(compose)。

函数的合成还必须满足结合律。

f(x)g(x)合成为f(g(x)),有一个隐藏的前提,就是fg都只能接受一个参数。如果可以接受多个参数,比如f(x, y)g(a, b, c),函数合成就非常麻烦。

这时就需要函数柯里化了。所谓"柯里化",就是把一个多参数的函数,转化为单参数函数。

有了柯里化以后,我们就能做到,所有函数只接受一个参数。后文的内容除非另有说明,都默认函数只有一个参数,就是所要处理的那个值。

1.3 高阶函数

​ 将函数作为参数或者返回值的函数,比如forEach、filter、bind等

/**
*	函数作为参数
*/
// forEach
function forEach(arr, fn) {
    for (let i = 0; i < arr.length; i ++) {
        fn(arr[i], i, arr)
    }
}
const arr = [1, 2, 3, 4]
forEach(arr, function (item, index, itself) {
    console.log(item, index, itself)
})
/**
*	函数作为返回值
*/
// once
function once(fn) {
    let flag = false
    return function () {
        if (!flag) {
            flag = true
            return fn.apply(this, arguments)
        }
    }
}
const pay = once(function (money) {
    console.log(money)
})

pay(5)
pay(5)
pay(5)

​ 高阶函数可以屏蔽细节,让我们更专注于目标

1.4 闭包

闭包是指一个函数对另一个函数中的成员变量有引用,使其不能被释放

// 闭包示例
function greet() {
    const msg = 'hello'
    return function () {
        console.log(msg)
    }
}

greet()()
function salary(base) {
    return function (per) {
        console.log(base + per)
    }
}

const level1 = salary(10000)
const level2 = salary(20000)

level1(8000)
level2(8000)

1.5 纯函数

纯函数会对于相同的输入永远有着相同的输出

// 纯函数
const arr = [1, 2, 2, 3, 65]
console.log(arr.slice(0, 3))
console.log(arr.slice(0, 3))
console.log(arr.slice(0, 3))

//      结果
//     [ 1, 2, 2 ]
//     [ 1, 2, 2 ]
//     [ 1, 2, 2 ]

// 不纯函数
console.log(arr.splice(0, 3))
console.log(arr.splice(0, 3))
console.log(arr.splice(0, 3))

//      结果
//     [ 1, 2, 2 ]
//     [ 3, 65 ]
//     []

1.5.1 纯函数的好处

  • 可缓存

    因为其对相同的输入永远有相同的结果,所以可以把相同的结果给缓存起来

    // 记忆函数
    function memoize(fn) {
        const cache = {}
        console.log('first time')
        return function () {
            const key = JSON.stringify(arguments)
            cache[key] = cache[key] || fn.apply(fn, arguments)
            return cache[key]
        }
    }
    const r = memoize(function () {
        console.log('fn')
    })
    
    r()
    r()
    r()
    
    // 打印
    // first time
    // fn
    // fn
    // fn
    
  • 可测试

    纯函数会让测试更加方便

  • 并行处理

    纯函数不需要访问共享的内存数据

1.6 柯里化

当一个函数有多个参数时,可以先传入部分参数,然后返回一个新的函数接收剩余参数并返回结果

// 原始函数

function checkAge(age) {
    const min = 18
    return age > min
}
console.log(checkAge(19))

// 结果
// true

// 柯里化
function checkAgeCurry(min) {
    return function (age) {
        return age > min
    }
}

const min = checkAgeCurry(18)

console.log(min(20))
console.log(min(10))

// 结果
// true
// false

// simplify
const checkAgeSimplified = min => age => age > min

console.log(checkAgeSimplified(18)(20))

// 结果
// true

1.6.1 柯里化的实现

// 模拟柯里化
// 接收一个函数,使其柯里化
function curry(fn) {
    return function cu(...args) {
        // 判断返回的函数执行时参数的长度是否与原函数一致,若一致返回fn(...args),否则将参数拼接递归调用cu
        if (args.length < fn.length) {
            return function () {
                return cu(...args.concat(Array.from(arguments)))
            }
        }

        return fn(...args)
    }
}

1.7 函数组合

将多个细粒度的函数组合起来形成一个复杂功能的函数

// 函数组合

function compose(first, reverse) {
    return function (arr) {
        return first(reverse(arr))
    }
}

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

function reverse(arr) {
    return arr.reverse()
}

const findLast = compose(first, reverse)

console.log(findLast([1, 2, 3]))

1.7.1 模拟lodash/flowRight

// flowRight

// const compose = (...fns) => val => fns.reverse().reduce((pre, cur) => cur(pre), val)

function compose(...fns) {
    return function (val) {
        return fns.reverse().reduce((pre, cur) => cur(pre), val)
    }
}

const first = arr => arr[0]
const reverse = arr => arr.reverse()

const findLast = compose(first, reverse)

console.log(findLast([1, 2, 3]))

1.8 函子

1.8.1 Functor

函数不仅可以用于同一个范畴之中值的转换,还可以用于将一个范畴转成另一个范畴。这就涉及到了函子——Functor。

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

它首先是一种范畴,也就是说,是一个容器,包含了值和变形关系。比较特殊的是,它的变形关系可以依次作用于每一个值,将当前容器变形成另一个容器。

任何具有map方法的数据结构,都可以当作函子的实现。

一般约定,函子的标志就是容器具有map方法。该方法将容器里面的每一个值,映射到另一个容器。

// 函子的实现
class Functor {
  constructor(val) { 
    this.val = val; 
  }

  map(fn) {
    return new Functor(fn(this.val));
  }
}

上面代码中,Functor是一个函子,它的map方法接受函数f作为参数,然后返回一个新的函子,里面包含的值是被f处理过的(f(this.val))。

// 用例
new Functor(1).map(x => x + 1)

// 返回结果为Functor(2)

上面的例子说明,函数式编程里面的运算,都是通过函子完成,即运算不直接针对值,而是针对这个值的容器----函子。函子本身具有对外接口(map方法),各种函数就是运算符,通过接口接入容器,引发容器里面的值的变形。

函数式编程,实际上就是函子的各种运算, 由于可以把运算方法封装在函子里面,所以又衍生出各种不同类型的函子,有多少种运算,就有多少种函子。函数式编程就变成了运用不同的函子,解决实际问题。

1.8.2 of方法

1.8.1中生成新函子的操作用到了面向对象的new操作符,所以函数式编程约定:函子有个of方法,用来生成新的容器。

// of方法替换 new 操作符
class Functor {
  static of(val) {
    return new Functor(val)
  }
    
  constructor(val) { 
    this.val = val; 
  }

  map(fn) {
    return Functor.of(fn(this.val));
  }
}

如此1.8.1中的用例可修改为以下形式

// 用例
Functor.of(1)
	.map(val => val + 1)

// 返回结果为  Functor(2)

1.8.3 Maybe函子

函子接受各种函数,处理容器内部的值。这里就有一个问题,容器内部的值可能是一个空值(比如null),而外部函数未必有处理空值的机制,如果传入空值,很可能就会出错。

Functor.of(null)
    .map(str => str.toUpperCase());

上述代码中传入null之后会报错。

在Maybe函子的map方法中,设置了空值检测:

class Maybe extends Functor {
    map(fn) {
        return this.val ? Maybe.of(fn(this.val)) : Maybe.of(null)
    }
}

这样函子在处理空值的时候就不会再出现问题了

Maybe.of(null)
	.map(str => str.toUpperCase())

// 结果为  Maybe(null)

1.8.4 Either函子

在函数式编程中,Either函子能实现if...else...这样的运算。

Either函子内部有两个值:Left(左值)和right(右值)。正常情况下,使用的是右值,当出现异常时将会用到左值。

// Right
class Right extends Functor {
    map(fn) {
        return Right.of(fn(this.val))
    }
}

// Left
class Left extends Functor {
    map(fn) {
        return this
    }
}

Right.of(1)
    .map(x => x + 1)
Left.of(1)
	.map(x => x + 1)

// 结果
// Right(2)
// Left(1)

Either 函子的另一个用途是代替try...catch,使用左值表示错误。

const parseJSON = jsonStr => {
	try{
		return Right.of(JSON.parse(jsonStr))
	} catch (e) {
		return Left.of(e.message)
	}
}

1.8.5 Monad函子

函子是一个容器,可以包含任何值。函子之中再包含一个函子,也是完全合法的。但是,这样就会出现多层嵌套函子的问题。

Functor.of(
  Functor.of(
    Functor.of(1)
  )
)

上述代码中共有三层嵌套,如若想取出最底层的值,需要连续三次使用this.val。因此便出现了Monad函子。

Monad函子的作用是总是返回一个单层的函子。 它有一个flatMap方法,与map方法作用相同,唯一的区别是如果生成了一个嵌套函子,它会取出后者内部的值,保证返回的永远是一个单层的容器,不会出现嵌套的情况。

class Monad extands Functor {
    join() {
        return this.val
    }
    
    flatMap(fn) {
        return this.map(fn).join()
    }
}

如果函数f返回的是一个函子,那么this.map(f)就会生成一个嵌套的函子。所以,join方法保证了flatMap方法总是返回一个单层的函子。这意味着嵌套的函子会被铺平(flatten)。

1.8.6 IO函子

在IO操作是不纯的操作,需要用 把 IO 操作写成Monad函子,通过它来完成。

var fs = require('fs');

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

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

上面代码中,读取文件和打印本身都是不纯的操作,但是readFileprint却是纯函数,因为它们总是返回 IO 函子。

如果 IO 函子是一个Monad,具有flatMap方法,那么我们就可以像下面这样调用这两个函数。

readFile('./user.txt')
	.flatMap(print)

上面的代码完成了不纯的操作,但是因为flatMap返回的还是一个 IO 函子,所以这个表达式是纯的。

由于返回还是 IO 函子,所以可以实现链式操作。因此,在大多数库里面,flatMap方法被改名成chain

var tail = function(x) {
  return new IO(function() {
    return x[x.length - 1]
  });
}

readFile('./user.txt')
	.flatMap(tail)
	.flatMap(print)

// 等同于
readFile('./user.txt')
	.chain(tail)
	.chain(print)

上面代码读取了文件user.txt,然后选取最后一行输出。

二、 异步编程

1.1 同步和异步

Javascript语言的执行环境是单线程的,一次只能完成一件任务。如果有多个任务,就必须排队,前面一个任务完成,再执行后面一个任务,以此类推。 但是只要有一个任务耗时很长,后面的任务都必须排队等着,会拖延整个程序的执行。常见的浏览器无响应(假死),往往就是因为某一段Javascript代码长时间运行(比如死循环),导致整个页面卡在这个地方,其他任务无法执行。

为了解决这个问题,Javascript语言将任务的执行模式分成两种:同步和异步。

同步就是后一个任务等待前一个任务结束,然后再执行,程序的执行顺序与任务的排列顺序是一致的、同步的;异步模式是每一个任务有一个或多个回调函数,前一个任务结束后,不是执行后一个任务,而是执行回调函数,后一个任务则是不等前一个任务结束就执行,所以程序的执行顺序与任务的排列顺序是不一致的、异步的。

1.2 异步的实现

1.2.1 回调函数

当有f1和f2两个函数,f1执行时会有很长时间的消耗并且f2要在f1执行后再执行时,可以将f2作为f1的回调函数传入f1:

// 回调函数
function f1(callback){
    setTimeout(function () {
      callback();
    }, 1000);
  }

// 执行
f1(f2)

采用这种方式,我们把同步操作变成了异步操作,f1不会堵塞程序运行,相当于先执行程序的主要逻辑,将耗时的操作推迟执行。

回调函数的优点是简单、容易理解和部署,缺点是容易造成回调地狱不利于代码的阅读和维护。

1.2.2 事件监听

该种方法在给dom元素添加事件时极为常用:未一个dom添加如click的事件,这个事件直到触发时才会执行

// 事件监听
const div = document.getElementById('div')
div.addEventListener('click', function(e) {
    console.log(e)
})

以上函数只有在触发click事件时才会执行,而且还可以绑定多个事件,每个事件可以指定多个回调函数 。但是过多的事件监听程序就会变得过于事件驱动。

1.2.3 订阅/发布

存在一个"信号中心",某个任务执行完成,就向信号中心"发布"(publish)一个信号,其他任务可以向信号中心"订阅"(subscribe)这个信号,从而知道什么时候自己可以开始执行。这就叫做"发布/订阅模式"(publish-subscribe pattern),又称"观察者模式"(observer pattern)。

在vue项目中,我们可以用EventBus来模拟订阅发布者模式:

// 订阅/发布
// 创建一个空的vue实例
const bus = new Vue()

// 监听onChange事件
bus.$on('onChange', val => {
	console.log(val)
})

// 触发onChange事件
bus.$emit('onChange', 1)

这种方法的性质与事件监听类似,但是明显优于后者。因为我们可以通过查看"消息中心",了解存在多少信号、每个信号有多少订阅者,从而监控程序的运行。

1.2.4 Promise

Promise对象用于表示一个异步操作的最终完成 (或失败)及其结果值。

// Promise
// 创建一个promise实例
const p1 = new Promise((resolve, reject) => {
    // 异步操作
    
    // 成功时
    resolve(res)
    
    // 失败时
    reject(err)
})

p1.then(res => {
    console.log(res)
}, err => {
    console.log(err)
})

一个 Promise 对象代表一个在这个 promise 被创建出来时不一定已知的值。它让您能够把异步操作最终的成功返回值或者失败原因和相应的处理程序关联起来。 这样使得异步方法可以像同步方法那样返回值:异步方法并不会立即返回最终的值,而是会返回一个 promise,以便在未来某个时候把值交给使用者。

一个 Promise 必然处于以下几种状态之一:

  • 待定(pending): 初始状态,既没有被兑现,也没有被拒绝。
  • 已兑现(fulfilled): 意味着操作成功完成。
  • 已拒绝(rejected): 意味着操作失败。

待定状态的 Promise 对象要么会通过一个值被兑现(fulfilled),要么会通过一个原因(错误)被拒绝(rejected)。当这些情况之一发生时,我们用 promise 的 then 方法排列起来的相关处理程序就会被调用。如果 promise 在一个相应的处理程序被绑定时就已经被兑现或被拒绝了,那么这个处理程序就会被调用,因此在完成异步操作和绑定处理方法之间不会存在竞争状态。

因为 Promise.prototype.thenPromise.prototype.catch 方法返回的是 promise, 所以它们可以被链式调用。

我们可以用promise.then(),promise.catch()和promise.finally()这些方法将进一步的操作与一个变为已敲定状态的promise关联起来。这些方法还会返回一个新生成的promise对象,这个对象可以被非强制性的用来做链式调用。

const p2 = new Promise((resolve, reject) => {
    // 异步操作
    
    // 成功时
    resolve(res)
    
    // 失败时
    reject(err)
})

// 链式调用
p1.then(res => {
    console.log(res)
    return p2
}).then(res => {
    console.log(res)
}).catch(err => {
    console.log(err)
})