本文从Lambda演算历史切入,深入解析JavaScript中柯里化与闭包的共生关系。通过缓存传参、条件判断、计算结果及函数缓存四个维度的实战案例,展示柯里化在减少传参、优化性能中的应用。同时结合防抖节流与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 函数,包含 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 柯里化高阶函数的资料请关注本站其它相关文章!