JavaScript 函数柯里化:把一个"大问题"拆成几个"小问题"

先从一个日常场景说起

假设你开了一家三明治店。每天早上,顾客排着队点餐:

"我要一个面包,加生菜,加番茄,加牛肉。"

你的厨师每次都要听完整句话才开始做——面包、生菜、番茄、牛肉,四个信息一起接收,然后开始组装。

这没问题。但有一天你发现:80% 的顾客都选全麦面包

于是你想:能不能让事情简单一点?能不能先把"面包"这件事定下来,然后顾客只需要说剩下三个?

这就是柯里化的核心思想——不是一次性解决所有问题,而是先解决一部分,剩下的以后再说

到底什么是柯里化?

用代码说人话:

// 普通函数:一次传入所有参数
function makeSandwich(bread, vegetable, meat, sauce) {
  return `${bread} + ${vegetable} + ${meat} + ${sauce}`;
}

makeSandwich('全麦', '生菜', '牛肉', '蛋黄酱');
// "全麦 + 生菜 + 牛肉 + 蛋黄酱"

柯里化之后呢?

// 柯里化版本:每次只传一个参数,返回一个新函数
function curriedMakeSandwich(bread) {
  return function(vegetable) {
    return function(meat) {
      return function(sauce) {
        return `${bread} + ${vegetable} + ${meat} + ${sauce}`;
      };
    };
  };
}

// 使用方式变了
const withWholeWheat = curriedMakeSandwich('全麦');  // 先固定面包
const withLettuce = withWholeWheat('生菜');           // 再固定蔬菜
const withBeef = withLettuce('牛肉');                  // 再固定肉类
const mySandwich = withBeef('蛋黄酱');                 // 最后加酱

console.log(mySandwich);  // "全麦 + 生菜 + 牛肉 + 蛋黄酱"

你看,同一个功能,但调用方式完全不同。关键区别在于:柯里化让你可以在中间任何一步停下来,拿着"半成品"去干别的事

// 比如你可以批量生产"全麦底"的三明治
const wholeWheatBase = curriedMakeSandwich('全麦');

const sandwich1 = wholeWheatBase('生菜')('鸡肉')('千岛酱');
const sandwich2 = wholeWheatBase('番茄')('培根')('芥末酱');
const sandwich3 = wholeWheatBase('酸黄瓜')('火腿')('蜂蜜芥末');

wholeWheatBase 就是一个已经固定了第一个参数的函数。你不需要每次都重复传"全麦"。

为什么要这么折腾?三个真正的原因

原因一:参数复用(最重要的)

这是柯里化最直接的好处。当某个参数在多次调用中值都一样时,你没必要每次都写一遍。

真实场景——格式化数字:

// 普通写法:每次都传格式
function formatNumber(locale, currency, number) {
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency: currency
  }).format(number);
}

// 你在中国卖东西,每次都这么调:
formatNumber('zh-CN', 'CNY', 99);     // "¥99.00"
formatNumber('zh-CN', 'CNY', 199);    // "¥199.00"
formatNumber('zh-CN', 'CNY', 2999);   // "¥2,999.00"

烦不烦?'zh-CN' 和 'CNY' 写了三遍。

柯里化版本:

const curriedFormat = locale => currency => number =>
  new Intl.NumberFormat(locale, { style: 'currency', currency }).format(number);

const formatCNY = curriedFormat('zh-CN')('CNY');  // 一次固定,永久复用

formatCNY(99);    // "¥99.00"
formatCNY(199);   // "¥199.00"
formatCNY(2999);  // "¥2,999.00"

干净多了。而且如果你突然要支持美元市场:

const formatUSD = curriedFormat('en-US')('USD');
formatUSD(49.99);  // "$49.99"

一行搞定新市场。

原因二:为函数组合铺路

这是柯里化在函数式编程中地位崇高的原因。

想象一条流水线:原料 → 切割 → 清洗 → 烹饪 → 装盘。每一步都是一个函数。如果每个函数都只接受一个参数、返回一个结果,那么它们就可以像乐高积木一样任意拼接。

// 单参数函数可以无缝串联
const add10 = x => x + 10;
const double = x => x * 2;
const halve = x => x / 2;

// 流水线:先加10,再翻倍,再减半
const pipeline = x => halve(double(add10(x)));

