纯函数、柯里化与函数组合:从原理到源码,构建更可维护的前端代码体系

49 阅读17分钟

为什么要关注纯函数和柯里化?

在日常开发中,你是否遇到过这些问题:

  • 修改一个函数后,其他看似无关的模块出现了 bug
  • 相同的输入有时返回不同的结果,导致测试用例不稳定
  • 代码复用困难,类似的逻辑在多处重复编写
  • 阅读 React、Redux、Vue3 源码时,对某些设计模式感到困惑

这些问题的根源往往在于:缺乏对函数式编程核心概念的理解。纯函数和柯里化作为函数式编程的两大基石,不仅能帮助我们写出更稳定、可测试的代码,更是理解现代前端框架设计思想的关键。

本文收益:

  • 掌握纯函数的定义与实践,避免副作用带来的隐患
  • 理解柯里化的本质,学会用单一职责原则优化代码结构
  • 从 Vue3、Redux 源码中看到这些思想的实际应用
  • 获得可直接落地的编码实践和团队推广建议

一、纯函数:稳定性的基石

1.1 什么是纯函数

JavaScript 符合函数式编程范式,纯函数是其中最重要的概念之一。在 React 开发中,组件被要求像纯函数一样工作;在 Redux 中,reducer 必须是纯函数。理解纯函数,是掌握现代前端框架的必经之路。

下图展示了 Redux 官方文档对数据不可变性的强调:

图 1:React 中的数据不可变性

根据维基百科定义,纯函数需要满足三个条件:

  1. 确定性输出:相同的输入必然产生相同的输出
  2. 无外部依赖:输出只依赖于输入参数,不依赖外部状态或 I/O 设备
  3. 无副作用:不触发事件、不修改外部状态、不改变输入参数

简单总结:

  • 确定的输入 → 确定的输出(可预测性)
  • 执行过程中不产生副作用(隔离性)

"纯"字表达的是"纯粹"的含义,即函数只做一件事:根据输入计算输出,不做任何额外操作。

1.2 副作用:bug 的温床

什么是副作用?

副作用(Side Effect)源自医学概念,指药物在治疗疾病之外产生的额外影响。在计算机科学中,副作用指函数执行时,除了返回值之外对外部环境产生的影响,例如:

  • 修改全局变量
  • 修改传入的参数对象
  • 发起网络请求
  • 操作 DOM
  • 写入文件或数据库
  • 打印日志(严格来说也是副作用,但通常可接受)

为什么副作用是问题?

副作用会破坏代码的可预测性和可测试性。当函数依赖或修改外部状态时:

  • 相同输入可能产生不同输出
  • 函数行为难以追踪和调试
  • 并发执行时可能产生竞态条件
  • 单元测试需要复杂的 mock 和环境准备

在编程中,我们提倡"数据的不可变性"(Immutability):尽量不修改原有数据,而是创建新数据。这是避免副作用的重要实践。

1.3 纯函数实战案例

让我们通过数组操作来理解纯函数:

案例 1:slice vs splice

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:对象操作

// ❌ 非纯函数:直接修改传入的对象
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 组件

// React 函数组件应该像纯函数一样
// ✅ 正确:不修改 props
function HelloWorld(props) {
  // 只读取 props,不修改
  return <div>{props.message}</div>;
}

// ❌ 错误:修改 props
function BadComponent(props) {
  props.count++; // 违反纯函数原则!
  return <div>{props.count}</div>;
}

1.4 纯函数的优势

为什么纯函数在函数式编程中如此重要?

  1. 编写时更专注

    • 只需实现业务逻辑,不用担心外部状态
    • 不需要关心参数来源或依赖的外部变量
  2. 使用时更安心

    • 确定输入不会被篡改
    • 确定的输入必然产生确定的输出
    • 可以安全地并发执行
  3. 测试更简单

    • 不需要复杂的 mock 和环境准备
    • 测试用例稳定可靠
  4. 易于调试和重构

    • 函数行为可预测,问题容易定位
    • 可以安全地替换或组合函数

React 官方文档明确要求:无论是函数组件还是 class 组件,都必须像纯函数一样保护 props 不被修改。

图 2:React 的严格规则

本节小结

  • 纯函数三要素:确定性输出、无外部依赖、无副作用
  • 副作用是 bug 的温床:修改外部状态会破坏可预测性
  • 数据不可变性:优先创建新数据而非修改原数据
  • 实践原则:使用 slicemapfilter 等不修改原数组的方法
  • 框架要求:React/Redux 等框架强制要求纯函数思想

二、柯里化:单一职责的艺术

2.1 柯里化的本质

柯里化(Currying)是函数式编程的另一个核心概念。它的名字来源于数学家 Haskell Curry。

维基百科定义:

  • 把接收多个参数的函数,转换成接受单一参数的函数
  • 返回接受余下参数的新函数
  • 最终返回结果

