开头:初衷是想写一篇介绍redux的分享,结果阅读源码时发现看懂源码还必须先对函数式编程有一点的了解,结果写着写着就变成了一篇介绍函数式编程的文章,也罢...
函数式编程与js
1. ES6+ 语法对于函数式编程更为友好
2. RxJS 、redux等青睐函数式的库的开始流行,
3. 带有‘函数式’标签的框架开始流行,诸如react.
从react认识函数式编程
这篇文章里我将略去一大堆形式化的概念介绍,重点展示在 JavaScript 中一些常见的写法,从例子讲述到底什么是函数式的代码、函数式代码与一般写法有什么区别、函数式的代码能给我们带来什么好处以及在react全家桶中常见的一些函数式模型都有哪些。
枯燥的概念
"函数式编程"是一种"编程范式",也就是如何编写程序的方法论,就像我们熟知的面向对象编程一样。
我眼中的函数式编程
以函数作为主要载体的编程方式,用函数去拆解、抽象一般的表达式,最小‘可视’单位是函数的一种编码规范,重点体现‘函数式’,
理解命令式or声明式开始
命令式代码的意思就是,我们通过编写一条又一条指令去让计算机执行一些动作,这其中一般都会涉及到很多繁杂的细节,面向的是命令的过程。
与命令式不同,声明式意味着我们要写表达式(命令的意图),而不是一步一步的具体的指示
注:表达式"是一个单纯的运算过程,总是有返回值;"语句"是执行某种操作,没有返回值。函数式编程要求,只使用表达式,不使用语句。也就是说,每一步都是单纯的运算,而且都有返回值。
// 命令式const arr = ['apple', 'pen', 'apple-pen'];for(const i in arr){ const c = arr[i][0]; arr[i] = c.toUpperCase() + arr[i].slice(1);}// 声明式 // 函数式写法function upperFirst(word) { return word[0].toUpperCase() + word.slice(1);}function wordToUpperCase(arr) { return arr.map(upperFirst);}console.log(wordToUpperCase(['apple', 'pen', 'apple-pen']));复制代码
声明式的写法是一个表达式(这里是一个函数表达式),如何进行计数器迭代,返回的数组如何收集,这些细节都隐藏了起来。它指明的是做什么,而不是怎么做。更加清晰和简洁之外,我们可以通过函数命名一眼就能知道它大概做了些什么,而不需要关注它内部的实现
这里(声明式)的一些明显的好处
- 语义更加清晰(依赖简洁有效的命名)
- 可复用性更高(函数为可调用的最小单位)
- 可维护性更好(只需关注表达式的内部的实现,更易定位bug)
- 作用域局限,副作用少(es6之后局部作用域的支持)
为什么称react组件为‘声明式’UI?
function TodoListComponent(props) { return (
- {props.todos.map((message) =>
- )}
很简单的判定方式,每次都有返回值(注意return)
函数是‘一等公民’
这句话解释起来就是:函数和其他js其他数据类型都一样...
再详细一点:你可以像对待任何其 他数据类型一样对待它们——把它们存在数组里,当作参数传递,赋值给变量...等 等。
所以我们将以函数为参数并且返回了另一个函数的函数称之为高阶函数:
- 接受一个或多个函数作为输入
- 输出一个函数
大概的模样:
function gaojiefn(fn) { //... return enhandceFn // 返回一个增强过的函数}复制代码
作用:函数功能的增强
在react中对应高阶函数就是高阶组件(hoc),顾名思义就是一个接受组件为参数并且返回一个组件的函数,它的强大之处在于能够给任意数量的组件提供数据源,并且可以被用来实现逻辑复用。 最最常见的例子:react-redux中的connect函数
connect( state => state.user, { action })(App)复制代码
其作用就是在组件APP外层包裹了一个用以获取redux储存的state和action的容器,我们通常称他们为容器组件。
另外一个典型的例子就是react-router-v4提供的withRouter(),用来包裹任何需要获取路由信息的组件。 当然我们也可以发挥想象,自由发挥,自定义我们的hoc,来公用我们想共享的逻辑:const newCom = (WrapedCom) => { //...公用的逻辑 return}复制代码
总结:高阶组件在react中非常重要,配合es7支持的装饰器写法,强大而优美
没有副作用的纯函数
拿数组操作的 slice 和 splice做比较
var xs = [1,2,3,4,5];// 纯的xs.slice(0,3);//=> [1,2,3]xs.slice(0,3);//=> [1,2,3]xs.slice(0,3);//=> [1,2,3]// 不纯的xs.splice(0,3);//=> [1,2,3]xs.splice(0,3);//=> [4,5]xs.splice(0,3)//=>[]复制代码
这两个函数的作用并无二致——但是注意,它们各自 的方式却大不同。我们说 slice 符合纯函数的定 义是因为对相同的输入它保证能返回相同的输出(每次返回新数组)。而 splice 则会在原先的数组操作,并修改原先的数组。
通过上面的例子 我们大概知道,纯函数大概就是这样子
对于相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用,也不依赖外部环境的状态。
在函数中要怎么做?
不依赖不改变外界环境
// 不纯的var minimum = 21;var checkAge = function(age) {return age >= minimum;};// 纯的var checkAge = function(age) {var minimum = 21;return age >= minimum;};// 不纯的var count = 1function add() { return count + 1}// 纯的function add() { var count = 1 return count + 1}复制代码
在我们从依赖性和改变外界环境的两个角度举了两组例子
不纯的版本中,函数 的结果将取决于 外部变量 甚至 修改外部变量。从而增加了认知负荷,有时候甚至引发bug,这就是为什么我们刚开始写js时,总有人警告我们不要设置全局变量。
在纯函数中他们总只着眼于自己的一亩三分地,不会给别人(程序员)添麻烦。当然这并不是要求我们不要‘副作用’,一个有完整的程序总会产生‘副作用’,关键是我们怎么去优雅的处理它们(在函数式的理念中可以定义一种有用的‘functor’去包裹‘副作用’)
优点:
1.可缓存性
import _ from 'lodash';var sin = _.memorize(x => Math.sin(x));//第一次计算的时候会稍慢一点var a = sin(1);//第二次有了缓存,速度极快var b = sin(1);复制代码
利用了lodash的memorize函数,第一次会在内存在记忆住了sin(1)的值,第二次的时候直接从内存中提取以来加快了运行速度,提升了性能(切记只有纯函数才能这样子储存起来,因为他们对相同的输入有相同的输出)
不知道你有没有用过 reselect 这个库redux state的任意改变都会导致所有容器组件的mapStateToProps的重新调用,进而导致使用到selectors重新计算,但state的一次改变只会影响到部分seletor的计算值,只要这个selector使用到的state的部分未发生改变,selector的计算值就不会发生改变,理论上这部分分计算时间是可以被节省的。 reselect正是用来解决这个问题的,它可以创建一个具有记忆功能的selector,但他们的计算参数并没有发生改变时,不会再次计算,而是直接使用上次缓存的结果。从而优化了性能。
2.可测试性
相同输入=>相同输出 这简直是单元测试梦寐以求的
3.引用透明
纯函数是完全自给自足的,它需要的所有东西明确表示。仔细思考思考这一 点...这种自给自足的好处是什么呢?纯函数的依赖很明确,因此更易于观察 和理解,更加容易定位bug的位置
再谈柯里化
由一道经典面试题入手:
add(1)(2)(3) = 6add(1, 2, 3)(4) = 10add(1)(2)(3)(4)(5) = 15实现一个通用的add函数?复制代码
function add() { var _args = []; return function(){ if(arguments.length === 0) { return _args.reduce(function(a,b) { return a + b; }); } [].push.apply(_args, [].slice.call(arguments)); return arguments.callee; } }复制代码上面的实现,利用闭包的特性,主要目的是想通过数组操作的方法将所有的参数收集在一个数组里,并最终传入reduce将数组里的所有项加起来。因此我们在调用add方法的时候,参数就显得非常灵活(随意组合,无关顺序)。
柯里化==颗粒化
柯里化通常也称部分求值,其含义是给函数分步传递参数,每次传递参数后,部分应用参数,并返回一个更具体的函数接受剩下的参数,中间可嵌套多层这样的接受部分参数函数,逐步缩小函数的适用范围,逐步求解,直至返回最后结果。
通俗的讲:
函数柯里化允许和鼓励你分隔复杂功能变成更小更容易分析的部分。这些小的逻辑单元显然是更容易理解和测试的,然后你的应用就会变成干净而整洁的组合,由一些小单元组成的组合。
优点:
1.提高通用性
当我们把一个复杂逻辑的函数拆分成,一个个更小的逻辑单元时,函数组合(代码服用)将变得更加的灵活且维护测试更简单。
2.延迟执行
3.固定易变因素(bind)
一道面试题,如何用call,apply来实现bind的功能
Object.prototype.bind = function(context) { var _this = this; var args = [].prototype.slice.call(arguments, 1); return function() { return _this.apply(context, args) }}复制代码
bind与call,apply的区别就是bind只绑定,不立即执行,而call,apply则立即回执行,所以这里利用了延迟执行的特性实现了一个bind
下面是redux中关于中间件的应用
这是从生庆哥写的一个自定义中间件
export default store => next => action => { //...省略 return callApi({ url, params, schema, method, json, customHeaders }).then((response) => { if (showLoading) { next(actionWith({ response, type: successType, showLoading: false })); } else { next(actionWith({ response, type: successType })); } Toast.hide(); if (response.result.status !== 'success') { Toast.info(` ${response.result.message} `, 3, null, false); } /** * { errCode, errMsg, result }; */ return response; } next(actionWith(actionObj)); });};复制代码
除去逻辑部分,大概就是这样:
export default store => next => action => { //...逻辑部分 next(action));}复制代码
这就是redux中间件的实现,嵌套了三层函数,分别传递了store、next、action这三个参数,最后返回next(action),现在我们来看看柯里化在其中怎样大显身手的。
为何不在一层函数中同时传递三个参数呢?当然如果只为了传递store、next、action这三个参数我们直接可以写成一层,可是这里中间件的每一层函数将来都会单独运行,所以利用curry函数延迟执行的特性,记忆住每一层函数的返回,形成三个单独函数。
我们再来解释一下为什么要这么麻烦延迟执行?首先先来看一下redux对中间件处理的applymiddleware的源码:function applyMiddleware() { for (var _len = arguments.length, middlewares = Array(_len), _key = 0; _key < _len; _key++) { middlewares[_key] = arguments[_key]; } return function (createStore) { return function (reducer, preloadedState, enhancer) { var store = createStore(reducer, preloadedState, enhancer); var _dispatch = store.dispatch; var chain = []; var middlewareAPI = { getState: store.getState, dispatch: function dispatch(action) { return _dispatch(action); } }; chain = middlewares.map(function (middleware) { return middleware(middlewareAPI); }); _dispatch = _compose2['default'].apply(undefined, chain)(store.dispatch); return _extends({}, store, { dispatch: _dispatch }); }; };}复制代码
- -中间件执行的第一层从这里开始:
var middlewareAPI = { getState: store.getState, dispatch: function dispatch(action) { return _dispatch(action); } }; chain = middlewares.map(function (middleware) { return middleware(middlewareAPI); });复制代码
这里生成一个中间件函数数组,并将middlewareAPI传入,这里其实就是中间件形成的第一步,将store传入,这里还有一个点就是在利用了闭包的原理,中间件的执行过程中若是有改变store的操作,会同步更新middlewareAPI,使得传入每个middleware的store都是最新的
- -第二层的next执行在这里:
_dispatch = _compose2['default'].apply(undefined, chain)(store.dispatch);复制代码
compose是函数式编程中一个重要的功能:‘函数组合’,‘函数组合’也是函数式编程的一个重要且强大的应用
export default function compose(...funcs) { if (funcs.length === 0) { return arg => arg } if (funcs.length === 1) { return funcs[0] } return funcs.reduce((a, b) => (...args) => a(b(...args)))}复制代码
利用reduce函数将middleware组合成嵌套函数,最后结果是这样:_dispatch=mid1(mid2(mid3(...(store.dispathch)))),形成pipe(管道),对最原始的dispatch进行了一个功能的增强
3.-第三层的实现,大家应该都使用过,就像这样:
dispatch(action)复制代码
触发一个action
柯里化真的很强大
总结:函数式的基础的概念大概就是这些,当然还有一些晦涩难懂却很有用的思想没有介绍(比如函子(functor),类型签名(类似ts的类型定义)等等)。因为实在太晦涩难懂。
个人的看法:函数式编程是一种以函数为‘最小可视’的编程思想,思考方式更加贴近人的大脑思考模式(或者称之为 拟人化),但是函数式编程并不是必须的,因为 函数式编程是一种理念大于实践的编码理论知识,并不是所有的理念都适用于现在(比如现在的js),也许将来随着语言的不断发展,会慢慢加深对函数式的支持,到时才能慢慢转理论为实践。