JavaScript 拆分函数详解:闭包、部分应用与代码复用
了解如何通过柯里化将多参数的 JavaScript 函数转换为可重复使用的单参数链,它与部分应用有何不同,以及何时应该跳过这一技术。
如果反复向函数传递相同的第一个参数,每次调用都会产生少量的重复处理开销。柯里化允许你一次性确定函数的部分输入并重用该结果。下面将介绍什么是柯里化、闭包如何为其提供支持、它与部分应用有何不同,以及何时它能真正提升代码质量。
柯里化的含义
柯里化会将一个接受多个参数的函数转换成一组各自只接受一个参数的函数。常规调用会一次性提供所有参数:
f(a, b, c)
而柯里化形式则每次调用只提供一个值,且每次调用都会返回链中的下一个函数:
f(a)(b)(c)
每个中间函数都会记住之前调用中的参数值。这些记忆来自下文将介绍的闭包。
转换普通函数
从一个普通的两个参数的加法函数开始:
function sum(a, b) {
return a + b;
}
sum(2, 3); // 5
有一个简单的辅助函数可以封装任何需要两个参数的函数:外部函数接收第一个参数,返回一个接受第二个参数的函数,之后才用这两个参数调用原始函数:
function curry(fn) {
return function (a) {
return function (b) {
return fn(a, b);
};
};
}
const curriedSum = curry(sum);
curriedSum(2)(3); // 5
需要注意以下几点:
curry属于高阶函数:它的输入和输出都是函数。- 它返回的每个函数都只期望接收一个参数。
- 由于内部函数闭包了外部变量,因此在外部调用返回后仍能读取到
a的值。
这个辅助函数仅处理恰好两个参数的情况。通用版本会检查fn.length,并持续收集参数直到数量足够,但原理是相同的。
实际应用案例:专用日志记录器
单纯的算术操作难以体现其优势,因此可以考虑一种接收严重程度级别和消息的日志记录器:
function log(level, message) {
console.log(`[${level}] ${message}`);
}
调用点很快就会重复使用同一级别:
log("ERROR", "Something broke");
log("ERROR", "Invalid token");
log("ERROR", "API failed");
通过柯里化日志记录器,你可以一次性创建好预配置的日志记录器:
function log(level) {
return function (message) {
console.log(`[${level}] ${message}`);
};
}
const errorLog = log("ERROR");
const infoLog = log("INFO");
errorLog("Something broke");
errorLog("Invalid token");
infoLog("User logged in");
当创建errorLog和infoLog时,日志级别就已经确定。这样一来调用代码会更简洁,意图更易理解,而且这些专用函数还可以被导出或注入到其他地方。正是这种重用性与表达力的结合,使得柯里化如此重要。
部分应用
部分应用指的是在不改变原有函数结构的前提下,预先填入某些参数以生成一个新的函数。JavaScript通过bind方法原生支持这一功能:
function multiply(a, b) {
return a * b;
}
const double = multiply.bind(null, 2);
double(5); // 10
第一个bind参数用于设置this,但multiply会忽略它,因此该值为null。multiply仍然需要两个参数;double只是预先提供了第一个参数的变体而已。
柯里化与部分应用
两者目标相同,但实现机制不同:
- 柯里化将函数重构为一系列单参数函数。
- 部分应用则直接填充部分参数,保持函数的原有结构不变。
柯里化链中的每一步实际上都是一种单参数的部分应用,但部分应用并不一定需要通过柯里化来实现。
开放式柯里化
有一种有趣的变体会持续接收值,直到通过空调用结束该链:
let infiniteSum = function (a) {
return function (b) {
if (b !== undefined) return infiniteSum(a + b);
return a;
};
};
infiniteSum(1)(2)(3)(4)(); // 10
其执行过程如下:
- 每次传入数值时,都会将其加到累计值中,并返回一个包含该累计值的新函数。
- 最后的
()调用会使b变为undefined,从而返回累计值。 - 闭包会将累计值从一步传递到下一步。
显式检查 b !== undefined 非常重要。像 if (b) 这样的真值测试会将 0 视为终止条件,因此 infiniteSum(1)(0)(2)() 会在遇到零时停止,而无法返回 3。这种模式是练习闭包的好方法,也是常见的面试问题,但由于容易忽略最后的调用,它并不适合作为生产级 API 的默认实现。
闭包让一切正常运作
以下是最简单的柯里化加法器:
function add(a) {
return function (b) {
return a + b;
};
}
add(3)(4); // 7
add(3) 返回内部函数后便结束执行,但该内部函数仍能访问 a。这就是闭包现象:一个函数即便在外部函数返回后,仍能继续访问其定义时所在作用域中的变量。由于这些被捕获的值会一直存在,因此可以存储并重复使用这些已部分应用的函数:
const add3 = add(3);
add3(4); // 7
add3(10); // 13
关于这些被捕获变量是如何被解析的更多信息,请参阅JavaScript作用域链如何解析变量。
何时使用以及何时避免使用
在以下情况下,柯里化能带来好处:
- 许多调用点都出现相同的初始参数
- 它能让代码更清晰而非更加晦涩
- 你需要小巧、可组合且可重复使用的函数
以下情况可跳过此方法:
- 简单的单次函数无需拆分
- 你的团队不熟悉这种模式
- 链式调用会降低可读性
总结
柯里化实际上就是按照参数知晓的顺序来组织函数的输入:像日志级别或基础 URL 这样的配置会在早期确定,而每次调用时的数据则稍后传来。闭包负责记住这些信息,bind 方法则在不需要完整链式调用的情况下提供更轻量的部分应用功能,而核心问题始终是:这样的调用方式是否能让代码更易阅读和复用。
相关阅读
- 理解 JavaScript 闭包与事件循环 — 通过实际代码示例,了解闭包如何保留外部变量,以及事件循环如何对调用栈、微任务和宏任务进行排序。
- JavaScript 的作用域链究竟如何解析变量 — 阐述了变量查找背后的词法作用域链机制,解释为何使用 var 会导致循环错误,以及闭包是如何由此自然产生的。