函数式编程实战指南:从纯函数到 Maybe / Either 容器

"我写过 map / filter / reduce 就是函数式编程了吗?"不算。函数式编程(FP)不是某几个 API,而是一整套关于"如何用纯函数和不可变数据来组织程序"的思想。这篇文章把 FP 最核心的几个概念用 JavaScript 代码讲透,然后看看 React、Redux、RxJS 是怎么把这些思想嵌进现代前端的。

第一原则:纯函数

纯函数有两条:

  1. 给定相同输入,永远返回相同输出。
  2. 没有副作用 —— 不修改外部状态,不读取外部状态(不读全局变量、不读时间、不读 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(也叫 chainbind),能把"返回容器的函数"链起来不产生嵌套容器 —— 这就是单子(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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

RSA 加密算法详解:从数学原理到生产级代码

2026-5-15 11:21:09

技术教程

单元测试与 TDD 完全指南:从 Red-Green-Refactor 到测试金字塔

2026-5-15 11:21:10

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索