pipeline(5);  // (5+10)*2/2 = 15

如果函数是多参数的,你就没法直接把它们串起来——你得在中间手动填参数。柯里化保证了每个步骤都是"拿一个东西,还一个东西"的纯净形态。

原因三:更声明式的代码风格

比较这两种写法:

// 命令式:描述"怎么做"
users.filter(function(user) {
  return user.age >= 18 && user.active === true;
});

// 声明式配合柯里化:描述"做什么"
const isAdult = age => user => user.age >= age;
const isActive = () => user => user.active === true;

const isAdultAndActive = user => isAdult(18)(user) && isActive()(user);
users.filter(isAdultAndActive);

第二种写法把判断逻辑数据分开了。isAdult(18) 本身就是一个可测试、可复用的断言函数,不依赖具体数据。

实际怎么用?三种方式

方式一:手写(理解原理)

// 最简单的两参数柯里化
function curryTwo(fn) {
  return function(a) {
    return function(b) {
      return fn(a, b);
    };
  };
}

// 使用
const add = (a, b) => a + b;
const curriedAdd = curryTwo(add);

curriedAdd(2)(3);  // 5

方式二:通用柯里化工具函数

function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      // 参数够了,直接执行原函数
      return fn.apply(this, args);
    } else {
      // 参数不够,返回一个新函数继续收集参数
      return function(...moreArgs) {
        return curried.apply(this, [...args, ...moreArgs]);
      };
    }
  };
}

// 任意参数数量的函数都能用
const sum = (a, b, c, d) => a + b + c + d;
const curriedSum = curry(sum);

curriedSum(1)(2)(3)(4);        // 10 — 每次传一个
curriedSum(1, 2)(3, 4);       // 10 — 也可以一次传多个
curriedSum(1, 2, 3, 4);       // 10 — 甚至全部一次传完

注意那个 fn.length——JavaScript 函数的 length 属性会告诉你它声明了多少个形参。这就是判断"参数够没够"的依据。

方式三:ES6 箭头函数(最常用)

实际开发中,大多数时候你不需要通用工具函数。箭头函数本身就能写出漂亮的柯里化代码:

// 数据获取场景
const get = key => obj => obj[key];

const getName = get('name');
const getAge = get('age');

getName({ name: '张三', age: 25 });  // "张三"
getAge({ name: '张三', age: 25 });   // 25

// DOM 操作场景
const getBySelector = selector => document.querySelector;
const $id = id => document.getElementById(id);

const mainEl = $id('main');
const btnEl = getBySelector('.submit-btn');

一个完整的实战例子

做一个简单的日志系统:

// 柯里化的日志函数
const logWith = level => prefix => message =>
  console.log(`[${new Date().toISOString()}] [${level}] [${prefix}] ${message}`);

// 为不同模块创建专用日志
const dbLog = logWith('DEBUG')('Database');
const apiLog = logWith('INFO')('API');
const errorLog = logWith('ERROR')('Auth');

// 使用
dbLog('连接池初始化完成');
// "[2024-03-15T08:30:00Z] [DEBUG] [Database] 连接池初始化完成"

apiLog('GET /api/users 200 45ms');
// "[2024-03-15T08:30:01Z] [INFO] [API] GET /api/users 200 45ms"

errorLog('Token 已过期');
// "[2024-03-15T08:30:02Z] [ERROR] [Auth] Token 已过期"

每个模块拿到手的都是已经配置好的专属函数,调用时只需关心"要记什么",不用管"怎么记"。

什么时候不该用?

说实话,不是所有地方都需要柯里化。

  • 只有一两个参数的函数——柯里化反而增加嵌套层级,可读性下降
  • 参数之间有依赖关系——第二个参数的值取决于第一个参数的结果,强行拆开会很别扭
  • 团队不熟悉函数式编程——代码是给人看的,如果你的同事看到 f(a)(b)(c) 会懵,那就别用

记住我的原则:工具是为了解决问题存在的,不是为了炫技存在的。如果一个模式让你的代码更难理解,那它就不是正确的模式。

一句话总结

柯里化就是:把"一次给齐所有答案"变成"一次回答一个问题"。这样做的好处是,你可以在任何一个问题上停下来,拿着已有的答案去做别的事——而不必每次都从头开始。

就像你不会每次做菜都从种小麦开始一样。有些事情,提前定下来就好。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容