天天看点

浅析JavaScript函数式编程

浅析JavaScript函数式编程

前言

随着React的流行,函数式编程在前端领域备受关注。尤其近几年,越来越多的类库偏向于函数式开发:lodash/fp,Rx.js、Redux的纯函数,React16.8推出的hooks,Vue3.0的composition Api...同时在ES5/ES6标准中也有体现,例如:箭头函数、迭代器、map、filter、reduce等。

那么为什么要使用函数式编程呢?我们通过一个例子感受一下:在业务需求开发中,我们更多时候是对数据的处理,例如:​

​将字符串数组进行分类,转为字符串对象格式​

​。

// jsList => jsObj
const jsList = [
  'es5:forEach',
  'es5:map',
  'es5:filter',
  'es6:find',
  'es6:findIndex',
  'add'
]

const jsObj = {
  es5: ["forEach", "map", "filter"],
  es6: ["find", "findIndex"]
}      

先通过我们最常用的命令式实现一遍:

const jsObj = {}

for (let i = 0; i < jsList.length; i++) {
const item = jsList[i];
const [vesion, apiName] = item.split(":")

if (apiName) {
if (!jsObj[vesion]) {
jsObj[vesion] = []
}

jsObj[vesion].push(apiName);   
}
}      

接下来再看函数式的实现:

const jsObj = jsList
  .map(item => item.split(':'))
  .filter(arr => arr.length === 2)
  .reduce((obj, item) => {
    const [version, apiName] = item
    return {
      ...obj,
      [version]: [...(obj[version] || []), apiName]
    }
  }, {})      

两段代码对比下来,会发现命令式的实现过程中会产生大量的临时变量,还参杂大量的逻辑处理,通常只有读完整段代码才会明白具体做了什么。如果后续需求变更,又会添加更多的逻辑处理,想想脑壳都痛...

反观函数式的实现:单看每个函数,就可以知道在做什么,代码更加语义化,可读性更高。​整个过程就像一条完整的流水线,数据从一个函数输入,处理完成后流入下一个处理函数...每个函数都是各司其职​。

接下来,让我们在窥探函数式编程的世界之前,先简单了解一下上面提到的编程范式。

编程范式

编程范式是指软件工程中的一类典型的编程风格,编程范式提供并决定了程序员对程序的看法。

例如在面向对象编程中,程序员认为程序是一系列相互作用的对象;而在函数式编程中,程序会被当做一个无状态的函数计算的序列。常见的编程范式如下:

命令式编程

命令式编程是一种描述电脑所需作出的行为的编程范式,也是目前使用最广的编程范式,其主要思想就是站在计算机的角度思考问题,关注计算执行步骤,每一步都是指令。(代表:C、C++、Java)

大部分命令式编程语言都支持四种基本的语句:

  1. 运算语句;
  2. 循环语句(for、while);
  3. 条件分支语句(if else、switch);
  4. 无条件分支语句(return、break、continue)。

计算机执行的每一个步骤都是程序员控制的,所以可以​更加精细严谨的控制代码,提高应用程序的性能​;但是由于存在大量的流程控制语句,在处理多线程、并发问题时,​容易造成逻辑紊乱​。

声明式编程

声明式编程描述的是目标的性质,让计算机明白目标,而非流程。通过定义具体的规则,以便系统底层可以自动实现具体功能。(代表:Haskell)

相较于命令式编程范式,不需要流程控制语言,没有冗余的操作步骤,​使得代码更加语义化,降低了代码的复杂性​;但是其底层实现的逻辑并​不可控​,不适合做更加精细的代码优化。

总结下来,这两种编程范式最大的不同就是:

  1. ​How​:命令式编程告诉计算机​

    ​如何​

    ​ 计算,关心解决问题的步骤;
  2. ​What​:声明式编程告诉计算机需要计算​

    ​什么​

    ​,关心解决问题的目标。

函数式编程

声明式编程是一个大的概念,其下包含一些有名的子编程范式:约束式编程、领域专属语言、逻辑式编程、函数式编程。其中​领域专属语言​(DSL)和​函数式编程​(FP)在前端领域的应用更加广泛,接下来开始我们今天的主角--​函数式编程​。

函数式编程并不是一种工具,而是一种可以适用于任何环境的编程思想,它是一种以函数使用为主的软件开发风格。这与大家都熟悉的面向对象编程的思维方式完全不同,函数式的目的是​通过函数抽象作用在数据流的操作,从而在系统中消除副作用并减少对状态的改变​。

为了充分理解函数式编程,我们先来看下它有哪些基本概念?

概念

函数是一等公民

函数与其他数据类型一样,不仅可以赋值给变量,也可以当作参数传递,或者做为函数的返回值。例如:

// 做为变量
fn = () => {}
// 做为参数
function fn1(fn){fn()}
// 做为函数返回值
function fn2(){return () => {} }      

正是函数是‘一等公民’的前提,函数式编程才得以实现,而在JavaScript中,闭包和高阶函数成了中坚力量。

纯函数
纯函数是这样一种函数,即相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用。

提到纯函数,熟悉redux的同学可能再熟悉不过了,在redux中所有的修改都需要使用纯函数。纯函数具有以下特点:

  • ​无状态​:函数的输出仅取决于输入,而不依赖外部状态;
  • ​无副作用​:不会造成超出其作用域的变化,即不修改函数参数或全局变量等。
function add(obj) {
  obj.num += 1
  return obj
}

const obj = {num: 1}
add(obj)
console.log(obj)
// { num: 2 }      

这个函数不是纯的,因为js对象传递的是引用地址,函数内部的修改会直接影响外部变量,最后产生了预料之外的结果。接下来,我们改成纯函数的写法:

function add(obj) {
  const _obj = {...obj}
  _obj.num += 1
  return _obj
}

const obj = {num: 1}
add(obj)
console.log(obj);
// { num: 1 }      

通过在函数内部创建新的变量进行更改(是不是有想起redux的reducer写法~~),从而避免产生副作用。纯函数除了​无副作用​外,还有其他好处:

  1. ​可缓存性​正是因为函数式声明的无状态特点,即:​相同输入总能得到相同的输出​。所以我们可以提前缓存函数的执行结果,实现更多功能。例如:优化斐波拉契数列的递归解法。
  2. ​可移植性/自文档化​纯函数的依赖很明确,更易于观察和理解,配合类型签名可以使程序更加简单易读。
// get :: a -> a
const get = function (id) { return id}
// map :: (a -> b) -> [a] -> [b]
const map = curry(function (f, res){
    return res.map(f)
})      
  1. ​可测试性​纯函数让测试更加简单,只需简单地给函数一个输入,然后断言输出就可以了。
副作用

函数的副作用是指​在调用函数时,除了返回函数值外还产生了额外的影响​。例如修改上个例子中的修改参数或者全局变量。除此之外,以下副作用也都有可能会发生:

  • 更改全局变量
  • 处理用户输入
  • 屏幕打印或打印log日志
  • DOM查询以及浏览器cookie、localstorage查询
  • 发送http请求
  • 抛出异常,未被当前函数捕获
  • ...

​副作用往往会影响代码的可读性和复杂性,从而导致意想不到的bug。在实际开发中,我们是离不开副作用的,那么在函数式编程中应尽量减少副作用,尽量书写纯函数。​

引用透明

如果一个函数对于相同输出始终产生同一个输出结果,完全不依赖外部环境的变化,那么就可以说它是引用透明的。

数据不可变

所有数据被创建后不可更改,如果想要修改变量,需要新建一个新的对象进行修改(例如上面纯函数提到的例子)。

说完这些概念,我们再来看一下在函数式编程中又有哪些常见的操作。

柯里化(curry)

把接受多个参数的函数变换成接受一个单一参数的函数,并返回接受剩余参数而且返回结果的新函数。
F(a,b,c) => F(a)(b)(c)      

接下来我们实现一版简单的curry函数。

function curry(targetFunc) {
  // 获取目标函数的参数个数
  const argsLen = targetFunc.length

  return function func(...rest) {
    return rest.length < argsLen ? func.bind(null, ...rest) : targetFunc.apply(null, rest)
}
}

function add(a,b,c,d) {
return a + b + c + d
}

console.log(curry(add)(1)(2)(3)(4));
console.log(curry(add)(1, 2)(3)(4));
// 10      

仔细的同学可能已经看出来,上面实现的curry函数并不是单纯柯里化函数,因为柯里化强调的是​生成单元函数​,但是单次​传入多个参数​也可以,更像是​柯里化​和​偏函数​的综合应用。那偏函数又是怎么定义的呢?

偏函数(Partial)是指固定一个函数的一些参数,然后产生另一个更小元的函数。

偏函数在创建的时候还可以传入预设的​

​partials​

​​参数,类似​

​bind​

​的使用。通常情况下,我们不会自己写curry函数,像Lodash、Ramda这些库都实现了curry函数,这些库实现的curry函数和柯里化的定义也是不太一样的。

const add = function (a, b, c) {return a + b + c}

const curried = _.curry(add)
curried(1)(2)(3)
curried(1, 2)(3)
curried(1, 2, 3)
// 还实现了附加参数的占位符
curried(1)(_, 3)(2)      

组合(compose)

compose在函数式编程中也是一个很重要的思想。​把复杂的逻辑拆分成一个个简单任务,最后组合起来完成任务,使得整个过程的数据流更明确、可控、可读。​这也印证了上面我们提到过:函数式编程像一条流水线,初始数据通过多个函数依次处理,最后完成整体输出。

// 整个过程处理
a => fn => b
// 拆分成多段处理
a => fn1 => fn2 => fn3 => b      

接下来,我们实现一般简单的compose:

function compose(...fns) {
  return fns.reduce((a,b) => {
    return (...args) => {
      return a(b(...args))
    }
  })
}

function fn1(a) {
  console.log('fn1: ', a);
  return a+1
}

function fn2(a) {
  console.log('fn2: ', a);
  return a+1
}

function fn3(a) {
  console.log('fn3: ', a);
  return a+1
}

console.log(compose(fn1, fn2, fn3)(1));
// fn3:  1
// fn2:  2
// fn1:  3
// 4      

分析上述compose的实现,可以看出fn3是先于fn2执行,fn2先于fn1执行,也就是说:compose创建了一个​从右向左​执行的数据流。如果要实现​从左到右​的数据流,可以直接更改compose的部分代码即可实现:

  • 更换Api接口:把​

    ​reduce​

    ​​改为​

    ​reduceRight​

  • 交互包裹位置:把​

    ​a(b(...args))​

    ​​改为​

    ​b(a(...args))​

    ​。

也可以使用Ramda中提供的组合方式:管道(pipe)。

R.pipe(fn1, fn2, fn3)      

函数组合不仅让代码更富有可读性,数据流的整体流向也更加清晰,程序更加可控。接下来,我们看下函数式编程在具体业务中的实践。

编程实践

数据处理

业务开发过程中,我们更多的时候是对接口请求数据或表单提交数据的处理,尤其是经常开发B端的同学更是深有体会。笔者之前就做过针对大量表单数据的处理需求,例如:​针对用户提交的表单数据做一定的处理:1. 清除空格;2. 全部转为大写。​

首先我们站在函数式编程的思维上分析一下整个需求:

  1. ​抽象​:每个处理过程都是一个纯函数
  2. ​组合​:通过compose组合每一个处理函数
  3. ​扩展​:只需删除或添加对应的处理纯函数即可

接下来,我们看一下整体的实现:

// 1. 实现遍历函数
function traverse (obj, handler) {
  if (typeof obj !== 'object') return handler(obj)

  const copy = {}
  Object.keys(obj).forEach(key => {
    copy[key] = traverse(obj[key], handler)
  })

  return copy
}

