深入详解JS函数的柯里化

作者:拾光时间:2026-10-24 12:01:38浏览:153

本文深入解析JavaScript中函数的柯里化技术。首先通过函数隐式转换与toString/valueOf优先级机制,梳理基础概念;接着演示如何封装数组map方法,引入回调与call/apply的应用。核心部分详细拆解柯里化的实现逻辑,从简单累加到通用参数收集,结合闭包特性讲解部分求值原理,并提供通用柯里化函数与bind方法的实现思路。

一、补充知识点之函数的隐式转换

先来看一个简单的思考题。


function fn() {
    return 20;
}
console.log(fn + 10); // 输出结果是多少?

稍微修改一下代码,再思考输出结果会是什么?


function fn() {
    return 20;
}
 
fn.toString = function() {
    return 10;
}
 
console.log(fn + 10);  // 输出结果是多少?

还可以继续对代码进行修改。


function fn() {
    return 20;
}
 
fn.toString = function() {
    return 10;
}
 
fn.valueOf = function() {
    return 5;
}
 
console.log(fn + 10); // 输出结果是多少?

// 输出结果分别为
function fn() {
    return 20;
}
10
 
20
 
15

当使用console.log输出,或者进行数学运算时,可能会触发隐式转换。从上述三个例子中可以总结出关于函数隐式转换的一些规律。

如果没有重新定义toString和valueOf方法,函数的隐式转换会调用默认的toString方法,它将函数的定义内容作为字符串返回。如果主动定义了toString或valueOf方法,则隐式转换的返回结果由开发者控制,其中valueOf的优先级略高于toString。

因此,上述例子的结论就非常容易理解了。建议大家动手尝试运行一下。

二、补充知识点之利用call/apply封装数组的map方法

map():对数组中的每一项运行给定函数,返回每次函数调用的结果组成的新数组。

通俗来说,就是遍历数组的每一项元素,在map的第一个参数(回调函数)中进行运算处理后返回计算结果,最终返回一个由所有计算结果组成的新数组。


// 回调函数中有三个参数
// 第一个参数表示newArr的每一项,第二个参数表示该项在数组中的索引值
// 第三个表示数组本身
// 除此之外,回调函数中的this,当map不存在第二参数时,this指向丢失,当存在第二个参数时,指向改参数所设定的对象
var newArr = [1, 2, 3, 4].map(function(item, i, arr) {
    console.log(item, i, arr, this);  // 可运行试试看
    return item + 1;  // 每一项加1
}, { a: 1 })
 
console.log(newArr); // [2, 3, 4, 5]

在上述例子的注释中详细阐述了map方法的细节。现在面临一个难题,就是如何手动封装map方法。

可以先思考for循环。我们可以使用for循环来实现一个map,但在封装时会遇到一些问题。使用for循环时,循环过程本身很好封装,但在循环内部对每一项执行的操作很难用一个固定的东西封装起来,因为每个场景下对数据的处理方式肯定不同。

于是大家想到了一个很好的办法,将这些不同的操作单独用一个函数来处理,让这个函数成为map方法的第一个参数,具体回调函数中是什么样的操作,由使用者在使用时决定。因此,根据这个思路的封装实现如下。


Array.prototype._map = function(fn, context) {
    var temp = [];
    if(typeof fn == 'function') {
        var k = 0;
        var len = this.length;
        // 封装for循环过程
        for(; k < len; k++) {
            // 将每一项的运算操作丢进fn里,利用call方法指定fn的this指向与具体参数
            temp.push(fn.call(context, this[k], k, this))
        }
    } else {
        console.error('TypeError: '+ fn +' is not a function.');
    }
 
    // 返回每一项运算结果组成的新数组
    return temp;
}
 
var newArr = [1, 2, 3, 4]._map(function(item) {
    return item + 1;
})
// [2, 3, 4, 5]

