浅析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(函子)MonadApplication Functor等还没有提到,真正掌握这些东西还是需要一定练习积累,感兴趣的同学可以自行了解一下,或者期待笔者后续的文章。

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

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

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

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

推荐资料

functional light JS

Functional-Light-JS - github

redux-middleware

函数式编程浅析

函数式编程在Redux/React中的应用

函数式编程指北

JavaScript函数式编程指南

感谢你的阅读,有任何问题,欢迎评论区留言讨论,互相学习。

文章分类
前端