为什么要关注纯函数和柯里化?
在日常开发中,你是否遇到过这些问题:
- 修改一个函数后,其他看似无关的模块出现了 bug
- 相同的输入有时返回不同的结果,导致测试用例不稳定
- 代码复用困难,类似的逻辑在多处重复编写
- 阅读 React、Redux、Vue3 源码时,对某些设计模式感到困惑
这些问题的根源往往在于:缺乏对函数式编程核心概念的理解。纯函数和柯里化作为函数式编程的两大基石,不仅能帮助我们写出更稳定、可测试的代码,更是理解现代前端框架设计思想的关键。
本文收益:
- 掌握纯函数的定义与实践,避免副作用带来的隐患
- 理解柯里化的本质,学会用单一职责原则优化代码结构
- 从 Vue3、Redux 源码中看到这些思想的实际应用
- 获得可直接落地的编码实践和团队推广建议
一、纯函数:稳定性的基石
1.1 什么是纯函数
JavaScript 符合函数式编程范式,纯函数是其中最重要的概念之一。在 React 开发中,组件被要求像纯函数一样工作;在 Redux 中,reducer 必须是纯函数。理解纯函数,是掌握现代前端框架的必经之路。
下图展示了 Redux 官方文档对数据不可变性的强调:

图 1:React 中的数据不可变性
根据维基百科定义,纯函数需要满足三个条件:
- 确定性输出:相同的输入必然产生相同的输出
- 无外部依赖:输出只依赖于输入参数,不依赖外部状态或 I/O 设备
- 无副作用:不触发事件、不修改外部状态、不改变输入参数
简单总结:
- 确定的输入 → 确定的输出(可预测性)
- 执行过程中不产生副作用(隔离性)
"纯"字表达的是"纯粹"的含义,即函数只做一件事:根据输入计算输出,不做任何额外操作。
1.2 副作用:bug 的温床
什么是副作用?
副作用(Side Effect)源自医学概念,指药物在治疗疾病之外产生的额外影响。在计算机科学中,副作用指函数执行时,除了返回值之外对外部环境产生的影响,例如:
- 修改全局变量
- 修改传入的参数对象
- 发起网络请求
- 操作 DOM
- 写入文件或数据库
- 打印日志(严格来说也是副作用,但通常可接受)
为什么副作用是问题?
副作用会破坏代码的可预测性和可测试性。当函数依赖或修改外部状态时:
- 相同输入可能产生不同输出
- 函数行为难以追踪和调试
- 并发执行时可能产生竞态条件
- 单元测试需要复杂的 mock 和环境准备
在编程中,我们提倡"数据的不可变性"(Immutability):尽量不修改原有数据,而是创建新数据。这是避免副作用的重要实践。
1.3 纯函数实战案例
让我们通过数组操作来理解纯函数:
案例 1:slice vs splice
javascript
const names = ["小吴", "why", "JS高级"];
// slice 是纯函数
// 1. 相同输入产生相同输出
// 2. 不修改原数组
const newNames1 = names.slice(0, 2);
console.log("newNames1:", newNames1); // ["小吴", "why"]
console.log("names:", names); // ["小吴", "why", "JS高级"] - 原数组未变
// splice 不是纯函数
// 会修改原数组,产生副作用
const newNames2 = names.splice(2);
console.log("newNames2:", newNames2); // ["JS高级"]
console.log("names:", names); // ["小吴", "why"] - 原数组被修改!
案例 2:对象操作
javascript
// ❌ 非纯函数:直接修改传入的对象
function baz(info) {
info.age = 100; // 副作用:修改了外部对象
}
const obj = { name: "小吴", age: 23 };
baz(obj);
console.log(obj); // { name: "小吴", age: 100 } - 原对象被修改
// ✅ 纯函数:返回新对象,不修改原对象
function test(info) {
return {
...info,
age: 100
};
}
const obj2 = { name: "小吴", age: 23 };
const newObj = test(obj2);
console.log(obj2); // { name: "小吴", age: 23 } - 原对象未变
console.log(newObj); // { name: "小吴", age: 100 } - 新对象
案例 3:React 组件
javascript
// React 函数组件应该像纯函数一样
// ✅ 正确:不修改 props
function HelloWorld(props) {
// 只读取 props,不修改
return <div>{props.message}</div>;
}
// ❌ 错误:修改 props
function BadComponent(props) {
props.count++; // 违反纯函数原则!
return <div>{props.count}</div>;
}
1.4 纯函数的优势
为什么纯函数在函数式编程中如此重要?
-
编写时更专注
- 只需实现业务逻辑,不用担心外部状态
- 不需要关心参数来源或依赖的外部变量
-
使用时更安心
- 确定输入不会被篡改
- 确定的输入必然产生确定的输出
- 可以安全地并发执行
-
测试更简单
- 不需要复杂的 mock 和环境准备
- 测试用例稳定可靠
-
易于调试和重构
- 函数行为可预测,问题容易定位
- 可以安全地替换或组合函数
React 官方文档明确要求:无论是函数组件还是 class 组件,都必须像纯函数一样保护 props 不被修改。