简单理解: 只传递给函数一部分参数来调用它,让它返回另一个函数处理剩余参数。

对比示例:

// 普通函数:一次性传入所有参数
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 基础多参数函数

function add(x, y, z) {
  return x + y + z;
}

const result = add(10, 20, 30);
console.log(result); // 60

2.2.2 柯里化改造

// 通过闭包实现参数保存
function sum(x) {
  return function(y) {
    return function(z) {
      return x + y + z;
    };
  };
}

const result1 = sum(10)(20)(30);
console.log(result1); // 60
  • 在全局作用域中定义了一个名为 sum 的函数。
  • sum 接受一个参数 x,执行后会返回一个匿名函数(记作 inner1)。
  • inner1 接受一个参数 y,执行后会返回另一个匿名函数(记作 inner2)。
  • inner2 接受一个参数 z,执行时会返回 x + y + z 的计算结果。
  • 由于 JavaScript 的闭包特性,每个内部函数都能访问其外部函数的参数,因此 x 和 y 的值会被一直保留。

关键点:

  • 每个函数接收一个参数并返回新函数
  • 通过闭包访问上层函数的参数
  • 最内层函数执行最终计算

2.2.3 箭头函数简化

// 方式 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):一个类(或函数)应该只有一个引起它变化的原因。

对比示例:

// ❌ 所有逻辑挤在一起
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:固定第一个参数

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:日志函数优化

// ❌ 传统方式:重复传入时间和类型
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 实现思路

如何将普通函数自动转换为柯里化函数?

需求分析:

  1. 传入一个普通函数,返回柯里化版本
  2. 需要知道函数的参数个数(通过 fn.length 获取)
  3. 支持多种调用方式:fn(1,2,3)fn(1,2)(3)fn(1)(2)(3)
// 获取函数参数个数
function foo(x, y, z, q) {
  console.log(foo.length); // 4
}

2.4.2 完整实现

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 的柯里化运用

代码结构:

return {
  render,
  hydrate,
  createApp: createAppAPI(render, hydrate)
};

createAppAPI 返回的函数就是 createApp,通过 ES6 对象简写形式:

// 完整形式
createApp: createApp

// 简写形式
createApp

最终形成嵌套调用:

createAppAPI(render, hydrate)(rootComponent, rootProps)

这种写法进一步扩大了封装的灵活性,但也提高了抽象程度。

2.5.2 Redux 源码案例

Redux 中也有典型的柯里化应用:

图 5:Redux 柯里化调用

参考链接:redux-thunk/src/index.ts

本节小结

  • 柯里化本质:将多参数函数转换为单参数函数链
  • 核心价值:单一职责 + 逻辑复用
  • 实现关键:闭包保存参数 + 递归收集参数
  • 应用场景:工具函数封装、参数预设、延迟执行
  • 源码体现:Vue3、Redux 等框架广泛使用
  • 注意事项:避免过度嵌套(2-3 层为宜)

三、组合函数:函数的乐高积木

3.1 什么是组合函数

组合函数(Compose Function)是函数式编程中的一种使用技巧,用于将多个函数组合成一个新函数。

场景描述:

  • 需要对数据依次执行两个函数 fn1fn2
  • 每次都要手动调用两次,操作重复
  • 能否将这两个函数组合起来,自动依次调用?

基础示例:

// 乘以 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 组合函数的优势

  1. 保持函数独立性doublesquare 各自功能独立
  2. 减少重复调用:组合一次,多次使用
  3. 提高可读性newFn(10)square(double(10)) 更清晰
  4. 灵活组合:可以调整执行顺序 n(m(count))m(n(count))

这种模式和 bind 函数类似:所有操作都在第二层函数中完成。


四、通用组合函数实现

4.1 需求分析

前面的 composeFn 只能组合两个函数,实际开发中可能需要组合更多函数。我们需要实现一个通用的组合函数:

需求:

  • 支持传入任意数量的函数
  • 验证传入的都是函数类型
  • 按顺序依次执行函数
  • 上一个函数的返回值作为下一个函数的参数

4.2 完整实现

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