// 2. 实现具体业务处理的纯函数
function toUpperCase(str) {
  return str.toUpperCase() // 转为大写
}

function toTrim(str) {
  return str.trim() // 删除前后空格
}

// 3. 通过compose执行
// 用户提交数据如下:
const obj = {
  info: {
    name: ' asyncguo '
  },
  address: {
    province: 'beijing',
    city: 'beijing',
    area: 'haidian'
  }
}
console.log(traverse(obj, compose(toUpperCase, toTrim)));
/**
    {
     info: { name: 'ASYNCGUO' },
     address: { province: 'BEIJING', city: 'BEIJING', area: 'HAIDIAN' }
    }
*/      

redux中间件实现

说到函数式在JavaScript中的实践,那就不得不聊一下redux。首先我们先实现一版简单redux:

function createStore(reducer) {
  let currentState
  let listeners = []

  function getState() {
    return currentState
  }

  function dispatch(action) {
    currentState = reducer(currentState, action)
    listeners.map(listener => {
      listener()
    })
    return action
  }

  function subscribe(cb) {
    listeners.push(cb)
    return () => {}
  }

  dispatch({type: 'ZZZZZZZZZZ'})

  return {
    getState,
    dispatch,
    subscribe
  }
}

// 应用实例如下:
function reducer(state = 0, action) {
  switch (action.type) {
    case 'ADD':
      return state + 1
    case 'MINUS':
      return state - 1
    default:
      return state
  }
}

const store = createStore(reducer)

console.log(store);
store.subscribe(() => {
  console.log('change');
})
console.log(store.getState());
console.log(store.dispatch({type: 'ADD'}));
console.log(store.getState());      

首先使用​

​reducer​

​​初始化​

​store​

​​,后续事件产生时,通过​

​dispatch​

​​更新​

​store​

​​状态,同时通过​

​getState​

​​获取​

​store​

​的最新状态。

​redux​

​规范了​单向数据流​,​

​action​

​​只能由​

​dispatch​

​​函数派发,并通过纯函数​

​reducer​

​​更新状态​

​state​

​,然后继续等待下一次的事件。这种单向数据流的机制进一步简化事件管理的复杂度,并且还可以在事件流程中插入​中间件(middleware)​。通过中间件,可以实现日志记录、thunk、异步处理等一系列扩展处理,大大得增强事件处理的灵活性。

接下来对上面的redux进一步增强优化:

// 扩展createStore
function createStore(reducer, enhancer){
 if (enhancer) {
   return enhancer(createStore)(reducer)
  }

  ...
}
// 中间件的实现
function applyMiddleware(...middlewares) {
  return function (createStore) {
    return function (reducer) {
      const store = createStore(reducer)
      let _dispatch = store.dispatch

      const middlewareApi = {
        getState: store.getState,
        dispatch: action => _dispatch(action)
      }

      // 获取中间件数组:[mid1, mid2]
      // mid1 = next1 => action1 => {}
      // mid2 = next2 => action2 => {}
      const midChain = middlewares.map(mid => mid(middlewareApi))

      // 通过compose组合中间件:mid1(mid2(mid3())),得到最终的dispatch
      // 1. compse执行顺序:next2 => next1
      // 2. 最终dispatch:action1 (action1中调用next时,回到上一个中间件action2; action2中调用next时,回到最原始的dispatch)

      _dispatch = compose(...midChain)(store.dispatch)

      return {
        ...store,
        dispatch: _dispatch
      }
    }
  }
}

// 自定义中间件模板
const middleaware = store => next => action => {
    // ...逻辑处理
    next(action)
}      

通过​

​compose​

​​组合所有的​

​middleware​

​​,然后返回包装过的​

​dispatch​

​​。接下来,在每次​

​dispatch​

​​时,​

​action​

​​会经过全部中间件进行一系列操作,最后透传给纯函数​

