函数式编程的理论知识我已经 阐(胡)述(诌) 完了,没看过的小伙伴,能够猛击下面链接开启穿越模式:html
下面我会从如何用 FP
编写高质量的函数、分析源码里面的技巧,以及实际工做中如何编写,来展现如何打通你的任督二脉。linux
话很少说,下面就开始实战吧。 git
FP
编写高质量的函数这里我经过简单的 demo
来讲明一些技巧。技巧点以下:github
那你就要注意了,这多是一个硬编码,不够灵活性,你可能须要进行处理了,如何处理呢?好比经过传参来干掉值类型的变量,下面举一个简单的例子。编程
代码以下:设计模式
document.querySelector('#msg').innerHTML = '<h1>Hello World'</h1>'
复制代码
咱们来欣赏一下上面的代码,我来吐槽几句:数组
第一:硬编码味道很重,代码都是写死的。缓存
第二:扩展性不好,复用性很低,难道我要在其余地方进行 crtl c
ctrl v
而后再手工改?bash
第三:若是我在 document.querySelector('#msg')
拿到对象后,不想 innerHTML
,我想作一些其余的事情,怎么办?
看了上面的三点,是否是感受很 DT
。OK
,下面我就先向你们展现一下,如何彻底重构这段代码。这里我只写 JS
部分:
代码以下:
// 使用到了组合函数,运用了函数的高阶性等
const compose = (...fns) => value => fns.reverse().reduce((acc, fn) => fn(acc), value)
const documentWrite = document.write.bind(document)
const createNode = function(text) {
return '<h1>' + text + '</h1>'
}
const setText = msg => msg
const printMessage = compose(
documentWrite,
createNode,
setText
)
printMessage('hi~ godkun')
复制代码
效果如图所示:
完整代码我放在了下面两个地址上,小伙伴可自行查看。
codepen: codepen.io/godkun/pen/…
注意事项一:
compose
函数的执行顺序是从右向左,也就是数据流是从右向左流,你能够把
const printMessage = compose(
documentWrite,
createNode,
setText
)
复制代码
当作是下面这种形式:
documentWrite(createNode(setText(value)))
复制代码
注意事项二:
在 linux
世界里,是遵循 pipe
(管道) 的思想,也就是数据从左向右流,那怎么把上面的代码变成 pipe
的形式呢?
很简单,只须要把 const compose = (...fns) => value => fns.reverse().reduce((acc, fn) => fn(acc), value)
中的 reverse
干掉就行了,写成:
const compose = (...fns) => value => fns.reduce((acc, fn) => fn(acc), value)
复制代码
总结
是否是发现经过用函数式编程进行重构后,这个代码变得很是的灵活,好处大体有以下:
上来我就写了个 简单 的开胃菜?
并不简单,你们好好想想,仔细体会一下。
思考题:这里我甩贴一张小伙伴在群里分享的图:
是否是感到头皮发麻,这是我送个你们的礼物,你们能够尝试把上面图片的代码用函数式进行彻底重构,加油。
下面轻松点,代码 demo
以下:
let arr = [1,3,2,4,5]
function fun(arr) {
let result = arr.sort()
console.log('result', result)
console.log('arr', arr)
}
fun(arr)
复制代码
结果以下图所示:
看上面,你会发现数组 arr
被修改了。因为 fun(arr)
函数中的参数 arr
是引用类型,若是函数体内对此引用所指的数据进行直接操做的话,就会有潜在的反作用,好比原数组被修改了,这种状况下,改怎么办呢?
很简单,在函数体内对 arr
这个引用类型进行建立副本。以下面代码:
let arr = [1,3,2,4,5]
function fun(arr) {
let arrNew = arr.slice()
let result = arrNew.sort()
console.log('result', result)
console.log('arr', arr)
}
fun(arr)
复制代码
经过 slice
来建立一个新的数组,而后对新的数组进行操做,这样就达到了消除反作用的目的。这里我只是举一个例子,可是核心思想我已经阐述出来了,这里已经体现了理论卷中的数据不可变的思想了。
若是函数体内引用变量的变化,会形成超出其做用域的影响,好比上面代码中对 arr
进行操做,影响到了数组 arr
自己 。那这个时候,咱们就须要思考一下,要不要采用不可变的思想,对引用类型进行处理。
注意函数里面有没有大量的
for
循环
为何说这个呢,由于这个很好判断。若是有的话,就要思考一下需不须要对 for
循环进行处理,下文有对 for
循环的专门介绍。
注意函数里面有没有过多的
if/else
也是同样的思想,过多的 if/else
也要根据状况去作相应的处理。
标题的意识其实能够这样理解,对函数进行高阶化处理。当把函数当成参数的时候,也就是把代码自己当成参数了。
什么状况下要考虑高阶化呢。
当你优化到必定地步后,发现仍是不够复用性,这个时候就要考虑将参数进行函数化,这样能够将参数变成能够提供更多功能的函数。
函数的高阶化,每每在其余功能上得以体现,好比柯里化,组合。
经过上面例子的分析,我也向你们展现了如何将函数最小化。经过将大函数拆成多个具备单一职责的小函数,来提升复用性和灵活性。
FP
不是万能的,你们不要认为它很完美,它也有本身的缺点,下面我简单的说两点吧。
进行 FP
时, 若是你使用的不恰当,是会形成性能问题的。好比你递归用的不恰当,好比你柯里化嵌套的过多。
这里我想说的是,在进行 FP
时,不要过分的抽象,过分的抽象会致使可读性变差。
说到函数式编程,那必定要看看 Ramda.js
的源码。ramda.js
的源码搞懂后,函数式编程的思想也就基本没什么问题了。
关于 Ramda.js
能够看一下阮大的博客:
看完了,那开始执行:
git clone git@github.com:ramda/ramda.git
复制代码
而后咱们来分析源码,首先按照常规套路,看一下 source/index.js
文件。
如图所示:
嗯好,我大概知道了,咱们继续分析。
看一下 add.js
import _curry2 from './internal/_curry2';
var add = _curry2(function add(a, b) {
return Number(a) + Number(b);
});
export default add;
复制代码
看上面代码,咱们发现,add
函数被包了一个 _curry2
函数。 下划线表明这是一个内部方法,不暴露成 API
。这时,你再看其余函数,会发现都被包了一个 _curry1/2/3/N
函数。
以下图所示:
从代码中,咱们能够知道,1/2/3/N
表明掉参数个数为 1/2/3/N
的函数的柯里化,并且会发现,全部的 ramda
函数都是通过柯里化的。
咱们思考一个问题,为何
ramda.js
要对函数所有柯里化?
咱们看一下普通的函数 f(a, b, c)
。若是只在调用的时候,传递 a
。会发现,JS
在运行调用时,会将 b
和 c
设置为 undefined
。
从上面咱们能够知道,JS
语言不能原生支持柯里化。非柯里化函数会致使缺乏参数的实参变成 undefined
。继续想会发现,ramda.js
对函数所有柯里化的目的,就是为了优化上面的场景。
下面,咱们看一下 _curry2
代码,这里为了可读性,我对代码进行了改造,我把 _isPlaceholder
去掉了,假设没有占位符,同时把 _curry1
放在函数内,而且对过程进行了相应注释。
二元参数的柯里化,代码以下:
function _curry2(fn) {
return function f2(a, b) {
switch (arguments.length) {
case 0:
return f2;
case 1:
return _curry1(function (_b) {
// 将参数从右到左依次赋值 1 2
// 第一次执行时,是 fn(a, 1)
return fn(a, _b);
});
default:
// 参数长度是 2 时 直接进行计算
return fn(a, b);
}
};
}
function _curry1(fn) {
return function f1(a) {
// 对参数长度进行判断
if (arguments.length === 0) {
return f1;
} else {
// 经过 apply 来返回函数 fn(a, 1)
return fn.apply(this, arguments);
}
};
}
const add = _curry2(function add(a, b) {
return Number(a) + Number(b);
});
// 第一次调用是 fn(a, 1)
let r1 = add(1)
// 第二次调用是 fn(2,1)
let r2 = r1(2)
console.log('sss', r2)
复制代码
完整代码地址以下:
codeopen:codepen.io/godkun/pen/…
上面的代码在关键处已经作了注释,这里我就不过多解释细节了,小伙伴自行领悟。
柯里化的好处
看了上面对 ramda.js
源码中柯里化的分析,是否是有点收获,就像上面说的,柯里化的目的是为了优化在 JS
原生下的一些函数场景。好处以下:
第一:从上面 add
函数能够知道,经过柯里化,可让函数在真正须要计算的时候进行计算,起到了延迟的做用,也能够说体现了惰性思想。
第二:经过对参数的处理,作到复用性,从上面的 add
函数能够知道,柯里化把多元函数变成了一元函数,经过屡次调用,来实现须要的功能,这样的话,咱们就能够控制每个参数,好比提早设置好不变的参数,从而让代码更加灵活和简洁。
PS: 柯里化命名的由来
ramda
中的 compose
和 pipe
-- 组合函数/管道函数本文一开始,我就以一个例子向你们展现了组合函数 compose
和 pipe
的用法。
关于 ramda
中,compose
和 pipe
的实现我这里就再也不分析了,小伙伴本身看着源码分析一下。这里我就简洁说一下组合函数的一些我的见解。
我的对组合(管道也是组合)函数的见解
在我看来,组合是函数式编程的核心,FP
的思想是要函数尽量的小,尽量的保证职责单一。这就直接肯定了组合函数在 FP
中的地位,玩好了组合函数,FP
也就基本上路了。
和前端的组件进行对比来深入的理解组合函数
函数的组合思想是面向过程的一种封装,而前端的组件思想是面对对象的一种封装。
实际工做中,你确定会遇到下面这种接收和处理数据的场景。
代码以下:
// 伪代码
res => {
// name 是字符串,age 是数字
if (res.data && res.data.name && res.data.age) {
// TODO:
}
}
复制代码
上面这样写,看起来好像也没什么问题,可是经不起分析。好比 name
是数字,age
返回的不是数字。这样的话, if
中的判断是能经过的,可是实际结果并非你想要的。
那该怎么办呢?问题不大,跟着我一步步的优化就
OK
了。
res => {
if (res.data && typeof res.data.name === 'string' && typeof res.data.age === 'number') {
// TODO:
}
}
复制代码
看起来是够鲁棒了,可是这段代码过于命令式,没法复用到其余地方,在其余的场景中,还要重写一遍这些代码,很烦。
// is 是一个对象函数 伪代码
res => {
if (is.object(res.data) && is.string(res.data.name) && is.number(res.data.age)) {
// TODO:
}
}
复制代码
可能有人要问,这是函数式编程么。如今我告诉你,这是 FP
,将过程抽象掉的行为也是一种函数式思想。上面代码,提升了复用性,将判断的过程抽象成了 is
的对象函数中,这样在其余地方均可以复用这个 is
。
可是,代码仍是有问题,通常来讲,各个接口的返回数据都是 res.data
这种类型的。因此若是按照上面的代码,咱们会发现,每次都要写 is.object(res.data)
这是不能容忍的一件事。咱们能不能作到不写这个判断呢?
固然能够,你彻底能够在 is
里面加一层对 data
的判断,固然这个须要你把 data
做为参数 传给 is
。
// is 是一个对象函数 伪代码
res => {
if (is.string(res.data, data.name) && is.number(res.data, data.age)) {
// TODO:
}
}
复制代码
按照上面的写法,is
系列函数会对第一个参数进行 object
类型判断,会再次提升复用性。
好像已经很不错了,但其实还远远不够。
为何还远远不够
第一:有 if
语句存在,可能会有人说,if
语句存在有什么的啊。如今我来告诉你,这块有 if
为何很差。是由于 if
语句的 ()
里面,最终的值都会表现成布尔值。因此这块限制的很死,须要解决 if
语句的问题。
第二:is
函数功能单一,只能作到返回布尔值,没法完成调试打印错误处理等功能,若是你想打印和调试,你又得在条件分支里面各类 console.log
,而后这些代码依旧过于命令式,没法重用。其实,咱们想一下,能够知道,这也是由于用了 if
语句形成的。
说完这些问题,那下面咱们来解决吧。
咱们想一下,若是要作到高度抽象和复用的话,首先咱们要把须要的功能罗列一下,大体以下:
第一个功能:检查类型
第二个功能:调试功能,能够自定义 console
的输出形式
第三个功能:处理异常的功能(简单版)
看到上面功能后,咱们想一下函数式思想中有哪些武器能够被咱们使用到。首先怎么把不一样的函数组合在一块儿。
PS:哈哈哈哈,你看你本身无心识间就说出了组合这个词。
是的,你真聪明。如今,如何将小函数组合成一个完成特定功能的函数呢?想一下,你会发现,这里须要用到函数的高阶性,要将函数做为参数传入多功能函数中。ok
,如今咱们知道实现的大体方向了,下面咱们来尝试一下吧。
这里我直接把个人实现过程贴出来了,有相应的注释,代码以下:
/** * 多功能函数 * @param {Mixed} value 传入的数据 * @param {Function} predicate 谓词,用来进行断言 * @param {Mixed} tip 默认值是 value */
function tap(value, predicate, tip = value) {
if(predicate(value)) {
log('log', `{type: ${typeof value}, value: ${value} }`, `额外信息:${tip}`)
}
}
const is = {
undef : v => v === null || v === undefined,
notUndef : v => v !== null && v !== undefined,
noString : f => typeof f !== 'string',
noFunc : f => typeof f !== 'function',
noNumber : n => typeof n !== 'number',
noArray : !Array.isArray,
};
function log(level, message, tip) {
console[level].call(console, message, tip)
}
const res1 = {data: {age: '', name: 'godkun'}}
const res2 = {data: {age: 66, name: 'godkun'}}
// 函数的组合,函数的高阶
tap(res1.data.age, is.noNumber)
tap(res2.data.age, is.noNumber)
复制代码
结果图以下:
会发现当,age
不是 Number
类型的时候,就会打印对应的提示信息,当时 Number
类型的时候,就不会打印信息。
这样的话,咱们在业务中,就能够直接写:
res => {
tap(res.data.age, is.noNumber)
// TODO: 处理 age
}
复制代码
不用 if
语句,若是有异常,看一下打印信息,会一目了然的。
固然这样写确定不能放到生产上的,由于 tap
不会阻止后续操做,我这样写的缘由是:这个 tap
函数主要是用来开发调试的。
可是,若是须要保证不符合的数据须要直接在 tap
处终止,那能够在 tap
函数里面加下 return false
return true
。而后写成下面代码的形式:
res => {
// if 语句中的返回值是布尔值
if (tap(res.data.age, is.noNumber)) {
// TODO: 处理 age
}
}
复制代码
可是这样写,会有个很差的地方。那就是用到了 if
语句,用 if
语句也没什么很差的。但退一步看 tap
函数,你会发现,仍是不够复用,函数内,还存在硬编码的行为。
以下图所示:
存在两点问题:
第一点:把 console
的行为固定死了,致使不能设置 console.error()
等行为
第二点:不能抛出异常,就算类型不匹配,也阻止不了后续步骤的执行
怎么解决呢?
简单分析一下,这里咱们通常但愿,先采用惰性的思想,让一个函数肯定好几个参数,而后,咱们再让这个函数去调用其余不固定的参数。这样作的好处是减小了相同参数的屡次 coding
,由于相同的参数已经内置了,不用我再去传了。
分析到这,你应该有所感悟。你会发现,这样的行为其实就是柯里化,经过将多元函数变成能够一元函数。同时,经过柯里化,能够灵活设置好初始化须要提早肯定的参数,大大提升了函数的复用性和灵活性。
对于柯里化,因为源码分析篇,我已经分析了 ramda
的柯里化实现原理,这里我为了节省代码,就直接使用 ramda
了。
代码以下:
const R = require('ramda')
// 其实这里你能够站在一个高层去把它们想象成函数的重载
// 经过传参的不一样来实现不一样的功能
const tapThrow = R.curry(_tap)('throw', 'log')
const tapLog = R.curry(_tap)(null, 'log')
function _tap(stop, level, value, predicate, error=value) {
if(predicate(value)) {
if (stop === 'throw') {
log(`${level}`, 'uncaught at check', error)
throw new Error(error)
}
log(`${level}`, `{type: ${typeof value}, value: ${value} }`, `额外信息:${error}`)
}
}
const is = {
undef : v => v === null || v === undefined,
notUndef : v => v !== null && v !== undefined,
noString : f => typeof f !== 'string',
noFunc : f => typeof f !== 'function',
noNumber : n => typeof n !== 'number',
noArray : !Array.isArray,
};
function log(level, message, error) {
console[level].call(console, message, error)
}
const res = {data: {age: '66', name: 'godkun'}}
function main() {
// 不开启异常忽略,使用 console.log 的 tapLog 函数
// tapLog(res.data.age, is.noNumber)
// 开启异常忽略,使用 console.log 的 tapThrow 函数
tapThrow(res.data.age, is.noNumber)
console.log('能不能走到这')
}
main()
复制代码
代码地址以下:
关键注释,我已经在代码中标注了。上面代码在第一次进行函数式优化的时候,在组合和高阶的基础上,加入了柯里化,从而让函数变得更有复用性。
PS: 具备柯里化的函数,在我看来,也是体现了函数的重载性。
执行结果以下图所示:
会发现使用 tapThrow
函数时,当类型不匹配的时候,会阻止后续步骤的执行。
此次实践的总结:
我经过屡次优化,向你们展现了,如何一步步的去优化一个函数。从开始的命令式优化,到后面的函数式优化,从开始的普通函数,到后面的逐步使用了高阶、组合、柯里的特性。从开始的有 if/else
语句到后面的逐步干掉它,来得到更高的复用性。经过这个实战,小伙伴能够知道,如何按部就班的使用函数式编程,让代码变得更加优秀。
思考题:上面的代码还能够继续优化,这里就再也不继续分析了,有兴趣的小伙伴能够自行分享,也能够和我私聊交流。在鄙人看来,前期先运用高阶、组合、柯里化作到这样,就已经很不错了。
以前就有各类干掉 for
循环的文章。各类讨论,这里我按照个人见解来解释一下,为何会存在干掉 for
循环这一说。
代码以下:
let arr = [1,2,3,4]
for (let i = 0; i < arr.length; i++) {
// TODO: ...
}
复制代码
咱们看上面这段代码,我来问一个问题:
上面这段代码如何复用到其余的函数中?
稍微想一下,你们确定能够很快的想出来,那就是封装成函数,而后在其余函数中进行调用。
你们为何会这样想呢?
是由于 for
循环是一种命令控制结构,你发现它很难被插入到其余操做中,也发现了 for
循环很难被复用的现实。
其实,当你说出这个答案的时候。这个关于为何要干掉 for
循环的讨论就已经结束了。
由于你在封装 for
循环时,就是在抽象 for
循环,就是在把 for
循环给隐藏掉,就是在把过程给隐藏掉,就是在告诉用户,你只须要调我封装的函数,而不须要关心内部实现。
因而乎,JS
就诞生了诸如 map
filter
reduce
等这种将循环过程隐藏掉的函数。底层本质上仍是用 for
实现的,只不过是把 for
循环隐藏了,若是按照业界内的说话逼格,就是把 for
循环干掉了。这就是声明式编程在前端中的应用之一。因此,其实你们天天都在写函数式编程,只不过你意识不到而已。
三种方式:
第一种:传统的循环结构 - 好比 for
循环
第二种:链式
第三种:函数式组合
这里我就不具体举例子了,很简单,前面的例子基本都涵盖了,小伙伴们自行实践一下。
为何在编写函数时,要考虑缓存?
一句话,避免计算重复值。计算就意味着消耗各类资源,而作重复的计算,就是在浪费各类资源。
纯洁性和缓存有什么关系?
咱们想一下能够知道,纯函数老是为给定的输入返回相同的输出,那既然如此,咱们固然要想到能够缓存函数的输出。
那如何作函数的缓存呢?
记住一句话:给计算结果赋予惟一的键值并持久化到缓存中。
大体 demo
代码:
function mian(key) {
let cache = {}
cache.hasOwnProperty(key) ?
main(key) :
cache[key] = main(key)
}
复制代码
上面代码是一种最简单的利用纯函数来作缓存的例子。下面咱们来实现一个很是完美的缓存函数。
给原生
JS
函数加上自动记忆化的缓存机制
代码以下:
Function.prototype.memorized = () => {
let key = JSON.stringify(arguments)
// 缓存实现
this._cache = this._cache || {}
this._cache[key] = this._cache[key] || this.apply(this, arguments)
return this._cache[key]
}
Function.prototype.memorize = () => {
let fn = this
// 只记忆一元函数
if (fn.length === 0 || fn.length > 1) return fn
return () => fn.memorized.apply(fn, arguments)
}
复制代码
代码地址以下:
经过扩展 Function
对象,咱们就能够充分利用函数的记忆化来实现函数的缓存。
上面函数缓存实现的好处有如下两点:
第一:消除了可能存在的全局共享的缓存
第二:将缓存机制抽象到了函数的内部,使其彻底与测试无关,只须要关系函数的行为便可
如何编写高质量函数系列文章以下(不包含本篇):
这个系列还在持续更新中,欢迎关注,下一篇是关于设计模式的。
能够关注个人掘金博客或者 github
来获取后续的系列文章更新通知。掘金系列技术文章汇总以下,以为不错的话,点个 star 鼓励一下。
我是源码终结者,欢迎技术交流。
也能够进 前端狂想录群 你们一块儿头脑风暴。有想加的,由于人满了,能够先加我好友,我来邀请你进群。
最后:尊重原创,转载请注明出处哈😋