在上述封装中,首先定义了一个空数组temp,用来存储最终的返回结果。在for循环中,每循环一次,就执行一次参数fn函数,fn的参数则使用call方法传入。

在理解了map的封装过程之后,就能明白为什么在使用map时,总是期望在第一个回调函数中有一个返回值。在eslint的规则中,如果在使用map时没有设置返回值,就会被判定为错误。

ok,明白了函数的隐式转换规则与call/apply在这种场景的使用方式,就可以尝试通过简单的例子来了解一下柯里化了。

三、由浅入深的柯里化

在前端面试中有一个关于柯里化的面试题,流传甚广。

实现一个add方法,使计算结果能够满足如下预期:

add(1)(2)(3) = 6 add(1, 2, 3)(4) = 10 add(1)(2)(3)(4)(5) = 15

很明显,计算结果正是所有参数的和,add方法每运行一次,肯定返回了一个同样的函数,继续计算剩下的参数。

我们可以从最简单的例子一步一步寻找解决方案。

当只调用两次时,可以这样封装。


function add(a) {
    return function(b) {
        return a + b;
    }
}
 
console.log(add(1)(2));  // 3

如果只调用三次:


function add(a) {
    return function(b) {
        return function (c) {
            return a + b + c;
        }
    }
}
 
console.log(add(1)(2)(3)); // 6

上述封装看上去与想要的结果有点类似,但参数的使用被限制得很死,因此并不是最终想要的结果,需要通用的封装。应该怎么办?总结上述两个例子,其实我们是利用闭包的特性,将所有的参数集中到最后返回的函数里进行计算并返回结果。因此在封装时,主要目的就是将参数集中起来计算。

来看看具体实现。


function add() {
    // 第一次执行时,定义一个数组专门用来存储所有的参数
    var _args = [].slice.call(arguments);
 
    // 在内部声明一个函数,利用闭包的特性保存_args并收集所有的参数值
    var adder = function () {
        var _adder = function() {
            [].push.apply(_args, [].slice.call(arguments));
            return _adder;
        };
 
        // 利用隐式转换的特性,当最后执行时隐式转换,并计算最终的值返回
        _adder.toString = function () {
            return _args.reduce(function (a, b) {
                return a + b;
            });
        }
 
        return _adder;
    }
    return adder.apply(null, [].slice.call(arguments));
}
 
// 输出结果,可自由组合的参数
console.log(add(1, 2, 3, 4, 5));  // 15
console.log(add(1, 2, 3, 4)(5));  // 15
console.log(add(1)(2)(3)(4)(5));  // 15

上述实现利用闭包的特性,主要目的是通过一些巧妙的方法将所有的参数收集在一个数组里,并在最终隐式转换时将数组里的所有项加起来。因此在调用add方法的时候,参数就显得非常灵活。当然,也就很轻松地满足了我们的需求。

那么读懂了上述demo,然后再来看看柯里化的定义,相信大家就会更加容易理解了。

柯里化(英语:Currying),又称为部分求值,是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回一个新的函数的技术,新函数接受余下参数并返回运算结果。

  • 接收单一参数,因为要携带不少信息,因此常常以回调函数的理由来解决。
  • 将部分参数通过回调函数等方式传入函数中
  • 返回一个新函数,用于处理所有的想要传入的参数

在上述例子中,可以将add(1, 2, 3, 4)转换为add(1)(2)(3)(4)。这就是部分求值。每次传入的参数都只是想要传入的所有参数中的一部分。当然实际应用中,并不会常常这么复杂地去处理参数,很多时候也仅仅只是分成两部分而已。

咱们再来一起思考一个与柯里化相关的问题。

假如有一个计算要求,需要将数组里面的每一项用自定义的字符连接起来。应该怎么做?想到使用join方法,就很简单。


var arr = [1, 2, 3, 4, 5];
 
// 实际开发中并不建议直接给Array扩展新的方法
// 只是用这种方式演示能够更加清晰一点
Array.prototype.merge = function(chars) {
    return this.join(chars);
}
 
