JavaScript 函数柯里化:从入门到精通,揭秘闭包与递归的底层魔法

52 阅读10分钟

JavaScript 函数柯里化:从入门到精通,揭秘闭包与递归的底层魔法

大家好,今天我们来聊一个在函数式编程中闪闪发光的概念——函数柯里化(Currying)。它听起来有点高大上,但其实就是一个层层叠加的过程。

在实际项目中,柯里化能帮我们写出更优雅、更可复用、更易维护的代码。尤其是处理日志、事件绑定、配置函数时,它简直是神器。本文将从基础例子出发,一步步深入底层逻辑,结合真实场景扩展应用,还会特别提醒一些易错点。准备好了吗?让我们开始这场柯里化的冒险之旅!

一、什么是函数柯里化?用生活比喻来理解

想象一下,你去吃火锅。先涮底料(固定一种口味),然后每次只加一种食材(牛肉、蔬菜、豆腐),而不是一次性把所有食材扔进去。柯里化就是这样:将一个接受多个参数的函数,转化为一系列只接受一个参数的函数链

标准定义(来自 Haskell Curry,这位大牛的名字直接成了这个技术的命名):

柯里化是将 f(a, b, c) 转化为 f(a)(b)(c) 的过程。

简单来说:

  • 普通函数:add(1, 2, 3) → 6
  • 柯里化后:curriedAdd(1)(2)(3) → 6

它不是立刻计算结果,而是逐步收集参数,直到参数齐全才执行原函数。

为什么有用?

  • 参数复用:固定常用参数,创建专用的新函数。
  • 延迟执行:参数不全时不计算,适合异步或配置场景。
  • 函数组合更优雅:在函数式编程中大放异彩。

二、从零开始:手把手看柯里化的演变

我们用最经典的 add 函数来演示,逐步升级。

1. 最原始的写法
function add(a, b) {
    return a + b;
}
console.log(add(1, 2)); // 3

参数一次性传完,简单粗暴。但如果我们总要加 1,怎么办?每次都写 add(1, x) 多麻烦。

2. 手动柯里化:返回嵌套函数
function add(a) {
    return function(b) {
        return a + b;
    };
}
console.log(add(1)(2)); // 3

哇!现在 add(1) 返回一个“记住 a=1 的新函数”。这背后靠的是闭包:内层函数捕获了外层函数的变量 a。

你可以这样用:

const addOne = add(1);  // 固定第一个参数
console.log(addOne(5)); // 6
console.log(addOne(10)); // 11

参数复用了!每次调用 addOne 都不用重复传 1。

3. 真实场景:日志函数的柯里化

来看一个超级实用的例子——日志系统。

普通日志:

console.log('[INFO]: 页面加载完成');
console.log('[ERROR]: 接口异常');

类型重复写,太烦人。我们柯里化它:

const log = type => message => {
    console.log(`[${type}]: ${message}`);
};

const errorLog = log('ERROR');
const infoLog = log('INFO');

errorLog('接口异常');         // [ERROR]: 接口异常
infoLog('页面加载完成');       // [INFO]: 页面加载完成

完美!log('ERROR') 创建了一个专用的错误日志函数。固定了类型,后续只传消息。这在大型项目中超级常见,比如统一日志上报。

三、通用柯里化函数:让任何函数都能柯里化

上面是手动柯里化,如果函数有 4 个参数呢?手动嵌套 4 层?太累了!

我们来写一个通用的 curry 函数:

function curry(fn) {
    return function curried(...args) {
        // 如果本次收集的参数 >= 原函数需要的参数个数,直接执行
        if (args.length >= fn.length) {
            return fn.apply(this, args);
        }
        // 否则,返回一个新函数,继续收集剩余参数
        return function(...moreArgs) {
            return curried.apply(this, args.concat(moreArgs));
        };
    };
}
我们来一步步拆解一下

JavaScript

function add(a, b, c) {
    return a + b + c;
}

const curriedAdd = curry(add);
// add.length === 3

现在我们开始调用:

第一步:curriedAdd(1)

第一次调用时:

  • args = [1]
  • args.length = 1 < fn.length (3) → 不够,不执行 add
  • 所以执行这行:return (...rest) => curried(...args, ...rest)
  • 把当前的 args = [1] 记住(闭包)
  • 返回一个新函数,这个新函数等着接收剩下的参数(rest)

这个新函数长这样(逻辑等价):

JavaScript

function(newParams) {
    return curried(1, ...newParams);  // 把之前收集的1和这次的新参数合并,再次调用curried
}
第二步:curriedAdd(1)(2)

