开篇我们先来看看以下两段代码:
// 不使用函数的编程
const upperArr = ['HTML', 'CSS', 'JavaScript'];
const lowerArr = [];
for (let i = 0; i < arr.length; i++) {
lowerArr.push(arr[i].toLowerCase());
}
// 使用函数的编程
function toLowerArr(arr) {
const lowerArr = [];
for (let i = 0; i < arr.length; i++) {
lowerArr.push(arr[i].toLowerCase());
}
return lowerArr;
}
对比两段代码,无疑更推荐第二种方式,即借助函数封装业务逻辑,方便复用维护,且能隔离掉外层作用域影响,避免污染到全局变量。有点像是周末去超市购物,买了一堆东西,如果没有个袋子只能靠双手狼狈地抓包。而函数就像那个得加钱的塑料袋,能帮着装下所有的物品,且便于手提搬运,这差不多就是我们初次认识以及使用它的原因。
对比面向对象
面向对象编程天天挂在嘴边时,但大多数 FE,内心「函数才是真爱」。关于「函数编程」与「面向对象」的区别,我们借思考一个小问题来探究二者间的差异。
「问题:如何把大象放进冰箱?」
面向过程:步骤
- 打开冰箱;
- 抱起大象;
- 塞进冰箱;
- 关闭冰箱;
- 视角:从实现的角度去看待问题,关注具体实现;
- 优点:比较简单,无须处理对象实例化;
- 缺点:功能耦合,较难维护,容易产生难以维护的强大函数;
面向对象:对象/接口/类/继承
- 创建冰箱对象,赋予属性:尺寸 | 开门 | 关门 | 存储;
- 创建大象对象,赋予属性:尺寸;
- 调用冰箱方法 -> 开门;
- 调用冰箱方法 -> 存储;
- 调用冰箱方法 -> 关门;
- 视角:从对象的角度去看待问题;
- 优点:易维护与扩展,适用于多人开发的大型项目,有继承/封装/多态,可衍生;
- 缺点:有一定的性能损耗,对象实例化的内存占用,实例对象的属性并不是每次都需要;
对比上述两种编程思路,你会发现我们日常使用的函数编程,恰恰就是面向过程编程。每个函数就是对每一步骤的封装,聚焦于具体实现,而不是关注对客观事物属性特征的抽象。
对二者的区别有个简单认识后,步入我们今天的主题:函数式编程。
函数式编程
函数基础
- 函数可以存储在变量中;
- 函数可作为参数传递;
- 函数可作为返回值返回;
- 函数可像对象拥有属性;
- 函数声明优先赋值(对比变量声明/函数表达式);
- 参数按值传递;
- JavaScript 中函数不支持重载;
函数执行机制
JavaScript 中函数的执行,是以栈的数据结构来进行的。栈的特点是,只有一个出入口,只能从顶部出入栈,遵循 "先进后出,后进先出" 的原则。
- 代码执行时,进入全局环境,将全局上下文入栈;
- 调用函数时,进入函数环境,将该函数上下文入栈;
- 函数调用结束时,进行出栈操作;
- 栈顶存储的是当前正在执行的上下文;
正常的情况下函数的执行就是这样一种出入栈的操作:
function foo() {
function bar() {
return 'I am bar';
}
}
foo();
以上的情况只是理想状态,JavaScript 中 「闭包」 的存在会中断调用结束的函数出栈操作,使得已经结束调用的函数,仍然存在于栈中,占用内存开销。
闭包的产生:产生闭包的场景多是函数作为返回值返回,或作为参数使用,且该函数使用了外部作用域的变量。正常函数调用结束时会销毁变量对象,释放内存空间,但闭包的存在,使得父函数内部变量存在引用,因此会将其保留在内存中。
闭包的作用:
- 延长变量对象的作用域范围;
- 延长变量对象的生命周期;
function foo() {
const fooVal = 'VanTopper';
const bar = function() {
console.log(fooVal); // bar 中使用来 foo 环境的变量
}
return bar; // 函数作为返回值返回
}
const getValue = foo();
getValue(); // -> VanTopper
函数生命周期
创建阶段 | 执行阶段 | 结束阶段 | 1. 创建变量对象 -- 初始化 Arguments 对象(并赋值) -- 函数声明(并赋值) -- 变量声明/函数表达式声明(未赋值) -- 确定 this 指向 2. 确定作用域 | 1. 变量对象赋值 -- 变量赋值 -- 函数表达式赋值 2. 调用函数 3. 执行其它代码 | 变量对象销毁(无闭包场景) |
---|
高阶函数
常用高阶函数: forEach
/ map
/ filter
/ every
/ some
/ find
「高阶函数的作用:抽象屏蔽实现细节,只需关注实现目标」
以 filter
为例,过滤功能的实现需要遍历数组,对每一项进行比对。遍历是固定不变的部分,但比对逻辑是可变的。我们可将不变部分固化,「程序的封装,首先都是从 不变 部分开始」。抽象提取遍历过程,并为比对逻辑提供配置入口, 使得 filter
方法调用者只需关注比对逻辑,无须每次重复写遍历过程。
// 高阶函数用法:简易实现 filter
function filter(arr, fn) {
const newArr = [];
for (let i = 0; i < arr.length; i++) {
const result = fn(arr[i], i);
if (result) {
newArr.push(result);
}
}
return newArr;
}
filter([-2, -1, 0, 1, 2], (val) => val > 0); // -> [1, 2]
纯函数
副作用来源
所有与外部交互都可能带来副作用,一般包括:
- 外部配置文件;
- 数据库存储的数据;
- 用户的输入数据;
- 函数内部源数据修改;
/* ----- 纯函数 start ----- */
function sum(a, b) {
return a + b;
}
sum(1, 2); // 3
sum(1, 2); // 3
sum(1, 2); // 3
/* ----- 非纯函数 start ----- */
// 不遵守固定的输入、固定的输出
function getRandom() {
// 每次返回值不一样
return Math.random();
}
// 函数内直接修改了数据
function formatData(arr) {
for (let item of arr) {
// 直接在原数组对象修改
item.visible = item.visible ? 1 : 0
}
}
优点/作用:(首先我很纯)
- 稳定输出,可靠辅助(固定输入、固定输出);
- 不抢兵不抢人头(无副作用、不污染数据);
- 可作缓存提升性能(以参数为键值缓存计算结果,避免重复运算,相同参数调用仅计算一次);
- 可作测试(多次调用,数据不反复横跳);
/*
实现带缓存计算的功能函数
1. 借助纯函数固定输入固定输出,保证参数相同时多次调用结果相同
2. 借助闭包维护缓存对象,延长其作用域范围与生命周期
*/
// 缓存函数
function memoize(fn) {
const cache = {};
return function() {
const key = JSON.stringify(arguments);
cache[key] = cache[key] || fn.apply(fn, arguments);
return cache[key];
}
}
//功能函数
function getArea(r) {
console.log('Computed...');
return Math.PI * r * r;
}
const getAreaWithMemory = memoize(getArea);
getAreaWithMemory(4); // 'Computed' 参数相同时 log 仅执行一次
getAreaWithMemory(4); //
getAreaWithMemory(4); //
柯里化
一般函数调用时,都是一次性计算求值。如果形参与实参数量不匹配,且未指定默认值(内部未作参数缺省处理),是无法正常调用。 例如:((a, b) => a + b)(10);
而「函数柯里化」是一种支持 预先传参,可进行 部分计算 的一种函数编程方法。
举个栗子:「存款买房」,假设我们有一个目标是买房,那么在买房前,资金不够时我们可采取存钱的方法作积累。等到钱存够了,再使用存款,进行买房操作。柯里化,就像我们去银行存钱,银行给你一张银行卡,可以让你继续往里面存钱(预先传参),每一次存钱动作都会增加卡里的余额(部分计算),等钱的数量足够支付时,就可进行取款支付了。
列举两个公式,方便对比下与普通函数不同的调用方式:
// 公式类型一 左边:普通函数 | 右边:柯里化
fn(a, b, c, d) => fn(a)(b)(c)(d);
fn(a, b, c, d) => fn(a, b)(c)(d);
fn(a, b, b, d) => fn(a)(b, c, d);
// 公式类型二
fn(a, b, c, d) => fn(a)(b)(c)(d)();
fn(a, b, c, d) => fn(a);fn(b);fn(c);fn(d);fn();
通过上述公式的转化,我们可以得出柯里化的几个特性:
- 部分传参
- 提前返回
- 延迟执行
// 柯里化方法实现
function curry(fn) {
return function curriedFn(...args) {
if (args.length < fn.length) {
return function() {
return curriedFn(...args.concat(Array.from(arguments)))
}
}
return fn(...args)
}
}
柯里化应用
- 改变普通函数足额传参的调用方式;
- 预传参的方式,缓存参数;
- 衍生出更具语义,更方便调用的函数;
// 验证函数
function checkValid(regex, value) {
return regex.test(value);
}
// 验证手机号码
checkValid(/^1[3456789]\d{9}$/, value);
// 验证电话号码
checkValid(/^([0-9]{3,4}-)?[0-9]{7,8}$/, value);
虽然 checkValid
基本实现功能,但调用方式很不友好,使用者每次调用都需要输入「记不住且复杂」 的正则表达式,有点反人类。针对这种场景,我们可借助柯里化,进行如下改造:
// 柯里化处理后 (借助 lodash 的 _.curry 方法,跟上文自实现的 curry 功能一样)
const curryCheckValid = _.curry(checkValid);
const checkMobile = curryCheckValid(/^1[3456789]\d{9}$/);
const checkPhone = curryCheckValid(/^([0-9]{3,4}-)?[0-9]{7,8}$/);
checkMobile(value);
checkPhone(value);
经过柯里化处理后衍生出的 checkMobile
与 checkPhone
对比 checkValid
,明显对调用者更友好,更符合最小知识原则,降低使用成本。
柯里化总结
优点:
- 允许部分传参,固定易变因素(提前计算,可作缓存);
- 返回可处理剩余参数的函数(留好接班人);
- 可将多元函数转化为一元或少元函数(结合函数组合发挥实力);
- 衍生出粒度更小,单一职责的函数(使用者学习成本降低);
缺点:
- 闭包的存在,带来的内存占用开销;
- 嵌套作用域带来的开销、影响作用域链查找速度;
函数组合 f(g(v))
组合的思想:「管道组装」
如图,一条大管道包含了所需功能,但是这种大管道,因尺寸问题需做切割时会造成很多边角料浪费,如果中间出现问题,只能全部更换。而拆分成多节小管道,不仅方便长度定制化,能节约浪费成本,且降低了问题排查和维修替换的成本。
函数组合就是引用这种小管道拼接的理念,每一个小管道都是一个小函数,然后组合成一个大函数。调用者不用关心中间这些小函数的处理结果,只关注头部入参和尾部处理结果,「重结果,轻过程」。
一般函数组合中函数执行过程分为两种:「从左往右」 和 「从右往左」,默认是 「从右往左」,请看以下代码的实现:
// 简易组合函数(这里的实现就是简单的套函数)
function compose(f, g) {
return function(value) {
return f(g(value));
};
}
const first = arr => arr[0];
const reverse = arr => arr.reverse();
// 获取数组最后一个元素
const getLast = compose(first, reverse);
getLast('Angular', 'React', 'Vue']; // -> Vue
函数组合看似把简单的问题复杂化,针对上述业务要求,完全可以在一个函数中去实现。拆分函数反而增加了工作量,但存在必有其合理性。
日常开发中为实现业务功能,不少伙伴经常写出这种一个个「功能很强大」的函数,融合所有逻辑一起解决。但是这种函数复用性却非常低,因为该函数耦合了太多功能依赖,如果需要复用其中部分功能时,可能选择自行再创建一个新函数会更加稳妥。而这种不必要的函数增加,就会存在很多功能类似又区别的代码存在,无形增加冗余。而且复杂函数往往参数都非单一,需要使用者有更多的参数学习成本。
函数组合还有一个作用是「分治」,将函数功能拆成单一化,粒度更细的小函数。采用乐高积木的理念,去组装成更强大的函数。而且这些小功能函数往往只有一次性创建成本,复用性极强。
另一个优势就是开源第三方工具函数,像 lodash/underscore 早已提供了这些基础函数,无须我们再自行维护。
// 使用 lodash 改写 getLast
// 这里 _.flowRight 实现的就是我们上面自建 componse 函数功能
const getLast = _.flowRight(_.first, _.reverse);
getLast(['Angular', 'React', 'Vue'); // -> Vue
组合规则
- 将多个函数组合成新的函数,忽略中间部分,只看最后结果;
- 满足结合律,结合顺序不同,结果相同(既可以把 f1 和 f2 组合,也可把 f1 和 f3 组合);
- 函数组合中每个函数需为一元函数,且最后组合成的函数也是一元函数(细粒度、小体量);
- 执行顺序:从右到左(默认)/ 从左到右;
函数组合调试
函数组合的特点是将处理的结果层层传递,逐步传给下一个函数。依据它的原理,我们可实现一个 log 函数作为中间函数插入,添加本身打印调试功能同时,需要将处理值透传下去。
const getLast = _.flowRight(_.first, log, _.reverse);
getLast(['Angular', 'React', 'Vue']); // 我们就能打印出 _.reverse 处理结果
// 调试函数
function log(value) {
console.log(value);
return value;
}
lodash/fp
先看个小问题,有类似的面试题,考点有 map
的参数与 parseInt
进制转换。
// lodash
_.map(['10', '10', '10'], parseInt);
// -> [10, NaN, 2]
// lodash/fp
fp.map(parseInt, ['10', '10', '10']);
// -> [10, 10, 10]
lodash 默认方法是数据优先,也就是数据先行,所以上述 _.map
执行如下:
_.map(['10', '10', '10'], (val, index) => parseInt(val, index));
parseInt('10', 0); // -> 10
parseInt('10', 1); // -> NaN
parseInt('10', 2); // -> 2
MSDN -> parseInt 转化规则
- lodash 默认方法(数据优先,函数置后)
- lodash/fp 模块方法(函数优先,数据置后,自动柯里化)
三种实现方式比对
const str = 'NEVER SAY GOODBYE';
/* ---- 原生实现 ---- */
const transByDefault = (str) => str.toLowerCase().split(' ').join('-');
/* ---- lodash ---- */
const join = _.curry((seq, str) => _.join(str, seq));
const split = _.curry((seq, str) => _.split(str, seq));
const transByLodash = _.flowRight(join('-'), split(' '), _.lowerCase);
/* ---- lodash/fp ---- */
const fp = _.noConflict();
const transByFp = fp.flowRight(fp.join('-'), fp.slit(' '), fp.lowerCase);
// 调用
transByDefault(str); // -> never-say-goodbye
transByLodash(str); // -> never-say-goodbye
transByFp(str); // -> never-say-goodbye
原生实现 | lodash实现 | lodash/fp实现 | 功能耦合,函数复用性低 | - 支持函数组合 - 数据优先,函数置后 - 多元函数需自行柯里化处理 | - 多元函数自动柯里化处理 - 函数优先,数据置后,无需额外处理 |
---|
函数组合总结
优点:
- 合并预算过程,组成强大的功能函数(灵活组合);
- 忽略中间处理过程,只关注最终运算结果(只要结果);
- 增强函数复用性,减少强功能函数;
缺点:
- 需要一些辅助的基本函数(可借助第三方工具库提供的常用工具函数);
- 需定义插入 log 函数进行中间结果调试;
本篇文章仅是「函数式编程」的管中窥豹,还有诸如偏函数、函子等函数编程的知识并未涉及到,感兴趣的小伙伴可继续再深入学习。
以上便是本次分享的全部内容,希望对你有所帮助^_^
喜欢的话别忘了动动手指,点赞、收藏、关注三连一波带走。
关于我们
我们是万拓科创前端团队,左手组件库,右手工具库,各种技术野蛮生长。
一个人跑得快,不如一群人跑得远。欢迎加入我们的小分队,牛年牛气轰轰往前冲。
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!