"我写过 map / filter / reduce 就是函数式编程了吗?"不算。函数式编程(FP)不是某几个 API,而是一整套关于"如何用纯函数和不可变数据来组织程序"的思想。这篇文章把 FP 最核心的几个概念用 JavaScript 代码讲透,然后看看 React、Redux、RxJS 是怎么把这些思想嵌进现代前端的。
第一原则:纯函数
纯函数有两条:
- 给定相同输入,永远返回相同输出。
- 没有副作用 —— 不修改外部状态,不读取外部状态(不读全局变量、不读时间、不读 IO)。
// 纯函数:同样输入,同样输出,没副作用
function add(a, b) { return a + b; }
// 不纯:依赖外部 Date.now()
function timestamp() { return Date.now(); }
// 不纯:修改了外部数组
function pushItem(arr, x) { arr.push(x); return arr; }
// 改成纯的:不修改原数组,返回新数组
function append(arr, x) { return [...arr, x]; }
纯函数的价值:可预测、可缓存、可并行、易测试。给一个纯函数加 memoize 立刻有缓存效果,因为同输入永远同输出;两个纯函数可以并行跑,因为它们不共享状态;测试纯函数只需要 expect(f(input)).toBe(output),不需要 mock 一堆环境。
第二原则:不可变数据
FP 不修改数据,而是返回新数据。这听起来很贵,但现代实现用结构共享让它便宜:
const arr = [1, 2, 3, 4, 5];
// 不要 push,要返回新数组
const arr2 = [...arr, 6]; // [1,2,3,4,5,6]
// 不要修改对象,要返回新对象
const user = { name: 'mores', age: 30 };
const updated = { ...user, age: 31 };
// 嵌套结构呢?手动展开很烦,用 Immer 或 immutable.js
import produce from 'immer';
const next = produce(state, (draft) => {
draft.user.profile.age = 31; // 看起来"修改",其实生成新对象
});
不可变数据的另一面是引用相等可以用作变化的判断。React 的 === 判断 props 是否变了能跑得这么快,正是因为它假设你用不可变更新 —— 引用一样就一定没变,引用不一样可能变了。
高阶函数:把函数当数据用
函数式的标志特征:函数可以作为参数,也可以作为返回值。
// 高阶函数:接受函数为参数
function once(fn) {
let called = false, result;
return function (...args) {
if (called) return result;
called = true;
return result = fn(...args);
};
}
const init = once(() => expensiveInit());
init(); init(); init(); // 只跑一次
// 高阶函数:返回函数
function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) return fn(...args);
return (...rest) => curried(...args, ...rest);
};
}
const add = curry((a, b, c) => a + b + c);
add(1)(2)(3); add(1, 2)(3); add(1, 2, 3); // 全部 6
map / filter / reduce:三件套
函数式编程最常见的"工具",几乎所有数据处理都能用它们组合:
const users = [
{ name: 'A', age: 30, active: true },
{ name: 'B', age: 25, active: false },
{ name: 'C', age: 35, active: true },
];
// 提取活跃用户的名字
users.filter((u) => u.active).map((u) => u.name); // ['A', 'C']
// 算所有活跃用户的平均年龄
const active = users.filter((u) => u.active);
const avg = active.reduce((s, u) => s + u.age, 0) / active.length; // 32.5
// reduce 万能:其他所有都能用 reduce 实现
function myMap(arr, fn) {
return arr.reduce((acc, x) => [...acc, fn(x)], []);
}
function myFilter(arr, pred) {
return arr.reduce((acc, x) => pred(x) ? [...acc, x] : acc, []);
}
关键认知:每一步都返回新数组,链式调用是惰性可读的。命令式写法要么改循环变量、要么手动维护中间数组,易错且不直观。
组合:小函数搭积木
函数组合 compose(f, g)(x) = f(g(x)) 是 FP 的精髓。Lodash、Ramda 都把这个做成核心 API:
const compose = (...fns) => (x) => fns.reduceRight((acc, fn) => fn(acc), x);
const pipe = (...fns) => (x) => fns.reduce((acc, fn) => fn(acc), x);
const trim = (s) => s.trim();
const lower = (s) => s.toLowerCase();
const replaceSpaces = (s) => s.replace(/\s+/g, '-');
const slugify = pipe(trim, lower, replaceSpaces);
slugify(' Hello World '); // 'hello-world'
这种风格的好处:每个小函数极容易测试和复用,组合本身没有副作用,你可以重新组合出新的处理流程而不动原函数。
柯里化:把多参函数变成一串单参
// 普通版:参数顺序固定
function greet(greeting, name) {
return `${greeting}, ${name}!`;
}
// 柯里化版:可以"先固定一个参数,生成新函数"
const greetCurried = curry(greet);
const hi = greetCurried('Hi');
const hola = greetCurried('Hola');
hi('Alice'); // 'Hi, Alice!'
hola('Bob'); // 'Hola, Bob!'
柯里化在函数管道里特别有用:
const filterBy = curry((pred, arr) => arr.filter(pred));
const isActive = (u) => u.active;
const getActive = filterBy(isActive); // 部分应用,先固定 pred
getActive(users); // 之后随时传 arr
不可变状态机:Redux 的灵魂
把 FP 思想搬到状态管理,就得到 Redux:state 是不可变对象;reducer 是纯函数 (state, action) => newState;副作用在 reducer 外。
// 纯函数 reducer
function todoReducer(state = [], action) {
switch (action.type) {
case 'ADD':
return [...state, { id: Date.now(), text: action.text, done: false }];
case 'TOGGLE':
return state.map((t) =>
t.id === action.id ? { ...t, done: !t.done } : t,
);
case 'REMOVE':
return state.filter((t) => t.id !== action.id);
default:
return state;
}
}
// 派发动作
let state = todoReducer(undefined, { type: '@@INIT' });
state = todoReducer(state, { type: 'ADD', text: 'study' });
state = todoReducer(state, { type: 'TOGGLE', id: state[0].id });
这种纯函数 + 不可变的状态管理,让"时间旅行调试""撤销重做""可重复 bug 复现"成了几行代码就能做的事 —— 因为每一次状态都是独立的对象,你随时可以指回过去任何一个版本。
函数式错误处理:Maybe / Either
FP 不喜欢 try/catch(副作用),倾向于把"失败可能性"放进类型。常见两种容器:
// Maybe:有或没有
class Some { constructor(v) { this.v = v; } map(f) { return new Some(f(this.v)); } isSome() { return true; } }
class None { map() { return this; } isSome() { return false; } }
const maybe = (v) => v == null ? new None() : new Some(v);
// 链式调用,任一步是 None 后面都自动短路
const user = maybe(getUser(id))
.map((u) => u.profile)
.map((p) => p.address)
.map((a) => a.city);
// Either:成功或失败,但带具体错误
class Right { constructor(v) { this.v = v; } map(f) { return new Right(f(this.v)); } }
class Left { constructor(e) { this.e = e; } map() { return this; } }
const parseJSON = (s) => {
try { return new Right(JSON.parse(s)); }
catch (e) { return new Left(e.message); }
};
这种思路在 TypeScript 里用判别联合表达得更顺手:type Result<T, E> = { ok: true; value: T } | { ok: false; error: E }。Rust 的 Result、Go 的 (value, error)、Haskell 的 Either 都是同一个思想。
惰性求值与无限流
JS 原生数组都是急切的。借助 generator 可以做惰性:
function* range(start, end) {
for (let i = start; i < end; i++) yield i;
}
function* map(it, fn) {
for (const x of it) yield fn(x);
}
function* take(it, n) {
let i = 0;
for (const x of it) {
if (i++ >= n) return;
yield x;
}
}
// 链:从 1 开始的偶数,前 5 个
const squares = take(map(range(1, 1e9), (x) => x * x), 5);
console.log([...squares]); // [1, 4, 9, 16, 25]
// 没有真的生成 1e9 长的数组 —— 全程惰性
RxJS 的 Observable、Java 的 Stream、Scala 的 LazyList,都是这个思路的封装。
FP 的代价
不可变更新有内存开销 —— 每次都创建新对象。结构共享和 Immer 这类库能把开销压到很低,但仍然存在。对极热路径(每秒百万次操作)要权衡。
另一个代价是学习曲线:Functor、Monad、Applicative 这些术语会吓退新人。其实 90% 的工程价值用 map / filter / reduce / compose / 不可变更新 就拿到了,不必非要把 Monad 完整搞懂。
什么时候不用 FP
- 高性能数值计算:循环里逐字节处理 ArrayBuffer 用 for 远比 reduce 快。
- 本质就是 IO/副作用的代码:数据库写、文件操作、网络请求,硬要包成纯函数反而绕路。
- 团队不熟:函数式风格的 PR review 比命令式累得多,如果团队没共识,代码可读性会下降。
避坑:对 reduce 的常见误用
reduce 是最强大也最容易写坏的 API。下面这些反例值得注意:
// 反例 1:用 reduce 拼接长字符串,O(n²) 复杂度
arr.reduce((acc, x) => acc + x.name, ''); // 每次 + 都创建新字符串
// 改用 map + join
arr.map((x) => x.name).join('');
// 反例 2:用 reduce 累积巨大对象
arr.reduce((acc, x) => ({ ...acc, [x.id]: x }), {}); // O(n²) 展开开销
// 直接用 Object.fromEntries 或 for 循环
Object.fromEntries(arr.map((x) => [x.id, x]));
// 反例 3:在 reduce 里改 acc(失去纯函数性)
arr.reduce((acc, x) => { acc.push(x * 2); return acc; }, []); // 行为正确但风格不一致
// 想用副作用就用 forEach,想要新数组就用 map
规律:reduce 真正适合的是"把 n 个东西合并为一个,且这个合并操作是 O(1)"。一旦合并本身 O(n),整体就 O(n²),要换工具。
Transducer:把多次遍历合一
普通链式 arr.filter(f).map(g).reduce(h) 会遍历数组三次。当数据量大时,可以用 transducer 把所有变换塞进一次遍历:
// 简化版 transducer 思想
function transduce(transforms, reducer, init, arr) {
return arr.reduce((acc, x) => {
let v = x, skip = false;
for (const t of transforms) {
v = t(v);
if (v === null) { skip = true; break; } // null 表示丢弃(filter 不通过)
}
return skip ? acc : reducer(acc, v);
}, init);
}
const result = transduce(
[(x) => x.active ? x : null,
(x) => x.score * 2,
(x) => x > 100 ? x : null],
(acc, x) => acc + x,
0,
users,
);
Clojure 的 transducer、RxJS 的操作符链都是这种"延迟 + 融合"的思路。日常 JS 里用得不多,但理解它对你看懂"流式 API 为什么能高效"有帮助。
函子(Functor)与单子(Monad):用上面 Maybe 串起来
前面写过 Maybe 容器。它满足两个范畴论定义:
- 有
map方法,能把容器内的值变换成另一个值,容器形状不变 —— 这就是函子(Functor)。 - 有
flatMap(也叫chain、bind),能把"返回容器的函数"链起来不产生嵌套容器 —— 这就是单子(Monad)。
class Some {
constructor(v) { this.v = v; }
map(f) { return new Some(f(this.v)); } // 函子操作
flatMap(f) { return f(this.v); } // 单子操作,f 返回的应是 Maybe
}
class None {
map() { return this; }
flatMap() { return this; }
}
// flatMap 让链式查询里"任何一步可能没结果"变得优雅
maybe(getUser(id))
.flatMap((u) => maybe(u.profile)) // 不嵌套 Some(Some(...))
.flatMap((p) => maybe(p.address))
.map((a) => a.city.toUpperCase());
不必把"单子是什么"研究透才能用。记住一条:当你发现自己在写"先看有没有,有就继续,没就跳过"这种链式安全访问时,Maybe + flatMap 是最干净的表达。
实战:用 FP 重构一段命令式代码
// 命令式:计算订单总价、应用折扣、加税
function calcTotal(items, discount, taxRate) {
let total = 0;
for (const item of items) {
total += item.price * item.qty;
}
if (total > 100) total *= (1 - discount);
total *= (1 + taxRate);
return total;
}
// 函数式版本
const sum = (arr) => arr.reduce((a, b) => a + b, 0);
const applyDiscount = (rate) => (total) => total > 100 ? total * (1 - rate) : total;
const applyTax = (rate) => (total) => total * (1 + rate);
const itemTotal = (item) => item.price * item.qty;
const calcTotalFP = (items, discount, tax) => pipe(
(xs) => xs.map(itemTotal),
sum,
applyDiscount(discount),
applyTax(tax),
)(items);
函数式版本看着稍长,但每个小函数独立可测、可复用,业务规则用 pipe 一眼看清。更重要的是:加新规则(比如"满 200 再减 50")只需要写一个新函数插进 pipe,不动其他代码 —— 这就是组合式设计带来的扩展性。
写在最后
函数式编程不是"换一种语法",它是"换一种思考状态"的方式:把程序看作数据的转换流,而不是对状态的修改序列。一旦接受这个心智模型,你会发现 React 的渲染就是"state → UI"的函数、Redux 就是"action → state"的函数、流处理就是 map/filter/reduce 的链。
给开始尝试 FP 的人一个最小起步包:下次写循环前,先想想能不能用 map/filter/reduce;下次改对象前,先想想能不能用 ... 展开;下次发现一段逻辑被多处复用,先想想能不能拆成纯函数 + compose。从这三个习惯开始,几个月后你的代码会自然变得更短、更容易测、更不容易出 bug。
—— 别看了 · 2026