调用上面返回的那个新函数,传入 2:

  • rest = [2]
  • 执行 curried(...args, ...rest) → curried(1, 2)
  • 现在 args 变成了 [1, 2]
  • args.length = 2 < 3 → 还是不够
  • 再次返回一个新函数,等着下一个参数
第三步:curriedAdd(1)(2)(3)

传入 3:

  • rest = [3]
  • curried(1, 2, 3)
  • args.length = 3 >= 3 → 够了!
  • 直接执行 fn(...args) → add(1,2,3) → 返回 6
支持一次传多个参数的例子

JavaScript

curriedAdd(1, 2)(3)   // 第一次 args=[1,2] → 不够 → 返回新函数
                      // 第二次传入3 → 合并成[1,2,3] → 执行add
curriedAdd(1)(2, 3)   // 同理
curriedAdd(1, 2, 3)   // 第一次就够了 → 直接执行

牛不牛?它支持严格柯里化(一次一个参数)不严格(一次多个)

底层逻辑详解:闭包 + 递归的双重魔法
  • 闭包的作用curried 函数记住原函数 fn 和已收集的参数 args。即使外层 curry 执行完,内层 curried 还能访问它们(自由变量不销毁)。
  • 递归的作用:每次参数不足,就返回自己(curried),继续收集。参数够了,就退出递归,执行原函数。
  • fn.length 的妙用:这是函数的属性,表示形参数量(不计 rest 参数,不计默认值后的参数)。

例子:

function test(a, b = 2, ...rest) {} 
console.log(test.length); // 1  只算 a

这就是判断“参数够不够”的关键!

四、几个细节知识点

fc962ce0cd306c49bc54248e80437e81.jpg

1.我们来详细解释一下fn.length为什么只表示形参数量(不计 rest 参数,不计默认值后的参数)

这是 JS 的一个历史遗留规则,也是很多人的坑。

JavaScript

function test(a, b = 2, ...rest) {
    // ...
}
console.log(test.length); // 1

function foo(a, b = 1, c) {
    // ...
}
console.log(foo.length); // 1 !!不是2

规则是:函数的 length 属性只计算“从开头开始,连续的、没有默认值的形参”数量,直到遇到第一个有默认值的形参就停止。

官方说法:length 是“预期传入的参数个数”,不包括有默认值的和 rest 参数。

具体规则:

  • 从左到右数形参
  • 遇到第一个有默认值的参数,就停
  • 只算停之前的参数个数
  • rest 参数(...rest)完全不计入 length

举例对比:

JavaScript

function f1(a, b, c) {}          // length: 3
function f2(a, b = 1, c) {}       // length: 1  ← b有默认值,停!
function f3(a = 1, b, c) {}       // length: 0  ← 第一个就有默认值
function f4(a, b, c = 3, d) {}    // length: 2  ← a,b 没默认,c有默认,停在cfunction f5(a, ...rest) {}        // length: 1  ← rest 不影响前面的计数

为什么设计成这样?

因为在 ES6 之前没有默认参数,length 就是“这个函数期望你传几个参数”。引入默认参数后,为了向后兼容,就规定:有默认值的参数是“可选的” ,所以不计入“必须传”的个数。

对柯里化的影响(重要提醒!)

我们之前的 curry 函数是用 fn.length 来判断参数是否够的。

所以如果原函数有默认参数:

JavaScript

function sum(a, b = 10, c = 20) {
    return a + b + c;
}
console.log(sum.length); // 1

const curriedSum = curry(sum);
curriedSum(5)  // args.length=1 >= sum.length(1) → 直接执行 sum(5)
               // 结果是 5 + 10 + 20 = 35

它会提前执行,因为它误以为只需要1个参数。

解决方案

  1. 尽量把需要柯里化的参数放在前面,不要用默认值
  2. 或者手动指定长度:

JavaScript

function curry(fn, arity = fn.length) {
    return function curried(...args) {
        if (args.length >= arity) {
            return fn(...args);
        }
        return (...rest) => curried(...args, ...rest);
    };
}

// 手动传真实需要的参数个数
const curriedSum = curry(sum, 3);

或者直接用 lodash 的 _.curry,它处理得更完善。

2.lodash 的 _.curry

既然上面提到了_.curry,我们就顺便扩展一下吧

基本用法

先引入 Lodash(现在常用模块化导入):

JavaScript

import { curry } from 'lodash';  // 推荐这样导入
// 或者全量导入:const _ = require('lodash');

语法:

JavaScript

_.curry(func, [arity=func.length])
  • func:你要柯里化的原函数
  • arity:可选,手动指定需要几个参数(默认用 func.length)

返回一个新的柯里化函数。

经典例子

JavaScript

function abc(a, b, c) {
    return [a, b, c];
}