实现要点:

  1. 参数验证:遍历检查每个参数是否为函数
  2. 边界处理
    • 第一个函数使用 apply 接收多个参数
    • 后续函数使用 call 接收单个参数(上一个函数的返回值)
  3. this 绑定:使用 apply/call 确保 this 指向正确
  4. 执行顺序:按传入顺序依次执行(先 double,再 square,最后 addTen

4.3 执行流程图解

newFn(5)
  ↓
double(5) → 10square(10) → 100addTen(100) → 110

本节小结

  • 组合函数:将多个函数组合成一个新函数
  • 适用场景:多个函数需要依次执行,且关联性强
  • 实现关键:第一个函数接收多参数,后续函数接收单参数
  • 执行顺序:按传入顺序依次执行
  • 注意事项:需要验证参数类型,绑定 this 指向

五、实战落地建议

5.1 代码层面

纯函数实践清单:

  1. 优先使用不可变方法

    • 数组:mapfilterreducesliceconcat
    • 对象:Object.assign({},...){...obj}
    • 避免:pushsplicesort(会修改原数组)
  2. 函数设计原则

    • 输入通过参数传递,不依赖全局变量
    • 输出通过 return 返回,不修改外部状态
    • 避免在函数内部发起网络请求或操作 DOM
  3. React 组件规范

    • 函数组件不修改 props
    • 使用 useState 管理内部状态
    • 副作用统一放在 useEffect

柯里化应用场景:

  1. 工具函数封装

    // 通用请求函数
    const request = baseURL => endpoint => params => {
      return fetch(`${baseURL}${endpoint}`, params);
    };
    
    const apiRequest = request('https://api.example.com');
    const getUserInfo = apiRequest('/user');
    getUserInfo({ id: 123 });
    
  2. 事件处理优化

    // 避免在 JSX 中创建匿名函数
    const handleClick = id => event => {
      console.log('Clicked item:', id);
    };
    
    <button onClick={handleClick(item.id)}>Click</button>
    
  3. 参数预设

    const logger = level => message => {
      console.log(`[${level}] ${message}`);
    };
    
    const errorLog = logger('ERROR');
    const infoLog = logger('INFO');
    

5.2 团队推广

渐进式推广策略:

  1. 第一阶段:意识培养

    • 团队分享会讲解纯函数和柯里化概念
    • Code Review 中指出副作用问题
    • 建立最佳实践文档
  2. 第二阶段:工具支持

    • ESLint 规则:禁止修改参数(no-param-reassign
    • 引入 Immutable.js 或 Immer.js
    • 封装常用的柯里化工具函数
  3. 第三阶段:规范落地

    • 新项目强制使用纯函数
    • 老项目逐步重构
    • 建立代码质量指标

常见问题应对:

问题解决方案
性能担忧(创建新对象)使用 Immer.js 优化,实际性能影响很小
学习成本高提供代码示例和最佳实践文档
历史代码改造难新代码严格执行,老代码逐步重构
调试困难使用 Redux DevTools 等工具

5.3 验证指标

代码质量指标:

  • 单元测试覆盖率提升(纯函数更易测试)
  • Bug 率下降(副作用减少)
  • 代码复用率提升(柯里化提高复用性)
  • Code Review 时间减少(代码更清晰)

六、总结与展望

6.1 核心要点回顾

纯函数:

  • 确定的输入产生确定的输出
  • 不产生副作用,不修改外部状态
  • 是构建可预测、可测试代码的基础
  • React、Redux 等框架的核心要求

柯里化:

  • 将多参数函数转换为单参数函数链
  • 体现单一职责原则
  • 提高代码复用性和灵活性
  • 在 Vue3、Redux 等源码中广泛应用

组合函数:

  • 将多个函数组合成新函数
  • 保持函数独立性的同时提高复用
  • 函数式编程的重要技巧

6.2 进阶方向

  1. 深入函数式编程

    • 学习 Functor、Monad 等高级概念
    • 研究 Ramda.js、Lodash/fp 等函数式库
    • 理解函数式编程在大型项目中的应用
  2. 框架源码阅读

    • Vue3 响应式系统中的纯函数应用
    • Redux 中间件的柯里化设计
    • React Hooks 的函数式思想
  3. 性能优化

    • 使用 Immer.js 优化不可变数据操作
    • 理解 React.memo 和纯组件的关系
    • 掌握函数式编程的性能优化技巧

6.3 团队落地路线图

短期(1-2 个月):

  • 团队技术分享,统一认知
  • 建立编码规范和最佳实践文档
  • 新项目试点应用

中期(3-6 个月):

  • 封装团队通用的工具函数库
  • 配置 ESLint 规则自动检查
  • Code Review 中强化纯函数要求

长期(6 个月以上):

  • 老项目逐步重构
  • 建立代码质量监控体系
  • 沉淀团队函数式编程最佳实践

附录:常见误区

  1. 误区:纯函数不能有任何副作用

    • 正解:console.log 等调试代码是可接受的副作用
    • 关键是不影响函数的核心逻辑和可预测性
  2. 误区:柯里化会降低性能

    • 正解:现代 JavaScript 引擎优化很好,性能影响微乎其微
    • 代码可维护性的提升远大于微小的性能损失
  3. 误区:所有函数都要柯里化

    • 正解:根据实际需求选择,不要过度设计
    • 参数固定且无复用需求的函数不需要柯里化
  4. 误区:纯函数不能调用其他函数

    • 正解:可以调用其他纯函数
    • 关键是整体不产生副作用

参考资源:


本文适合有一定 JavaScript 基础的前端工程师阅读。如有疑问或建议,欢迎交流讨论。