浅析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)
大部分命令式编程语言都支持四种基本的语句:
-
运算语句; -
循环语句(for、while); -
条件分支语句(if else、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 }
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 -> a
const 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 < 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. 全部转为大写。
首先我们站在函数式编程的思维上分析一下整个需求:
-
抽象:每个处理过程都是一个纯函数 -
组合:通过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进一步增强优化:
// 扩展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/)
感谢你的阅读,有任何问题,欢迎评论区留言讨论,互相学习。