const curried = curry(abc);  // 或 _.curry(abc)

curried(1)(2)(3);     // => [1, 2, 3]
curried(1, 2)(3);     // => [1, 2, 3]  支持一次传多个
curried(1)(2, 3);     // => [1, 2, 3]
curried(1, 2, 3);     // => [1, 2, 3]  一次性全传也行

和我们手写的一样灵活!

最牛的功能:占位符(Placeholder)

Lodash 的 _.curry 支持用占位符 __(或 _,取决于构建方式,通常是 _)来“跳过”某个参数,后面再补上。这在我们手写版里是没有的,超级实用!

默认占位符是 _.placeholder(通常是 _)。

JavaScript

const curried = curry(abc);

curried(1)(_.placeholder, 3)(2);  // => [1, 2, 3]
// 解释:第一次传1(固定a=1)
// 第二次传 placeholder(跳过b)和3(固定c=3)
// 第三次传2(补上b=2)

// 更灵活的例子
curried(_.placeholder, _.placeholder, 3)(1)(2);  // => [1, 2, 3]
// 先固定c=3,然后补a=1,再补b=2

这就允许你随意调整参数顺序,固定后面的参数而跳过前面的。实际场景中特别好用,比如配置函数或工具链。

如果你用全量 lodash,通常直接用 _ 作为占位符:

JavaScript

curried(1)(_, 3)(2);  // _ 就是 placeholder

处理默认参数的坑(完美兼容!)

记得我们前面被默认参数坑了吗?Lodash 的 curry 处理得很好,你可以手动指定 arity。

JavaScript

function sum(a, b = 10, c = 20) {
    return a + b + c;
}
// sum.length === 1(坑!)

const curriedSum = curry(sum, 3);  // 手动告诉它需要3个参数

curriedSum(5)(30)(40);  // => 75
curriedSum(5)(30);      // 返回一个等待c的函数,不会提前执行

牛逼!直接传第二个参数 3 就解决了。

注意点(避坑)

  • curried 函数的 .length 属性不会被设置(总是0),别依赖它。
  • 如果原函数有 rest 参数或复杂默认值,手动指定 arity 最保险。
  • lodash/fp 模块有个自动柯里化的版本(数据最后),更函数式,但占位符用法稍不同。

五、柯里化的强大应用场景

柯里化不是纸上谈兵,它在真实项目中大显身手。

1. 配置化工具函数

比如校验函数:

const match = curry((reg, str) => reg.test(str));
const haveSpace = match(/\s+/g);
const haveNumber = match(/\d+/g);

console.log(haveSpace('hello world')); // true
console.log(haveNumber('abc123'));     // true

过滤数组:

const filter = curry((fn, arr) => arr.filter(fn));
const findSpaces = filter(haveSpace);

console.log(findSpaces(['a b', 'abc', 'c d e'])); // ['a b', 'c d e']
2. 事件绑定与防抖
const curryOn = curry((event, fn, el) => el.addEventListener(event, fn));
const onClick = curryOn('click');

onClick(console.log('clicked'))(document.body);
3. 与函数组合(compose)联用

函数式编程的杀手锏:

const compose = (...fns) => x => fns.reduceRight((acc, fn) => fn(acc), x);
const addOne = add(1);
const double = x => x * 2;
const pipeline = compose(double, addOne);
pipeline(5); // 12

柯里化让组合更顺畅。

六、易错点大提醒!避坑指南

柯里化强大,但用错会踩坑:

  1. fn.length 的陷阱

    • 默认参数、rest 参数不计入 length。
    function foo(a, b = 1, c) {} // length = 1
    
    • 易错:如果原函数有默认值,curry 可能提前执行。解决方案:手动指定长度,或用更高级的实现(如 lodash)。
  2. this 绑定问题

    • 用 apply/call 时注意 this。建议用箭头函数避免。
  3. 性能考虑

    • 层层嵌套函数有轻微开销,大量柯里化时注意。
    • 闭包过多可能内存泄漏(及时释放引用)。
  4. 柯里化 vs 偏应用(Partial Application)

    • 柯里化:严格每次一个参数,返回链式函数。
    • 偏应用:固定部分参数(可多个),直接返回新函数。
    • 很多人混淆,但 JS 中通用 curry 往往支持偏应用。
  5. 参数顺序很重要

    • 最常固定的参数放前面(如日志类型先固定)。

七、总结:拥抱柯里化,让代码更香!

函数柯里化本质是闭包 + 递归的完美结合。它让我们从“一次性传参”转向“逐步配置”,代码更模块化、可复用、可读性爆棚。

在现代前端(React、Vue、Node)中,柯里化无处不在:Redux 中间件、hooks 配置、工具库实现……