var string = arr.merge('-')
 
console.log(string);  // 1-2-3-4-5

增加难度,将每一项加一个数后再连接起来。那么这里就需要map来帮助我们对每一项进行特殊的运算处理,生成新的数组然后用字符连接起来了。实现如下:


var arr = [1, 2, 3, 4, 5];
 
Array.prototype.merge = function(chars, number) {
    return this.map(function(item) {
        return item + number;
    }).join(chars);
}
 
var string = arr.merge('-', 1);
 
console.log(string); // 2-3-4-5-6

但是如果又想要让数组每一项都减去一个数之后再连接起来呢?当然和上面的加法操作一样的实现。


var arr = [1, 2, 3, 4, 5];
 
Array.prototype.merge = function(chars, number) {
    return this.map(function(item) {
        return item - number;
    }).join(chars);
}
 
var string = arr.merge('~', 1);
 
console.log(string); // 0~1~2~3~4

机智的小伙伴肯定发现困惑所在了。期望封装一个函数,能同时处理不同的运算过程,但并不能使用一个固定的套路将对每一项的操作都封装起来。于是问题就变成了和封装map的时候所面临的问题一样了。可以借助柯里化来搞定。

与map封装同样的道理,既然事先并不确定将对每一项数据进行怎么样的处理,只是知道需要将它们处理之后然后用字符连接起来,所以不妨将处理内容保存在一个函数里。而仅仅固定封装连接起来的这一部分需求。

于是就有了以下的封装。


// 封装很简单,一句话搞定
Array.prototype.merge = function(fn, chars) {
    return this.map(fn).join(chars);
}
 
var arr = [1, 2, 3, 4];
 
// 难点在于,在实际使用的时候,操作怎么来定义,利用闭包保存于传递num参数
var add = function(num) {
    return function(item) {
        return item + num;
    }
}
 
var red = function(num) {
    return function(item) {
        return item - num;
    }
}
 
// 每一项加2后合并
var res1 = arr.merge(add(2), '-');
 
// 每一项减2后合并
var res2 = arr.merge(red(1), '-');
 
// 也可以直接使用回调函数,每一项乘2后合并
var res3 = arr.merge((function(num) {
    return function(item) {
        return item * num
    }
})(2), '-')
 
console.log(res1); // 3-4-5-6
console.log(res2); // 0-1-2-3
console.log(res3); // 2-4-6-8

大家能从上述例子,发现柯里化的特征吗?

四、柯里化通用式

通用的柯里化写法其实比上边封装的add方法要简单许多。


var currying = function(fn) {
    var args = [].slice.call(arguments, 1);
 
    return function() {
        // 主要还是收集所有需要的参数到一个数组中,便于统一计算
        var _args = args.concat([].slice.call(arguments));
        return fn.apply(null, _args);
    }
}
 
var sum = currying(function() {
    var args = [].slice.call(arguments);
    return args.reduce(function(a, b) {
        return a + b;
    })
}, 10)
 
console.log(sum(20, 10));  // 40
console.log(sum(10, 5));   // 25

五、柯里化与bind


Object.prototype.bind = function(context) {
    var _this = this;
    var args = [].prototype.slice.call(arguments, 1);
 
    return function() {
        return _this.apply(context, args)
    }
}

这个例子利用call与apply的灵活运用,实现了bind的功能。

在前面的几个例子中,可以总结一下柯里化的特点:

  • 接收单一参数,将更多的参数通过回调函数来搞定?
  • 返回一个新函数,用于处理所有的想要传入的参数;
  • 需要利用call/apply与arguments对象收集参数;
  • 返回的这个函数正是用来处理收集起来的参数。

希望读完之后能够大概明白柯里化的概念,如果想要熟练使用它,就需要掌握更多的实际经验才行。

以上就是深入详解JS函数的柯里化的详细内容,更多关于JS函数的柯里化的资料请关注本站其它相关文章!

作者最新文章