最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 函数式编程的那些事

    正文概述 掘金(VANTOP前端团队)   2021-03-29   731

    函数式编程的那些事

    开篇我们先来看看以下两段代码:

    // 不使用函数的编程
    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,内心「函数才是真爱」。关于「函数编程」与「面向对象」的区别,我们借思考一个小问题来探究二者间的差异。

    「问题:如何把大象放进冰箱?」

    面向过程:步骤

    1. 打开冰箱;
    2. 抱起大象;
    3. 塞进冰箱;
    4. 关闭冰箱;
    • 视角:从实现的角度去看待问题,关注具体实现;
    • 优点:比较简单,无须处理对象实例化;
    • 缺点:功能耦合,较难维护,容易产生难以维护的强大函数;

    面向对象:对象/接口/类/继承

    1. 创建冰箱对象,赋予属性:尺寸 | 开门 | 关门 | 存储;
    2. 创建大象对象,赋予属性:尺寸;
    3. 调用冰箱方法 -> 开门;
    4. 调用冰箱方法 -> 存储;
    5. 调用冰箱方法 -> 关门;
    • 视角:从对象的角度去看待问题;
    • 优点:易维护与扩展,适用于多人开发的大型项目,有继承/封装/多态,可衍生;
    • 缺点:有一定的性能损耗,对象实例化的内存占用,实例对象的属性并不是每次都需要;

    对比上述两种编程思路,你会发现我们日常使用的函数编程,恰恰就是面向过程编程。每个函数就是对每一步骤的封装,聚焦于具体实现,而不是关注对客观事物属性特征的抽象。

    对二者的区别有个简单认识后,步入我们今天的主题:函数式编程。

    函数式编程

    函数基础

    1. 函数可以存储在变量中;
    2. 函数可作为参数传递;
    3. 函数可作为返回值返回;
    4. 函数可像对象拥有属性;
    5. 函数声明优先赋值(对比变量声明/函数表达式);
    6. 参数按值传递;
    7. JavaScript 中函数不支持重载;

    函数执行机制

    JavaScript 中函数的执行,是以栈的数据结构来进行的。栈的特点是,只有一个出入口,只能从顶部出入栈,遵循 "先进后出,后进先出" 的原则。

    1. 代码执行时,进入全局环境,将全局上下文入栈;
    2. 调用函数时,进入函数环境,将该函数上下文入栈;
    3. 函数调用结束时,进行出栈操作;
    4. 栈顶存储的是当前正在执行的上下文;

    正常的情况下函数的执行就是这样一种出入栈的操作:

    function foo() {
        function bar() {
            return 'I am bar';
        }
    }
    foo();
    

    函数式编程的那些事 以上的情况只是理想状态,JavaScript 中 「闭包」 的存在会中断调用结束的函数出栈操作,使得已经结束调用的函数,仍然存在于栈中,占用内存开销。

    闭包的产生:产生闭包的场景多是函数作为返回值返回,或作为参数使用,且该函数使用了外部作用域的变量。正常函数调用结束时会销毁变量对象,释放内存空间,但闭包的存在,使得父函数内部变量存在引用,因此会将其保留在内存中。

    闭包的作用:

    1. 延长变量对象的作用域范围;
    2. 延长变量对象的生命周期;
    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]
    

    纯函数

    副作用来源

    所有与外部交互都可能带来副作用,一般包括:

    1. 外部配置文件;
    2. 数据库存储的数据;
    3. 用户的输入数据;
    4. 函数内部源数据修改;
    /* ----- 纯函数 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. 不抢兵不抢人头(无副作用、不污染数据);
    3. 可作缓存提升性能(以参数为键值缓存计算结果,避免重复运算,相同参数调用仅计算一次);
    4. 可作测试(多次调用,数据不反复横跳);
    /*
     实现带缓存计算的功能函数
     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 ,明显对调用者更友好,更符合最小知识原则,降低使用成本。

    柯里化总结

    优点:

    1. 允许部分传参,固定易变因素(提前计算,可作缓存);
    2. 返回可处理剩余参数的函数(留好接班人);
    3. 可将多元函数转化为一元或少元函数(结合函数组合发挥实力);
    4. 衍生出粒度更小,单一职责的函数(使用者学习成本降低);

    缺点:

    1. 闭包的存在,带来的内存占用开销;
    2. 嵌套作用域带来的开销、影响作用域链查找速度;

    函数组合 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
    

    组合规则

    1. 将多个函数组合成新的函数,忽略中间部分,只看最后结果;
    2. 满足结合律,结合顺序不同,结果相同(既可以把 f1 和 f2 组合,也可把 f1 和 f3 组合);
    3. 函数组合中每个函数需为一元函数,且最后组合成的函数也是一元函数(细粒度、小体量);
    4. 执行顺序:从右到左(默认)/ 从左到右;

    函数组合调试

    函数组合的特点是将处理的结果层层传递,逐步传给下一个函数。依据它的原理,我们可实现一个 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实现
    功能耦合,函数复用性低
    - 支持函数组合
    - 数据优先,函数置后
    - 多元函数需自行柯里化处理

    - 多元函数自动柯里化处理
    - 函数优先,数据置后,无需额外处理

    函数组合总结

    优点:

    1. 合并预算过程,组成强大的功能函数(灵活组合);
    2. 忽略中间处理过程,只关注最终运算结果(只要结果);
    3. 增强函数复用性,减少强功能函数;

    缺点:

    1. 需要一些辅助的基本函数(可借助第三方工具库提供的常用工具函数);
    2. 需定义插入 log 函数进行中间结果调试;

    本篇文章仅是「函数式编程」的管中窥豹,还有诸如偏函数、函子等函数编程的知识并未涉及到,感兴趣的小伙伴可继续再深入学习。

    以上便是本次分享的全部内容,希望对你有所帮助^_^

    喜欢的话别忘了动动手指,点赞、收藏、关注三连一波带走。


    关于我们

    我们是万拓科创前端团队,左手组件库,右手工具库,各种技术野蛮生长。

    一个人跑得快,不如一群人跑得远。欢迎加入我们的小分队,牛年牛气轰轰往前冲。


    起源地下载网 » 函数式编程的那些事

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元