​reducer​

​​进行真正的状态更新。任何​

​middleware​

​​能够做到的事情,我们都可以通过手动包装​

​dispatch​

​调用实现,但是放在同一个地方统一管理使得整个项目的扩展变得更加容易。

// 1. 手动包装dispatch调用,实现logger功能
function dispatchWithLog(store, action) {
 console.log('dispatching', action)
 store.dispatch(action)
 console.log('next state', store.getState())
}

dispatchWithLog(store, {type: 'ADD'})

// 2. 中间件方式包装dispatch调用
const store = new Store(reducer, applyMiddleware(thunkMiddleware, loggerMiddleware))

store.dispatch(() => {
 setTimeout(() => {
   store.dispatch({type: 'ADD'})
  }, 2000)
})

// 中间件执行过程
thunk => logger => store.dispatch      

RxJS

提到​

​Rxjs​

​,更多人想到应该是​响应式编程​(Reactive Programming, RP),即​使用异步数据流进行编程​。响应式编程使用​

​Rx.Observale​

​为异步数据提供统一的名为可观察的流(observeale stream)的概念,可以说响应式编程的世界就是​流​的世界。想要提取其值,就必须先订阅它。例如:

Rx.observale.of(1, 2, 3, 4, 5)
 .filter(x => x%2 !== 0)
 .map(x => x * x)
 .subscrible(x => console.log(`ext: ${x}`))      

通过上面的例子,可以发现响应式编程就是让整个编程过程流式化,就像一条流水线,同时以函数式编程为主,即流水线的每条工序都是无副作用的(纯函数)。所以更准确的说​

​Rxjs​

​应该是​函数响应式编程​(Functional Reactive Programming,FRP),顾名思义,​FRP​同时具有函数式编程和响应式编程的特点。(今天主要是讲函数式编程,更多​

​Rxjs​

​​部分的内容,感兴趣的同学可以自行了解一下。笔者还是很推荐学习一下​

​Rxjs​

​在异步数据流上的处理~)

总结

函数式编程是一个很大的话题,今天我们主要是介绍了一下函数式编程的基础概念,当然还有更高级的概念:​Functor(函子)​、​Monad​、​Application Functor​等还没有提到,真正掌握这些东西还是需要一定练习积累,感兴趣的同学可以自行了解一下,或者期待笔者后续的文章。

对比面向对象编程,我们可以总结一下,函数式编程的优点:

  • ​代码更加简明,流程更可控​
  • ​流式处理数据​
  • ​降低事件驱动代码的复杂性​

当然,函数式编程也存在一定的性能问题,​在抽象层次往往因为过度包装,导致上下文切换的性能开销;同时由于数据不可变的特点,中间变量也会消耗更多内存空间​。

在日常业务开发中,函数式编程应是​与面向对象编程以互补的形式存在,根据具体的需求选择合适的编程范式​。在面对一种新技术或新的编程方式时,若其优点值得我们学习和借鉴时,并不应该因为某个缺陷就一味的拒绝它,更多时候是应该能够想到与其互补的更优解。不以优而喜,不以劣而悲,与君共勉~

推荐资料

编程范式(https://zh.wikipedia.org/wiki/%E7%BC%96%E7%A8%8B%E8%8C%83%E5%9E%8B)

functional light JS(https://frontendmasters.com/courses/functional-javascript-v3/)

Functional-Light-JS - github(https://github.com/getify/Functional-Light-JS)

redux-middleware(https://www.redux.org.cn/docs/api/applyMiddleware.html)

函数式编程浅析(https://zhuanlan.zhihu.com/p/74777206)

函数式编程在Redux/React中的应用 (https://tech.meituan.com/2017/10/12/functional-programming-in-redux.html)

函数式编程指北(https://llh911001.gitbooks.io/mostly-adequate-guide-chinese/content/ch5.html)

JavaScript函数式编程指南(https://book.douban.com/subject/30283769/)