JavaScript 柯里化是前端职位面试中最常见的问题之一。熟练掌握它不仅可以帮你通过面试,获得更好的职业机会,更直接的作用是可以让你的代码更简洁,工作更高效。
如下是一个面试题:
请定义一个 JavaScript 方法,实现如下输出:
sum(2, 3) // 5
sum(2)(3) // 5
我们可以把问题分解,对于第一种参数是 2 个的情况,可以这样编写函数:
function sum(a, b) {
return a + b;
}
如上函数对于第一种情况是工作的,但如果执行第二种格式则会报错如下:
Uncaught TypeError: sum(...) is not a function
这是由于函数的返回值是一个数字,而不是执行 (3) 时所期待的函数。
我们在上述函数的基础上做一些改动,根据参数值的数组返回不同的类型。例如:
function sum(...args) {
if (args.length === 2) {
return args[0] + args[1];
} else {
return function(...args2) {
return sum.apply(this, args.concat(args2));
};
}
}
sum(2)(3)
这里用到了 ES 6 的剩余参数特性(...args 和 ...args2)来保存传入的参数。
- 第 2 行:如果调用 sum 函数时传入的参数个数是 2,会直接返回这两个数字的和。
- 第 5 行:否则会返回一个函数,以供后续的调用。
- 第 6 行:在这个内嵌的函数中,会把之前调用 sum 时传入的参数 args 和本次调用 sum 函数时传入的 args2 合并为一个新的数组,然后使用 .apply() 方法调用 sum 函数,把合并后的数组作为参数传入。
仔细观察这个函数的具体实现方式之后,可以得出一个结论:它本质上是函数的递归调用。在参数个数未达到要求时(对于本例而言是 2 个参数),会返回一个新的函数,此函数调用时会调用外部定义的函数,当参数个数满足要求时返回期待的和,即一个数字。
延伸:更多参数的情况
上面我们讨论了求两数之和的情况,如果是更多数字之和呢?例如三数之和:
console.log(sum(2, 3, 4)) // 9
console.log(sum(2)(3)(4)) // 9
console.log(sum(2)(3, 4));// 9
console.log(sum(2, 3)(4));// 9
聪明的你也许想到了可以改写上述的 sum 方法,把求和的判断条件对应调整:
function sum(...args) {
if (args.length === 3) {
return args.reduce((prev, cur) => prev + cur, 0);
} else {
return function(...args2) {
return sum.apply(this, args.concat(args2));
};
}
}
在上述代码中第 2 行判断 args 参数的长度为 3 时,在第 3 行使用了数组的 reduce() 方法来计算数值之和。
问题出现了:如果参数个数不为 3,需要手动修改第 2 行中的数字 '3'。我们需要更智能的方式来处理这个问题。
柯里化函数的具体代码实现
上个小结中的 sum 函数已经有了柯里化的思维,不过有两个问题尚未解决:
- 在函数中需要预先得知参数的个数才能保证函数正常执行
- 此方法不具有通用性,例如需要计算多个数字的乘积时仍然需要编写结构类似的函数,没有实现逻辑的复用。
可以这样实现一个通用的 curry 方法:
function curry(fn) {
return function curried(...args) {
if (args.length === fn.length) {
return fn.apply(this, args);
} else {
return function(...args2) {
return curried.apply(this, args.concat(args2));
};
}
};
}
使用方法如下:
const sum = curry(function(a, b, c, d) {
return a + b + c + d;
});
console.log(sum(2,3,4,5));
console.log(sum(2)(3)(4)(5));
console.log(sum(2)(3,4,5));
console.log(sum(2,3)(4,5));
console.log(sum(2,3,4)(5));
curry 函数代码解释
- 第 1 行定义了 curry 方法的签名,它的入参是一个函数 fn
- 第 2 行返回了一个函数,调用 curry(fn) 方法后会返回一个封装后 curried 函数,具体的逻辑是在此函数中定义并执行的。
- 第 3 行判断如果调用 curried 函数时传入的参数个数和 fn 方法签名的参数个数一致,则把参数传入 fn 函数并执行,然后把它的返回值返回。这里使用 fn.length 获取 fn 方法的参数个数,解决了上述的第一个问题。
- 第 6 行定义了一个匿名函数,适用于调用 curried 函数时参数个数少于期待值的情况。它会返回一个匿名函数,当再次调用时会在第 7 行使用 curried.apply() 方法尝试执行 curried 函数,这时逻辑会再次进入 curried 函数体,当最后一次调用参数个数达到期望值时,会执行第 4 行的代码,并最终返回。
在理解了它的工作原理之后,我们发现柯里化只适用于参数个数固定的函数,它要求传入的函数的参数个数是已知的。所以如果传入的函数使用了剩余参数,则不生效。例如如下是不工作的:
// 传入的函数使用了 rest parameters
const sum = curry(function(...nums) {
return nums.reduce((prev, cur) => prev + cur, 0);
});
// 如下都会输出 f(...args2) 函数,而不是预期的数字之和
console.log(sum(2,3,4,5));
console.log(sum(2)(3)(4)(5));
console.log(sum(2)(3,4,5));
console.log(sum(2,3)(4,5));
console.log(sum(2,3,4)(5));
在现实项目中的使用场景
在项目开发中合理使用柯里化可以让代码更加简洁明了和容易维护。如下是一个真实的例子。
假设你需要为 url-parse 这个 URL 解析库编写测试用例,确保它是正常工作的。我们可以使用 QUnit 这样编写测试用例:
上述代码可以正常工作,不过我们可以发现 URLParse(url1) 和 URLParse(url2) 出现了多次,特别是当检测的属性增多时会更加明显。
我们可以在此基础上封装一个方法,然后借助于 curry() 方法来简化代码: