找回密码
 会员注册
查看: 35|回复: 0

浅析JavaScript函数式编程

[复制链接]

2万

主题

0

回帖

6万

积分

超级版主

积分
63691
发表于 2024-9-20 02:24:08 | 显示全部楼层 |阅读模式
前言随着React的流行,函数式编程在前端领域备受关注。尤其近几年,越来越多的类库偏向于函数式开发:lodash/fp,Rx.js、Redux的纯函数,React16.8推出的hooks,Vue3.0的compositionApi...同时在ES5/ES6标准中也有体现,例如:箭头函数、迭代器、map、filter、reduce等。那么为什么要使用函数式编程呢?我们通过一个例子感受一下:在业务需求开发中,我们更多时候是对数据的处理,例如:将字符串数组进行分类,转为字符串对象格式。// jsList => jsObjconst 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  item.split(':'))  .filter(arr => arr.length === 2)  .reduce((obj, item) => {    const [version, apiName] = item    return {      ...obj,      [version]: [...(obj[version] || []), apiName]    }  }, {})两段代码对比下来,会发现命令式的实现过程中会产生大量的临时变量,还参杂大量的逻辑处理,通常只有读完整段代码才会明白具体做了什么。如果后续需求变更,又会添加更多的逻辑处理,想想脑壳都痛...反观函数式的实现:单看每个函数,就可以知道在做什么,代码更加语义化,可读性更高。整个过程就像一条完整的流水线,数据从一个函数输入,处理完成后流入下一个处理函数...每个函数都是各司其职。接下来,让我们在窥探函数式编程的世界之前,先简单了解一下上面提到的编程范式。编程范式编程范式是指软件工程中的一类典型的编程风格,编程范式提供并决定了程序员对程序的看法。例如在面向对象编程中,程序员认为程序是一系列相互作用的对象;而在函数式编程中,程序会被当做一个无状态的函数计算的序列。常见的编程范式如下:命令式编程命令式编程是一种描述电脑所需作出的行为的编程范式,也是目前使用最广的编程范式,其主要思想就是站在计算机的角度思考问题,关注计算执行步骤,每一步都是指令。(代表:C、C++、Java)大部分命令式编程语言都支持四种基本的语句:运算语句;循环语句(for、while);条件分支语句(ifelse、switch);无条件分支语句(return、break、continue)。计算机执行的每一个步骤都是程序员控制的,所以可以更加精细严谨的控制代码,提高应用程序的性能;但是由于存在大量的流程控制语句,在处理多线程、并发问题时,容易造成逻辑紊乱。声明式编程声明式编程描述的是目标的性质,让计算机明白目标,而非流程。通过定义具体的规则,以便系统底层可以自动实现具体功能。(代表:Haskell)相较于命令式编程范式,不需要流程控制语言,没有冗余的操作步骤,使得代码更加语义化,降低了代码的复杂性;但是其底层实现的逻辑并不可控,不适合做更加精细的代码优化。总结下来,这两种编程范式最大的不同就是:How:命令式编程告诉计算机如何计算,关心解决问题的步骤;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写法~~),从而避免产生副作用。纯函数除了无副作用外,还有其他好处:可缓存性正是因为函数式声明的无状态特点,即:相同输入总能得到相同的输出。所以我们可以提前缓存函数的执行结果,实现更多功能。例如:优化斐波拉契数列的递归解法。可移植性/自文档化纯函数的依赖很明确,更易于观察和理解,配合类型签名可以使程序更加简单易读。// get :: a -> aconst get = function (id) { return id}// map :: (a -> b) -> [a] -> [b]const map = curry(function (f, res){    return res.map(f)})可测试性纯函数让测试更加简单,只需简单地给函数一个输入,然后断言输出就可以了。副作用函数的副作用是指在调用函数时,除了返回函数值外还产生了额外的影响。例如修改上个例子中的修改参数或者全局变量。除此之外,以下副作用也都有可能会发生:更改全局变量处理用户输入屏幕打印或打印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  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.全部转为大写。首先我们站在函数式编程的思维上分析一下整个需求:抽象:每个处理过程都是一个纯函数组合:通过compose组合每一个处理函数扩展:只需删除或添加对应的处理纯函数即可接下来,我们看一下整体的实现:// 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进一步增强优化:// 扩展createStorefunction 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.dispatchRxJS提到Rxjs,更多人想到应该是响应式编程(ReactiveProgramming,RP),即使用异步数据流进行编程。响应式编程使用Rx.Observale为异步数据提供统一的名为可观察的流(observealestream)的概念,可以说响应式编程的世界就是流的世界。想要提取其值,就必须先订阅它。例如:Rx.observale.of(1, 2, 3, 4, 5) .filter(x => x%2 !== 0) .map(x => x * x) .subscrible(x => console.log(`ext: ${x}`))通过上面的例子,可以发现响应式编程就是让整个编程过程流式化,就像一条流水线,同时以函数式编程为主,即流水线的每条工序都是无副作用的(纯函数)。所以更准确的说Rxjs应该是函数响应式编程(FunctionalReactiveProgramming,FRP),顾名思义,FRP同时具有函数式编程和响应式编程的特点。(今天主要是讲函数式编程,更多Rxjs部分的内容,感兴趣的同学可以自行了解一下。笔者还是很推荐学习一下Rxjs在异步数据流上的处理~)总结函数式编程是一个很大的话题,今天我们主要是介绍了一下函数式编程的基础概念,当然还有更高级的概念:Functor(函子)、Monad、ApplicationFunctor等还没有提到,真正掌握这些东西还是需要一定练习积累,感兴趣的同学可以自行了解一下,或者期待笔者后续的文章。对比面向对象编程,我们可以总结一下,函数式编程的优点:代码更加简明,流程更可控流式处理数据降低事件驱动代码的复杂性当然,函数式编程也存在一定的性能问题,在抽象层次往往因为过度包装,导致上下文切换的性能开销;同时由于数据不可变的特点,中间变量也会消耗更多内存空间。在日常业务开发中,函数式编程应是与面向对象编程以互补的形式存在,根据具体的需求选择合适的编程范式。在面对一种新技术或新的编程方式时,若其优点值得我们学习和借鉴时,并不应该因为某个缺陷就一味的拒绝它,更多时候是应该能够想到与其互补的更优解。不以优而喜,不以劣而悲,与君共勉~推荐资料编程范式(https://zh.wikipedia.org/wiki/%E7%BC%96%E7%A8%8B%E8%8C%83%E5%9E%8B)functionallightJS(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/)
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2024-12-25 13:35 , Processed in 1.010473 second(s), 26 queries .

Powered by Discuz! X3.5

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表