函数式编程基础
函数式编程(FP)的几个核心理解:
- 把需求分解到最小的函数粒度
- 每个函数只处理一件事:给定输入得到确定的输出,不产生副作用,只做计算
- 把多个函数按顺序组合起来解决复杂问题
- FP 之所以复用性好,本质上是因为它的单元足够简单——只关心输入和输出
部分示例参考 JavaScript 函数式编程实践指南
一、命令式 vs 声明式
这是理解 FP 的入口:命令式关心"怎么做",声明式关心"要什么"。
需求
筛选出年龄 ≥ 24 岁的员工做生涯指导,输出一张清单,每条信息用逗号分隔,并按年龄升序展示。
拆成三步:排序 → 筛选 → 提取文本。
const peopleList = [
{ name: 'John Lee', age: 22, career: 'engineer' },
{ name: 'Bob Chen', age: 24, career: 'engineer' },
{ name: 'Lucy Liu', age: 26, career: 'PM' },
{ name: 'Jack Zhang', age: 28, career: 'PM' },
{ name: 'Yan Xiu', age: 30, career: 'engineer' },
];
命令式写法
const len = peopleList.length;
// 【排序】冒泡
for (let i = 0; i < len; i++) {
for (let j = 0; j < len - 1 - i; j++) { // -i:每轮结束后末尾已就位,无需再比
if (peopleList[j].age > peopleList[j + 1].age) {
[peopleList[j], peopleList[j + 1]] = [peopleList[j + 1], peopleList[j]];
}
}
}
let logText = '';
for (let i = 0; i < len; i++) {
const person = peopleList[i];
if (person.age >= 24) { // 【筛选】
const perLogText = `${person.name}'s age is ${person.age}`; // 【提取】
logText += logText ? `,${perLogText}` : perLogText; // 【拼接】
}
}
分隔符拼接的经典 bug
常见写法是靠下标判断是不是最后一个:
if (i !== len - 1) logText += `${perLogText},`;
else logText += perLogText;
这里的 i 是原数组的下标,而不是"已筛选结果"的下标。只要最后一个元素没通过筛选,就会留下一个多余的尾逗号。 上面的示例数据碰巧最后一位(30 岁)满足条件,所以看不出问题——这类"数据碰巧对了"的 bug 最难排查。
稳妥的做法是像上面那样判断 logText 是否为空,或者干脆收集到数组里最后 join(',')——这正是函数式写法天然避免掉的一类错误。
命令式写法的三个问题:四个关注点(排序/筛选/提取/拼接)纠缠在两个循环里、peopleList 被原地修改、每一段逻辑都无法单独复用或测试。
函数式写法
// 每个函数只做一件事,都可以单独测试和复用
const ageBiggerThan24 = person => person.age >= 24;
const smallAgeFirst = (a, b) => a.age - b.age;
const generateLogText = person => `${person.name}'s age is ${person.age}`;
const logText = peopleList
.filter(ageBiggerThan24) // 先筛选再排序:待排序的数据更少
.sort(smallAgeFirst)
.map(generateLogText)
.join(',');
比较器必须返回数字,不能返回布尔值
const smallAgeFirst = (a, b) => a.age < b.age; // ❌
sort 的比较器约定:返回负数表示 a 排前面、正数表示 b 排前面、0 表示保持相对顺序。返回布尔值时 true → 1、false → 0,永远产生不了负数,V8 的 TimSort 会认为"要么交换要么相等",结果是数组几乎原样不动:
const ages = [28, 25, 22, 23, 24, 30, 26];
[...ages].sort((a, b) => a < b); // [28, 25, 22, 23, 24, 30, 26] ← 完全没排序
[...ages].sort((a, b) => a - b); // [22, 23, 24, 25, 26, 28, 30] ✅
这个 bug 在小数组上尤其隐蔽——元素少时可能碰巧是对的。
sort 会原地修改数组
sort 和 reverse 都是变异方法,返回的是原数组的引用。上面的链式调用之所以安全,是因为 filter 已经产出了一个新数组。直接 peopleList.sort(...) 则会破坏原始数据——这违背 FP 的原则。
ES2023 提供了不可变版本:toSorted()、toReversed()、toSpliced()、with(),Node 20+ 与现代浏览器均已支持:
const sorted = peopleList.toSorted(smallAgeFirst); // 原数组不变
二、纯函数
FP 的基石。一个函数是纯函数,必须同时满足:
- 相同输入永远得到相同输出(不依赖外部可变状态);
- 没有副作用(不修改外部世界)。
// ❌ 不纯:依赖外部变量,同样的输入结果会变
let taxRate = 0.1;
const addTax = price => price * (1 + taxRate);
// ❌ 不纯:修改了入参(副作用)
const addItem = (cart, item) => { cart.push(item); return cart; };
// ✅ 纯:依赖全部来自参数,返回新值
const addTax2 = (price, rate) => price * (1 + rate);
const addItem2 = (cart, item) => [...cart, item];
什么算副作用:修改入参或全局变量、发起网络请求、读写 DOM / 存储 / 文件、打印日志、抛异常、读取 Date.now() / Math.random()(同样的输入结果不同)。
纯函数的收益
- 可测试:不需要 mock 环境,给输入断言输出即可;
- 可缓存:相同输入必然同样输出,因此可以记忆化;
- 可并行/可重排:互不干扰,顺序无关;
- 易推理:读一个纯函数不需要在脑子里维护外部状态。
引用透明性是它的正式表述:任何一处 f(x) 都可以直接替换成它的返回值而不改变程序行为。
程序不可能全是纯函数
副作用不是"坏东西",它是程序唯一有用的产出——不写 DOM、不发请求的程序毫无意义。FP 的主张不是消灭副作用,而是把副作用推到边界:
输入(读取数据)→ ‖ 纯函数组成的核心逻辑 ‖ → 输出(渲染 / 请求 / 存储)
这就是 "Functional Core, Imperative Shell" 架构。Redux 就是范例:reducer 必须是纯函数,所有副作用被赶到 middleware 里。
三、函数是一等公民
JS 的函数本质上是可执行的对象,因此可以像普通值一样被对待。"一等公民"要满足三个条件:
- 可以赋值给变量:
const f = function () {} - 可以作为参数传递:
arr.map(fn) - 可以作为返回值返回:
const add = a => b => a + b
这三条是所有 FP 技巧(高阶函数、柯里化、组合)成立的前提,也是 JS 里策略模式、装饰器模式能退化成几行代码的原因。
作为对象,函数还带有自己的属性:
function add(a, b) { return a + b; }
add.name; // 'add'
add.length; // 2,形参个数(不含默认值参数和 rest 参数之后的部分)
add.call / apply / bind;
Function.length的这个特性是通用柯里化的实现基础。
四、不可变性
纯函数要求"不修改入参",落到 JS 上就是引用类型的不可变处理。这部分单独成篇 👉 数据不可变性
下一步 👉 函数式的核心实践:组合
