当前位置 : 主页 > 网络编程 > JavaScript >

从柯里化分析JavaScript重要的高阶函数实例

来源:互联网 收集:自由互联 发布时间:2023-01-17
目录 前情回顾 百变柯里化 缓存传参 缓存判断 缓存计算 缓存函数 防抖与节流 lodash 高阶函数 结语 前情回顾 我们在前篇 《✨从历史讲起,JavaScript 基因里写着函数式编程》 讲到了 J
目录
  • 前情回顾
  • 百变柯里化
    • 缓存传参
    • 缓存判断
    • 缓存计算
    • 缓存函数
  • 防抖与节流
    • lodash 高阶函数
      • 结语

        前情回顾

        我们在前篇 《✨从历史讲起,JavaScript 基因里写着函数式编程》 讲到了 JavaScript 的函数式基因最早可追溯到 1930 年的 lambda 运算,这个时间比第一台计算机诞生的时间都还要早十几年。JavaScript 闭包的概念也来源于 lambda 运算中变量的被绑定关系。

        因为在 lambda 演算的设定中,参数只能是一个,所以通过柯里化的天才想法来实现接收多个参数:

        lambda x. ( lambda y. plus x y )
        

        说这个想法是“天才”一点不为过,把函数自身作为输入参数或输出返回值,至今受用,也就是【高阶函数】的定义。

        将上述 lambda 演算柯里化写法转变到 JavaScript 中,就变成了:

        function add(a) {
            return function (b) {
                return a + b
            }
        }
        add(1)(2)
        

        所以,剖析闭包从柯里化开始,柯里化是闭包的“孪生子”。

        读完本篇,你会发现 JavaScript 高阶函数中处处是闭包、处处是柯里化~

        百变柯里化

        最开始,本瓜理解 柯里化 == 闭包 + 递归,得出的柯里化写法是这样的:

         let arr = []
         function addCurry() {
             let arg = Array.prototype.slice.call(arguments); // 递归获取后续参数
             arr = arr.concat(arg);
              if (arg.length === 0) { // 如果参数为空,则判断递归结束
                  return arr.reduce((a,b)=>{return a+b}) // 求和
              } else {
                  return addCurry;
              }
          }
        addCurry(1)(2)(3)()
        

        但这样的写法, addCurry 函数会引用一个外部变量 arr,不符合纯函数的特性,于是就优化为:

        function addCurry() {
            let arr = [...arguments]
            let fn = function () {
                if(arguments.length === 0) {
        	    return arr.reduce((a, b) => a + b)
                } else {
                    arr.push(...arguments)
                    return fn
                }
            }
            return fn
        }
        

        上述写法,又总是要以 ‘( )’ 空括号结尾,于是再改进为隐式转换 .toString 写法:

        function addCurry() {
            let arr = [...arguments]
            // 利用闭包的特性收集所有参数值
            var fn = function() {
                arr.push(...arguments);
                return fn;
            };
            // 利用 toString 隐式转换
            fn.toString = function () {
                return arr.reduce(function (a, b) {
                    return a + b;
                });
            }
            return fn;
        }
        
        • 注意一些旧版本的浏览器隐式转换会默认执行

        好了,到这一步,如果你把上述三种柯里化写法都会手写了,那面试中考柯里化的基础一关算是过了。

        然而,不止于此,柯里化实际存在很多变体, 只有深刻吃透它的思想,而非停留在一种写法上,才能算得上“高级”、“优雅”。

        接下来,让我们看看它怎么变?!

        缓存传参

        柯里化最基础的用法是缓存传参。

        我们经常遇到这样的场景:

        已知一个 ajax 函数,它有 3 个参数 url、data、callback

        function ajax(url, data, callback) {
          // ...
        }
        

        不用柯里化是怎样减少传参的呢?通常是以下这样,写死参数位置的方式来减少传参:

        function ajaxTest1(data, callback) {
          ajax('http://www.test.com/test1', data, callback);
        }
        

        而通过柯里化,则是这样:

        function ajax(url, data, callback) {
          // ...
        }
        let ajaxTest2 = partial(ajax,'http://www.test.com/test2')
        ajaxTest2(data,callback)
        

        其中 partial 函数是这样写的:

        function partial(fn, ...presetArgs) { // presetArgs 是需要先被绑定下来的参数
          return function partiallyApplied(...laterArgs) { //  ...laterArgs 是后续参数
                let allArgs =presetArgs.concat(laterArgs) // 收集到一起
                return fn.apply(this, allArgs) // 传给回调函数 fn
          }
        }
        

        柯里化固定参数的好处在:复用了原本的 ajax 函数,并在原有基础上做了修改,取其精华,弃其糟粕,封装原有函数之后,就能为我所用。

        并且 partial 函数不止对 ajax 函数有作用,对于其它想减少传参的函数同样适用。

        缓存判断

        我们可以设想一个通用场景,假设有一个 handleOption 函数,当符合条件 'A',执行语句:console.log('A');不符合时,则执行语句:console.log('others')

        转为代码即:

        const handleOption = (param) =>{
             if(param === 'A'){
                 console.log('A')
             }else{
                 console.log('others')
             }
        }
        

        现在的问题是:我们每次调用 handleOption('A'),都必须要走完 if...else... 的判断流程。比如:

        const handleOption = (param) =>{
             console.log('每次调用 handleOption 都要执行 if...else...')
             if(param === 'A'){
                 console.log('A')
             }else{
                 console.log('others')
             }
        }
        handleOption('A')
        handleOption('A')
        handleOption('A')
        

        控制台打印:

        有没有什么办法,多次调用 handleOption('A'),却只走一次 if...else...?

        答案是:柯里化。

        const handleOption = ((param) =>{
             console.log('从始至终只用执行一次 if...else...')
             if(param === 'A'){
                 return ()=>console.log('A')
             }else{
                 return ()=>console.log('others')
             }
        })
        const tmp = handleOption('A')
        tmp()
        tmp()
        tmp()
        

        控制台打印:

        这样的场景是有实战意义的,当我们做前端兼容时,经常要先判断是来源于哪个环境,再执行某个方法。比如说在 firefox 和 chrome 环境下,添加事件监听是 addEventListener 方法,而在 IE 下,添加事件是 attachEvent 方法;如果每次绑定这个监听,都要判断是来自于哪个环境,那肯定是很费劲。我们通过上述封装的方法,可以做到 一处判断,多次使用。

        肯定有小伙伴会问了:这也是柯里化?

        嗯。。。怎么不算呢?

        把 'A' 条件先固定下来,也可叫“缓存下来”,后续的函数执行将不再传 'A' 这个参数,实打实的:把多参数转化为单参数,逐个传递。

        缓存计算

        我们再设想这样一个场景,现在有一个函数是来做大数计算的:

        const calculateFn = (num)=>{
            const startTime = new Date()
            for(let i=0;i<num;i++){} // 大数计算
            const endTime = new Date()
            console.log(endTime - startTime)
            return "Calculate big numbers"
        }
        calculateFn(10_000_000_000)
        

        这是一个非常耗时的函数,在控制台看看,需要 8s+

        如果业务代码中需要多次用到这个大数计算结果,多次调用 calculateFn(10_000_000_000) 肯定是不明智的,太费时。

        一般的做法就是声明一个全局变量,把运算结果保存下来:

        比如 const resNums = calculateFn(10_000_000_000)

        如果有多个大数运算呢?沿着这个思路,即声名多个变量:

        const resNumsA = calculateFn(10_000_000_000)
        const resNumsB = calculateFn(20_000_000_000)
        const resNumsC = calculateFn(30_000_000_000)
        

        我们讲就是说:奥卡姆剃刀原则 —— 如无必要、勿增实体。

        申明这么多全局变量,先不谈占内存、占命名空间这事,就把 calculateFn() 函数的参数和声名的常量名一一对应,都是一个麻烦事。

        有没有什么办法?只用函数,不增加多个全局常量,就实现多次调用,只计算一次?

        答案是:柯里化。

        代码如下:

        function cached(fn){
          const cacheObj = Object.create(null); // 创建一个对象
          return function cachedFn (str) { // 返回回调函数
            if ( !cacheObj [str] ) { // 在对象里面查询,函数结果是否被计算过
                let result = fn(str);
                cacheObj [str] = result; // 没有则要执行原函数,并把计算结果缓存起来
            }
            return cacheObj [str] // 被缓存过,直接返回
          }
        }
        const calculateFn = (num)=>{
            console.log("计算即缓存")
            const startTime = new Date()
            for(let i=0;i<num;i++){} // 大数计算
            const endTime = new Date()
            console.log(endTime - startTime) // 耗时
            return "Calculate big numbers"
        }
        let cashedCalculate = cached(calculateFn) 
        console.log(cashedCalculate(10_000_000_000)) // 计算即缓存 // 9944 // Calculate big numbers
        console.log(cashedCalculate(10_000_000_000)) // Calculate big numbers
        console.log(cashedCalculate(20_000_000_000)) // 计算即缓存 // 22126 // Calculate big numbers
        console.log(cashedCalculate(20_000_000_000)) // Calculate big numbers
        

        这样只用通过一个 cached 缓存函数的处理,所有的大数计算都能保证:输入参数相同的情况下,全局只用计算一次,后续可直接使用更加语义话的函数调用来得到之前计算的结果。

        此处也是柯里化的应用,在 cached 函数中先传需要处理的函数参数,后续再传入具体需要操作得值,将多参转化为单个参数逐一传入。

        缓存函数

        柯里化的思想不仅可以缓存判断条件,缓存计算结果、缓存传参,还能缓存“函数”。

        设想,我们有一个数字 7 要经过两个函数的计算,先乘以 10 ,再加 100,写法如下:

        const multi10 = function(x) { return x * 10; }
        const add100 = function(x) { return x + 100; }
        add100(multi10(7))
        

        用柯里化处理后,即变成:

        const multi10 = function(x) { return x * 10; }
        const add100 = function(x) { return x + 100; }
        const compose = function(f,g) { 
            return function(x) { 
                return f(g(x))
            }
        }
        compose(add100, multi10)(7)
        

        前者写法有两个传参是写在一起的,而后者则逐一传参。把最后的执行函数改写:

        let compute = compose(add100, multi10)
        compute(7)
        

        所以,这里的柯里化直接把函数处理给缓存了,当声明 compute 变量时,并没有执行操作,只是为了拿到 ()=> f(g(x)),最后执行 compute(7),才会执行整个运算;

        怎么样?柯里化确实百变吧?柯里化的起源和闭包的定义是同宗同源。正如前文最开始所说,柯里化是闭包的一对“孪生子”。

        我们对闭包的解释:“闭包是一个函数内有另外一个函数,内部的函数可以访问外部函数的变量,这样的语法结构是闭包。”与我们对柯里化的解释“把接受多个参数的函数变换成接受一个单一参数(或部分)的函数,并且返回接受余下的参数和返回结果的新函数的技术”,这两种说法几乎是“等效的”,只是从不同角度对 同一问题 作出的解释,就像 lambda 演算和图灵机对希尔伯特第十问题的解释一样。

        同一问题:指的是在 lambda 演算诞生之时,提出的:怎样用 lambda 演算实现接收多个参数?

        防抖与节流

        好了,我们再来看看除了其它高阶函数中闭包思想(柯里化思想)的应用。首先是最最常用的防抖与节流函数。

        防抖:就像英雄联盟的回城键,按了之后,间隔一定秒数才会执行生效。

        function debounce(fn, delay) {
            delay = delay || 200;
            let timer = null;
            return function() {
                let arg = arguments;
                // 每次操作时,清除上次的定时器
                clearTimeout(timer);
                timer = null;
                // 定义新的定时器,一段时间后进行操作
                timer = setTimeout(function() {
                    fn.apply(this, arg);
                }, delay);
            }
        };
        var count = 0;
        window.onscroll = debounce(function(e) {
            console.log(e.type, ++count); // scroll
        }, 500);
        

        节流函数:就像英雄联盟的技能键,是有 CD 的,一段时间内只能按一次,按了之后就要等 CD;

        // 函数节流,频繁操作中间隔 delay 的时间才处理一次
        function throttle(fn, delay) {
            delay = delay || 200;
            let timer = null;
            // 每次滚动初始的标识
            let timestamp = 0;
            return function() {
                let arg = arguments;
                let now = Date.now();
                // 设置开始时间
                if (timestamp === 0) {
                    timestamp = now;
                }
                clearTimeout(timer);
                timer = null;
                // 已经到了delay的一段时间,进行处理
                if (now - timestamp >= delay) {
                    fn.apply(this, arg);
                    timestamp = now;
                }
                // 添加定时器,确保最后一次的操作也能处理
                else {
                    timer = setTimeout(function() {
                        fn.apply(this, arg);
                        // 恢复标识
                        timestamp = 0;
                    }, delay);
                }
            }
        };
        var count = 0;
        window.onscroll = throttle(function(e) {
            console.log(e.type, ++count); // scroll
        }, 500);
        

        代码均可复制到控制台中测试。在防抖和节流的场景下,被预先固定住的变量是 timer

        lodash 高阶函数

        lodash 大家肯定不陌生,它是最流行的 JavaScript 库之一,透过函数式编程模式为开发者提供常用的函数。

        其中有一些封装的高阶函数,让一些平平无奇的普通函数也能有相应的高阶功能。

        举几个例子:

        // 防抖动
        _.debounce(func, [wait=0], [options={}])
        // 节流
        _.throttle(func, [wait=0], [options={}])
        // 将一个断言函数结果取反
        _.negate(predicate) 
        // 柯里化函数
        _.curry(func, [arity=func.length])
        // 部分应用
        _.partial(func, [partials])
        // 返回一个带记忆的函数
        _.memoize(func, [resolver])
        // 包装函数
        _.wrap(value, [wrapper=identity])
        

        研究源码你就会发现,_.debounce 防抖、_.throttle 节流上面说过,_.curry 柯里化上面说过、_.partial 在“缓存传参”里说过、_.memoize 在“缓存计算”里也说过......

        再举一个例子:

        现在要求一个函数在达到 n 次之前,每次都正常执行,第 n 次不执行。

        也是非常常见的业务场景!JavaScript 实现:

        function before(n, func) {
          let result, count = n;
          return function(...args) {
            count = count - 1
            if (count > 0) result = func.apply(this, args)
            if (count <= 1) func = undefined
            return result
          }
        }
        const fn= before(3,(x)=>console.log(x))
        fn(1) // 1
        fn(2) // 2
        fn(3) // 不执行
        

        反过来:函数只有到 n 次的时候才执行,n 之前的都不执行。

        function after(n, func) {
          let count = n || 0
          return function(...args) {
            count = count - 1
            if (count < 1) return func.apply(this, args)
          }
        }
        const fn= after(3,(x)=>console.log(x))
        fn(1) // 不执行
        fn(2) // 不执行
        fn(3) // 3 
        

        全是“闭包”、全是把参数“柯里化”。

        细细体会,在控制台上敲一敲、改一改、跑一跑,下次或许你就可以自己写出这些有特定功能的高阶函数了。

        结语

        综合以上,可见由函数式启发的“闭包”、“柯里化”思想对 JavaScript 有多重要。几乎所有的高阶函数都离不开闭包、参数由多转逐一的柯里化传参思想。所在在很多面试中,都会问闭包,不管是一两年、还是三五年经验的前端程序员。定义一个前端的 JavaScript 技能是初级,还是中高级,这是其中很重要的一个判断点。

        对闭包概念模糊不清的、或者只会背概念的 => 初级

        会写防抖、节流、或柯里化等高阶函数的 => 中级

        深刻理解高阶函数封装思想、能自主用闭包封装高阶函数 => 高级

        以上就是从柯里化分析JavaScript重要的高阶函数实例的详细内容,更多关于JavaScript 柯里化高阶函数的资料请关注自由互联其它相关文章!

        上一篇:JavaScript利用canvas绘制流星雨效果
        下一篇:没有了
        网友评论