图 2:React 的严格规则
本节小结
- 纯函数三要素:确定性输出、无外部依赖、无副作用
- 副作用是 bug 的温床:修改外部状态会破坏可预测性
- 数据不可变性:优先创建新数据而非修改原数据
- 实践原则 :使用
slice、map、filter等不修改原数组的方法 - 框架要求:React/Redux 等框架强制要求纯函数思想
二、柯里化:单一职责的艺术
2.1 柯里化的本质
柯里化(Currying)是函数式编程的另一个核心概念。它的名字来源于数学家 Haskell Curry。
维基百科定义:
- 把接收多个参数的函数,转换成接受单一参数的函数
- 返回接受余下参数的新函数
- 最终返回结果
简单理解: 只传递给函数一部分参数来调用它,让它返回另一个函数处理剩余参数。
对比示例:
javascript
// 普通函数:一次性传入所有参数
function foo(m, n, x, y) {
return m + n + x + y;
}
foo(10, 20, 30, 40); // 100
// 柯里化函数:分步传入参数
function bar(m) {
return function(n) {
return function(x, y) {
return m + n + x + y;
};
};
}
bar(10)(20)(30, 40); // 100
这就像调节风扇档位:复杂需求可以分档次调节,每个档位的调用都基于前一档位,档位之间紧密关联且有明确顺序。
2.2 柯里化的结构演进
2.2.1 基础多参数函数
javascript
function add(x, y, z) {
return x + y + z;
}
const result = add(10, 20, 30);
console.log(result); // 60
2.2.2 柯里化改造
javascript
// 通过闭包实现参数保存
function sum(x) {
return function(y) {
return function(z) {
return x + y + z;
};
};
}
const result1 = sum(10)(20)(30);
console.log(result1); // 60
关键点:
- 每个函数接收一个参数并返回新函数
- 通过闭包访问上层函数的参数
- 最内层函数执行最终计算
2.2.3 箭头函数简化
javascript
// 方式 1:保留 return 关键字
const sum2 = x => y => z => {
return x + y + z;
};
// 方式 2:隐式返回(推荐)
const sum3 = x => y => z => x + y + z;
const result2 = sum3(20)(30)(40);
console.log(result2); // 90
箭头函数的链式写法大幅简化了柯里化代码,这也是现代 JavaScript 中常见的写法。
2.3 柯里化的核心价值
2.3.1 单一职责原则(SRP)
为什么需要柯里化?
在函数式编程中,我们希望:
- 一个函数处理的问题尽可能单一
- 不要将一大堆处理过程交给一个函数
- 每次传入的参数在单一函数中处理
- 处理完后在下一个函数中使用处理结果
这体现了单一职责原则(Single Responsibility Principle):一个类(或函数)应该只有一个引起它变化的原因。
对比示例:
javascript
// ❌ 所有逻辑挤在一起
function add(x, y, z) {
x = x + 2;
y = y * 2;
z = z * z;
return x + y + z;
}
console.log(add(10, 20, 30)); // 972
// ✅ 柯里化:每层处理一个职责
function sum(x) {
x = x + 2; // 第一层:处理 x
return function(y) {
y = y * 2; // 第二层:处理 y
return function(z) {
z = z * z; // 第三层:处理 z
return x + y + z;
};
};
}
console.log(sum(10)(20)(30)); // 972
注意边界:
- 单一职责不是越细越好,过度拆分会增加复杂度
- 职责的"粒度"需要根据实际项目判断
- 通常 2-3 层嵌套是最常见的情况
2.3.2 逻辑复用
柯里化的另一个重要优势是复用重复的参数 ,这和 bind 函数的思想类似。
案例 1:固定第一个参数
javascript
function foo(m, n) {
return m + n;
}
// 传统方式:重复传入相同的第一个参数
console.log(foo(5, 1)); // 6
console.log(foo(5, 2)); // 7
console.log(foo(5, 3)); // 8
console.log(foo(5, 4)); // 9
console.log(foo(5, 5)); // 10
// ✅ 柯里化:复用第一个参数
function makeAdder(count) {
return function(num) {
return count + num;
};
}
const adder5 = makeAdder(5);
console.log(adder5(1)); // 6
console.log(adder5(2)); // 7
console.log(adder5(3)); // 8
console.log(adder5(4)); // 9
console.log(adder5(5)); // 10
案例 2:日志函数优化
javascript
// ❌ 传统方式:重复传入时间和类型
function log(date, type, message) {
console.log(`[${date.getHours()}:${date.getMinutes()}][${type}]:[${message}]`);
}
log(new Date(), "DEBUG", "查找到轮播图的bug");
log(new Date(), "DEBUG", "查询菜单的bug");
log(new Date(), "DEBUG", "查询数据的bug");
// ✅ 柯里化优化:复用时间和类型
const logCurried = date => type => message => {
console.log(`[${date.getHours()}:${date.getMinutes()}][${type}]:[${message}]`);
};
// 复用时间
const nowLog = logCurried(new Date());
nowLog("DEBUG")("查找小吴去哪了");
// 复用时间 + 类型
const debugLog = logCurried(new Date())("DEBUG");
debugLog("查找信息1");
debugLog("查找信息2");
debugLog("查找信息3");
优势总结:
- 减少重复代码
- 提高函数灵活性
- 便于创建专用工具函数
2.4 通用柯里化函数实现
2.4.1 实现思路
如何将普通函数自动转换为柯里化函数?
需求分析:
- 传入一个普通函数,返回柯里化版本
- 需要知道函数的参数个数(通过
fn.length获取) - 支持多种调用方式:
fn(1,2,3)、fn(1,2)(3)、fn(1)(2)(3)
javascript
// 获取函数参数个数
function foo(x, y, z, q) {
console.log(foo.length); // 4
}
2.4.2 完整实现
javascript
function hyCurrying(fn) {
// 返回柯里化函数
function curried(...args) {
// 1. 参数足够时,直接执行原函数
if (args.length >= fn.length) {
// 使用 apply 绑定 this,避免指向问题
return fn.apply(this, args);
} else {
// 2. 参数不足时,返回新函数继续收集参数
function curried2(...args2) {
// 递归调用 curried,拼接参数
return curried.apply(this, args.concat(args2));
}
return curried2;
}
}
return curried;
}
// 测试
function add1(x, y, z) {
return x + y + z;
}
const curryAdd = hyCurrying(add1);
console.log(curryAdd(10, 20, 30)); // 60
console.log(curryAdd(10, 20)(30)); // 60
console.log(curryAdd(10)(20)(30)); // 60
实现要点:
fn.length:获取原函数的形参数量(上限)...args:收集用户传入的实参(不固定)- 参数足够时调用原函数,不足时递归返回新函数
- 使用
apply绑定this,防止指向偏移 - 使用
concat拼接历史参数和新参数
2.5 柯里化在源码中的应用
2.5.1 Vue3 源码案例
Vue3 源码中大量使用了柯里化思想。下图展示了 createApp 的实现:

图 3:Vue3 源码中的柯里化
在源码中,柯里化的运用方式更加灵活:

图 4:Vue3 源码 createAppAPI 的柯里化运用
代码结构:
javascript
return {
render,
hydrate,
createApp: createAppAPI(render, hydrate)
};
createAppAPI 返回的函数就是 createApp,通过 ES6 对象简写形式:
javascript
// 完整形式
createApp: createApp
// 简写形式
createApp
最终形成嵌套调用:
javascript
createAppAPI(render, hydrate)(rootComponent, rootProps)
这种写法进一步扩大了封装的灵活性,但也提高了抽象程度。
2.5.2 Redux 源码案例
Redux 中也有典型的柯里化应用:

图 5:Redux 柯里化调用
本节小结
- 柯里化本质:将多参数函数转换为单参数函数链
- 核心价值:单一职责 + 逻辑复用
- 实现关键:闭包保存参数 + 递归收集参数
- 应用场景:工具函数封装、参数预设、延迟执行
- 源码体现:Vue3、Redux 等框架广泛使用
- 注意事项:避免过度嵌套(2-3 层为宜)
三、组合函数:函数的乐高积木
3.1 什么是组合函数
组合函数(Compose Function)是函数式编程中的一种使用技巧,用于将多个函数组合成一个新函数。
场景描述:
- 需要对数据依次执行两个函数
fn1和fn2 - 每次都要手动调用两次,操作重复
- 能否将这两个函数组合起来,自动依次调用?
基础示例:
javascript
// 乘以 2
function double(num) {
return num * 2;
}
// 平方
function square(num) {
return num ** 2;
}
const count = 10;
// 传统方式:嵌套调用
const result = square(double(count)); // (10 * 2) ** 2 = 400
console.log(result);
// ✅ 组合函数:将两个函数组合
function composeFn(m, n) {
return function(count) {
return n(m(count));
};
}
const newFn = composeFn(double, square);
console.log(newFn(10)); // 400
核心思想:
- 第一层函数接收需要组合的函数
- 返回第二层函数(组合后的函数)接收数据
- 第二层函数内部依次执行传入的函数
3.2 组合函数的优势
- 保持函数独立性 :
double和square各自功能独立 - 减少重复调用:组合一次,多次使用
- 提高可读性 :
newFn(10)比square(double(10))更清晰 - 灵活组合 :可以调整执行顺序
n(m(count))或m(n(count))
这种模式和 bind 函数类似:所有操作都在第二层函数中完成。
四、通用组合函数实现
4.1 需求分析
前面的 composeFn 只能组合两个函数,实际开发中可能需要组合更多函数。我们需要实现一个通用的组合函数:
需求:
- 支持传入任意数量的函数
- 验证传入的都是函数类型
- 按顺序依次执行函数
- 上一个函数的返回值作为下一个函数的参数
4.2 完整实现
javascript
function hyCompose(...fns) {
const length = fns.length;
// 1. 验证:确保传入的都是函数
for (let i = 0; i < length; i++) {
if (typeof fns[i] !== 'function') {
throw new TypeError('所有参数必须是函数类型');
}
}
// 2. 返回组合后的函数
function compose(...args) {
let index = 0;
// 执行第一个函数,传入所有参数
let result = length ? fns[index].apply(this, args) : args;
// 依次执行剩余函数,每次传入上一个函数的返回值
while (++index < length) {
result = fns[index].call(this, result);
}
return result;
}
return compose;
}
// 测试
function double(m) {
return m * 2;
}
function square(n) {
return n ** 2;
}
function addTen(x) {
return x + 10;
}
// 组合多个函数
const newFn = hyCompose(double, square, addTen);
console.log(newFn(5)); // ((5 * 2) ** 2) + 10 = 110
实现要点:
- 参数验证:遍历检查每个参数是否为函数
- 边界处理 :
- 第一个函数使用
apply接收多个参数 - 后续函数使用
call接收单个参数(上一个函数的返回值)
- 第一个函数使用
- this 绑定 :使用
apply/call确保 this 指向正确 - 执行顺序 :按传入顺序依次执行(先
double,再square,最后addTen)
4.3 执行流程图解
javascript
newFn(5)
↓
double(5) → 10
↓
square(10) → 100
↓
addTen(100) → 110
本节小结
- 组合函数:将多个函数组合成一个新函数
- 适用场景:多个函数需要依次执行,且关联性强
- 实现关键:第一个函数接收多参数,后续函数接收单参数
- 执行顺序:按传入顺序依次执行
- 注意事项:需要验证参数类型,绑定 this 指向
五、实战落地建议
5.1 代码层面
纯函数实践清单:
-
优先使用不可变方法
- 数组:
map、filter、reduce、slice、concat - 对象:
Object.assign({},...)、{...obj} - 避免:
push、splice、sort(会修改原数组)
- 数组:
-
函数设计原则
- 输入通过参数传递,不依赖全局变量
- 输出通过 return 返回,不修改外部状态
- 避免在函数内部发起网络请求或操作 DOM
-
React 组件规范
- 函数组件不修改 props
- 使用
useState管理内部状态 - 副作用统一放在
useEffect中
柯里化应用场景:
-
工具函数封装
javascript// 通用请求函数 const request = baseURL => endpoint => params => { return fetch(`${baseURL}${endpoint}`, params); }; const apiRequest = request('https://api.example.com'); const getUserInfo = apiRequest('/user'); getUserInfo({ id: 123 }); -
事件处理优化
javascript// 避免在 JSX 中创建匿名函数 const handleClick = id => event => { console.log('Clicked item:', id); }; <button onClick={handleClick(item.id)}>Click</button> -
参数预设
javascriptconst logger = level => message => { console.log(`[${level}] ${message}`); }; const errorLog = logger('ERROR'); const infoLog = logger('INFO');
5.2 团队推广
渐进式推广策略:
-
第一阶段:意识培养
- 团队分享会讲解纯函数和柯里化概念
- Code Review 中指出副作用问题
- 建立最佳实践文档
-
第二阶段:工具支持
- ESLint 规则:禁止修改参数(
no-param-reassign) - 引入 Immutable.js 或 Immer.js
- 封装常用的柯里化工具函数
- ESLint 规则:禁止修改参数(
-
第三阶段:规范落地
- 新项目强制使用纯函数
- 老项目逐步重构
- 建立代码质量指标
常见问题应对:
| 问题 | 解决方案 |
|---|---|
| 性能担忧(创建新对象) | 使用 Immer.js 优化,实际性能影响很小 |
| 学习成本高 | 提供代码示例和最佳实践文档 |
| 历史代码改造难 | 新代码严格执行,老代码逐步重构 |
| 调试困难 | 使用 Redux DevTools 等工具 |
5.3 验证指标
代码质量指标:
- 单元测试覆盖率提升(纯函数更易测试)
- Bug 率下降(副作用减少)
- 代码复用率提升(柯里化提高复用性)
- Code Review 时间减少(代码更清晰)
六、总结与展望
6.1 核心要点回顾
纯函数:
- 确定的输入产生确定的输出
- 不产生副作用,不修改外部状态
- 是构建可预测、可测试代码的基础
- React、Redux 等框架的核心要求
柯里化:
- 将多参数函数转换为单参数函数链
- 体现单一职责原则
- 提高代码复用性和灵活性
- 在 Vue3、Redux 等源码中广泛应用
组合函数:
- 将多个函数组合成新函数
- 保持函数独立性的同时提高复用
- 函数式编程的重要技巧
6.2 进阶方向
-
深入函数式编程
- 学习 Functor、Monad 等高级概念
- 研究 Ramda.js、Lodash/fp 等函数式库
- 理解函数式编程在大型项目中的应用
-
框架源码阅读
- Vue3 响应式系统中的纯函数应用
- Redux 中间件的柯里化设计
- React Hooks 的函数式思想
-
性能优化
- 使用 Immer.js 优化不可变数据操作
- 理解 React.memo 和纯组件的关系
- 掌握函数式编程的性能优化技巧
6.3 团队落地路线图
短期(1-2 个月):
- 团队技术分享,统一认知
- 建立编码规范和最佳实践文档
- 新项目试点应用
中期(3-6 个月):
- 封装团队通用的工具函数库
- 配置 ESLint 规则自动检查
- Code Review 中强化纯函数要求
长期(6 个月以上):
- 老项目逐步重构
- 建立代码质量监控体系
- 沉淀团队函数式编程最佳实践
附录:常见误区
-
误区:纯函数不能有任何副作用
- 正解:console.log 等调试代码是可接受的副作用
- 关键是不影响函数的核心逻辑和可预测性
-
误区:柯里化会降低性能
- 正解:现代 JavaScript 引擎优化很好,性能影响微乎其微
- 代码可维护性的提升远大于微小的性能损失
-
误区:所有函数都要柯里化
- 正解:根据实际需求选择,不要过度设计
- 参数固定且无复用需求的函数不需要柯里化
-
误区:纯函数不能调用其他函数
- 正解:可以调用其他纯函数
- 关键是整体不产生副作用
参考资源:
本文适合有一定 JavaScript 基础的前端工程师阅读。如有疑问或建议,欢迎交流讨论。