函数式编程基础

张玥 2026年9月20日 阅读时间 45分钟
函数式编程 纯函数 不可变数据 高阶函数 函数组合
前端开发技巧之函数式编程基础

“函数式编程”这四个字,在前端圈已经被讲了很多年,但真正把它落到日常代码里的人依然是少数。大多数人第一次接触它,是在 Redux 的 reducer 里;第二次是在 React 的 setState 更新函数里;第三次是在数组的 map / filter / reduce 链式调用里。这些 API 每天都在用,可一旦被问到“为什么 reducer 必须是纯函数”“为什么不要直接改 state”“compose 和 pipe 到底差在哪”,回答往往就含糊了。函数式编程不是玄学,也不是要你把 JavaScript 写成 Haskell。它是一套关于“如何组织数据流动”的实用方法论:用纯函数替代共享状态,用不可变数据替代原地修改,用组合替代继承,用声明式描述替代命令式步骤。本篇 45 分钟长文,从最基础的概念讲起,逐层推进到高阶函数、函数组合、柯里化、函子与惰性求值,最后给出一套可以立即落地的重构方法与检查清单。

一、为什么前端需要函数式编程

前端应用的复杂度来源,和十年前已经完全不同。过去我们关心的是“怎么把一个页面画出来”,现在我们关心的是“怎么让一个持续运行数小时、状态成百上千、同时处理多个异步请求的单页应用不出错”。复杂度的重心,从渲染转移到了状态管理。

而状态管理的绝大多数 bug,都来自同一个根源:同一份数据被多个地方共享,并且被多个地方修改。你永远不知道是谁在什么时候改了它,只能靠 console.log 一步步回溯。函数式编程正是针对这一类问题开出的一整套处方。

共享可变状态 两个模块引用同一个对象,A 改了它,B 的逻辑就悄悄失效
隐式依赖 函数内部读取全局变量或 DOM,调用结果随环境变化而变
时序耦合 “必须先执行 A 再执行 B”,一旦顺序变化整个流程崩溃
难以测试 要测一个函数,得先搭出一个完整的运行环境

三行代码看懂命令式与函数式的差别

假设要计算一个数组里所有偶数的平方和。命令式的写法是这样的:

let sum = 0;
for (let i = 0; i < nums.length; i++) {
  if (nums[i] % 2 === 0) {
    sum += nums[i] * nums[i];
  }
}
return sum;

函数式的写法是这样的:

const sum = nums
  .filter(n => n % 2 === 0)
  .map(n => n * n)
  .reduce((a, b) => a + b, 0);

两者做的事情完全一样,但阅读体验天差地别。命令式版本要求你在大脑里模拟一遍执行过程:i 从 0 开始,每次加一,判断奇偶,累加……函数式版本则是直接描述结果是什么:先筛出偶数,再平方,最后求和。你不需要关心循环变量,不需要关心累加器在哪里被修改。

这就是函数式编程最核心的价值主张:减少你需要同时在大脑里保持的“活动状态”数量。而人的工作记忆容量是有限的,减少状态就是减少 bug。

它已经在你的代码里了

你不需要“开始学习函数式编程”,因为你已经在用了。以下这些每天都在写的东西,全部来自函数式编程:

map / filter 高阶函数,接收函数作为参数
reduce 折叠操作,函数式最核心的抽象之一
Promise.then 本质是函子的 map,链式组合
Redux reducer (state, action) => newState 纯函数
React 组件 props => UI 的纯映射(理想情况下)
// 这段代码你写过一百遍
const names = users.map(u => u.name);

// 它其实等价于
const names = map(u => u.name)(users);

// 而 map 的定义是:
const map = f => arr => arr.map(f);

// 一旦写成这种形式,它就可以被组合
const getName = map(u => u.name);
getName(users); // 复用同一个映射

本章后面的所有内容,本质上都是在帮你把这些“已经在用但说不清楚”的直觉,整理成一套可以主动运用的思维工具。

二、纯函数:整个体系的地基

如果函数式编程只能保留一个概念,那一定是纯函数。其他所有概念——不可变性、组合、柯里化、函子——都是在为“让纯函数成为可能”服务。

两条规则,缺一不可

规则一
相同输入 → 相同输出
规则二
不产生任何副作用

第一条通常被称为“引用透明”(Referential Transparency)。它的意思是:函数调用表达式可以被它的返回值直接替换,而不改变程序的行为。如果你写 const x = add(2, 3),那么在任何地方把 add(2, 3) 换成 5,程序的表现都应该完全一致。

第二条说的是:函数除了返回结果之外,不应该与外界发生任何可观察的交互。

什么算“副作用”

副作用的范围比很多人想象的广得多。以下这些都算:

修改外部变量 给全局对象、闭包变量、传入的参数对象赋值
修改传入的引用 arr.push(x)、obj.key = v,即使这个对象是参数
I/O 操作 console.log、fetch、localStorage、DOM 操作
读取外部可变状态 Date.now()、Math.random()、读全局配置
抛异常 在某些严格定义下,throw 也是一种副作用
修改 this 依赖调用上下文的函数,行为不可预测

对照着看,差别一目了然

// ❌ 不纯:依赖外部状态
let tax = 0.1;
function price(n) {
  return n * (1 + tax);
}

// ❌ 不纯:修改了传入的参数
function addItem(cart, item) {
  cart.push(item);
  return cart;
}

// ❌ 不纯:输出不确定
function makeId() {
  return Math.random().toString(36);
}

// ✅ 纯:所有依赖都来自参数
const price = (n, rate) => n * (1 + rate);

// ✅ 纯:返回新数组,不碰原数组
const addItem = (cart, item) => [...cart, item];
可预测 同样的输入永远得到同样的结果
可缓存 输入没变就不必重算,memoize 天然成立
可测试 不需要 mock,直接断言输入输出
可并行 不共享状态,就不会有竞争条件
可组合 输出即输入,函数之间可以自由拼接

为什么“可缓存”这件事很重要

引用透明带来一个非常实用的推论:如果参数没变,结果一定没变,那么结果就可以复用。这让 memoization 从一种“技巧”变成了“理所当然”。

function memoize(fn) {
  const cache = new Map();
  return function (...args) {
    const key = JSON.stringify(args);
    if (cache.has(key)) return cache.get(key);
    const result = fn.apply(this, args);
    cache.set(key, result);
    return result;
  };
}

// 只有当 fn 是纯函数时,这个包装才是安全的
const heavyCalc = memoize((n) => {
  // 大量计算……
  return n * n;
});
常见误区
“只要函数没有 return 就是纯函数”——错。一个什么都不返回但修改了全局变量的函数,副作用最严重。
判断标准
问自己两个问题:同样的参数调用两次,结果一样吗?调用它之后,函数外部的世界有任何变化吗?

纯函数不等于“完全不能有副作用”

这一点必须先说清楚,否则很容易走极端。任何一个真实应用,最终都必须产生副作用——要把像素画到屏幕上,要把数据发给服务器,要把内容写进 localStorage。

函数式编程的目标不是消灭副作用,而是把副作用挤到系统的边缘。核心的业务逻辑用纯函数表达,最容易测试、最容易推理;副作用只在最外层发生,并且集中、可见、可控制。这就是所谓的“函数式核心,命令式外壳”(Functional Core, Imperative Shell)。

// 纯函数核心:只做数据转换,好测试
const buildOrder = (cart, user) => ({
  items: cart.map(toLineItem),
  total: cart.reduce((s, i) => s + i.price * i.qty, 0),
  buyer: user.id,
  createdAt: user.now
});

// 命令式外壳:副作用集中在这一层
async function submitOrder(cart, user) {
  const order = buildOrder(cart, { ...user, now: Date.now() });
  const res = await fetch('/api/orders', {
    method: 'POST',
    body: JSON.stringify(order)
  });
  return res.json();
}

注意 Date.now() 的处理方式:它本身是不纯的,但我们在外壳里把时间作为参数注入进去,让 buildOrder 保持纯净。这个手法叫“依赖注入”,是让纯函数与真实世界共存的常用技巧。

三、不可变数据:从源头掐断 bug

纯函数的第二条规则是“不修改外部状态”,而 JavaScript 里最容易违反这条规则的地方,就是对象与数组的原地修改。

一个让人抓狂的经典 bug

const original = { name: '张玥', tags: ['css'] };

// 看起来像是"复制"
const copy = { ...original };
copy.tags.push('react');

console.log(original.tags);
// ['css', 'react'] —— 原对象也被改了!

原因是展开运算符只做浅拷贝:copy.tags 和 original.tags 指向的是同一个数组。这个问题在 React 的 setState 时代制造了无数“界面不更新”和“数据莫名其妙被改”的 bug。

哪些方法会改原数组,哪些不会

会改变原数组(危险) 返回新数组(安全) 说明
push / pop concat 增删元素优先用 concat 或展开
shift / unshift slice slice 是复制,splice 是修改
splice filter / map 删除用 filter,替换用 map
sort [...arr].sort() sort 就地排序,必须先复制
reverse [...arr].reverse() 同上,toReversed() 是新方案
fill / copyWithin Array.from / flatMap 构造新数组更安全

四种常用的不可变更新写法

// 1. 添加元素
const added = [...list, newItem];

// 2. 删除元素
const removed = list.filter(i => i.id !== id);

// 3. 修改元素
const updated = list.map(i =>
  i.id === id ? { ...i, done: true } : i
);

// 4. 深层更新
const next = {
  ...state,
  user: {
    ...state.user,
    profile: {
      ...state.user.profile,
      name: '新名字'
    }
  }
};
可追溯 每次变更产生新对象,历史状态天然保留
引用比较 === 就能判断有没有变化,React 依赖这一点
时间旅行 保存状态快照即可实现撤销/重做
代价 频繁复制大对象会增加内存与 GC 压力

深层不可变更容易踩的坑

手动写深层展开非常痛苦,层级一深就变成“缩进地狱”。实践中通常有三种应对方式:

  • 扁平化状态结构:把嵌套对象拆成 { ids: [], byId: {} } 的形式,更新时只需要动一层。这是 Redux 官方推荐的做法,也是性能最好的方案。
  • 使用 Immer 的思路:produce(state, draft => { draft.user.name = 'x' })——你写的是“可变”的代码,库内部用 Proxy 记录修改并生成新的不可变结构。
  • 限制嵌套深度:如果一份状态需要展开四层才能改一个字段,这通常说明数据结构本身需要重新设计。

Object.freeze 只是浅冻结

const config = Object.freeze({
  theme: 'dark',
  colors: ['#000']
});

config.theme = 'light'; // 静默失败(严格模式抛错)
config.colors.push('#fff'); // ✅ 依然能改!

// 深层冻结需要递归
const deepFreeze = (obj) => {
  Object.values(obj).forEach(v => {
    if (v && typeof v === 'object') deepFreeze(v);
  });
  return Object.freeze(obj);
};

顺便一提,structuredClone() 是现代浏览器提供的深拷贝方案,支持 Map、Set、Date、ArrayBuffer 等类型,比 JSON.parse(JSON.stringify(x)) 可靠得多——后者会丢失函数、undefined、Date 会变成字符串、循环引用直接报错。

性能顾虑:复制一定慢吗?

这是最常见的质疑。事实上:

  • 浅复制一个对象({ ...obj })只是复制一层引用,成本极低,通常远低于一次 DOM 操作。
  • 现代 JS 引擎对展开运算符有大量优化,几万次浅复制在现代设备上也就几毫秒。
  • 真正需要警惕的是每次渲染都深拷贝一个巨大的状态树,而不是“使用了不可变更新”。
  • 不可变性带来的收益——可预测性、可调试性、避免重渲染——通常远超它的成本。

四、高阶函数:把行为当作参数

JavaScript 里函数是一等公民:可以赋值给变量、可以作为参数传递、可以作为返回值返回。满足以下任意一条的函数,就叫高阶函数:

  • 接收一个或多个函数作为参数
  • 返回一个函数

三剑客:map、filter、reduce

方法 作用 输入输出长度 典型场景
map 一对一转换 长度不变 提取字段、格式化、加派生属性
filter 条件筛选 长度减少 去重、按状态过滤、搜索匹配
reduce 折叠成单一值 任意 求和、分组、建索引、管道

三者的共同点是:你只需要描述“对每个元素做什么”,不需要管理循环变量、索引边界和结果容器。这就是声明式的力量。

reduce 是万能的,但不要滥用

理论上,map 和 filter 都可以用 reduce 实现,因为它们本质上都是“遍历 + 累积”。但可读性上,能用 map 就别用 reduce。

// ❌ 用 reduce 做 map 的事
const names = users.reduce((acc, u) => {
  acc.push(u.name);
  return acc;
}, []);

// ✅ 直接用 map
const names = users.map(u => u.name);

// ✅ reduce 用在真正的"折叠"场景
const total = items.reduce(
  (sum, i) => sum + i.price * i.qty,
  0
);

// ✅ 分组:reduce 的强项
const byStatus = tasks.reduce((acc, t) => {
  (acc[t.status] ||= []).push(t);
  return acc;
}, {});

// ✅ 建索引:把数组变成查找表
const byId = users.reduce((acc, u) => {
  acc[u.id] = u;
  return acc;
}, {});
map 长度不变,逐个变形
filter 只保留满足条件的元素
reduce 把所有元素折叠成一个结果
记住 不要为了“显得函数式”而用 reduce

自己实现一遍,理解会更牢固

// 手写 map
const map = (fn) => (arr) => {
  const out = [];
  for (let i = 0; i < arr.length; i++) {
    out.push(fn(arr[i], i, arr));
  }
  return out;
};

// 手写 filter
const filter = (pred) => (arr) => {
  const out = [];
  for (const item of arr) {
    if (pred(item)) out.push(item);
  }
  return out;
};

// 手写 reduce
const reduce = (fn, init) => (arr) => {
  let acc = init;
  for (const item of arr) {
    acc = fn(acc, item);
  }
  return acc;
};

// 用起来
const activeNames = map(u => u.name)(
  filter(u => u.active)(users)
);

注意上面的写法:每个函数都先接收配置,再接收数据。这样写的好处是,map(u => u.name) 本身就是一个完整的、可复用的函数,可以把它存进变量、传给别的函数。这就是下一节“函数组合”的前提。

高阶函数的其他常见形态

debounce 接收函数,返回一个延迟执行的包装函数
throttle 接收函数,返回一个限频执行的包装函数
once 接收函数,返回一个只会执行一次的版本
retry 接收异步函数,返回带重试逻辑的版本
memoize 接收纯函数,返回带缓存的版本

这一类函数有个共同的名字:装饰器或函数包装器。它们不改变原函数的逻辑,只是在外面加一层行为。这正是高阶函数最实用的地方——把横切关注点(日志、缓存、限流、重试)从业务逻辑中剥离出来。

function debounce(fn, wait = 300) {
  let timer = null;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), wait);
  };
}

// 业务逻辑保持干净,限流是"外加"的
const handleSearch = debounce(keyword => {
  fetch(`/api/search?q=${keyword}`);
}, 300);

五、函数组合:像搭积木一样搭逻辑

纯函数的最大回报,就是可以自由组合。因为纯函数的输出只取决于输入,所以前一个函数的输出可以直接作为后一个函数的输入,中间不会有任何“隐藏通道”干扰。

compose 与 pipe 的区别

这两个词几乎总是成对出现,唯一的差别是执行方向:

  • compose(f, g, h)(x) 等价于 f(g(h(x)))——从右往左,符合数学上的书写习惯。
  • pipe(f, g, h)(x) 等价于 h(g(f(x)))——从左往右,符合人类的阅读顺序。

前端工程里 pipe 用得更普遍,因为读代码时不需要来回跳转。

const compose = (...fns) => x =>
  fns.reduceRight((acc, fn) => fn(acc), x);

const pipe = (...fns) => x =>
  fns.reduce((acc, fn) => fn(acc), x);

// 使用
const slugify = pipe(
  s => s.trim(),
  s => s.toLowerCase(),
  s => s.replace(/\s+/g, '-'),
  s => s.replace(/[^a-z0-9-]/g, '')
);

slugify(' Hello World! ');
// 'hello-world'

点击看看数据是怎么流过去的

下面这条流水线展示了 pipe 的四步转换。点击每个环节查看它做了什么:

1
trim
去除首尾空白
输入 ' Hello World! ',输出 'Hello World!'。这一步只清理边缘,不改变中间内容。
2
toLowerCase
统一为小写
输出 'hello world!'。URL 中大小写敏感,统一小写可以避免同一个页面出现多个地址。
3
replace
空白转连字符
用正则 /\s+/g 把所有连续空白替换成单个 -,输出 'hello-world!'。
4
replace
过滤非法字符
用 /[^a-z0-9-]/g 去掉所有非字母数字和连字符,输出最终的 'hello-world'。

点无风格(Point-free)

组合带来的一个副产品是:很多函数的参数可以“省略”。看下面两种写法:

// 显式声明数据参数
const getNames = users =>
  users
    .filter(u => u.active)
    .map(u => u.name);

// point-free:数据被"省略"了
const getNames = pipe(
  filter(u => u.active),
  map(u => u.name)
);
优点 关注点从“数据怎么流转”变成“由哪些步骤构成”
优点 中间步骤可以单独复用、单独测试
代价 过度使用会降低可读性,尤其是团队不熟悉时
建议 保持 2~4 步为宜,超过就拆成具名函数

组合的数学性质

函数组合满足两条重要规律,理解它们有助于你写出更可靠的代码:

  • 结合律:compose(f, compose(g, h)) 等价于 compose(compose(f, g), h)。这意味着你可以自由地把一组函数打包成一个“大函数”,再和别的函数组合,而不改变结果。这也是中间件能够层层叠加的理论基础。
  • 单位元:存在一个恒等函数 id = x => x,使得 compose(f, id) 等价于 compose(id, f) 等价于 f。它看起来没用,但在函数式编程里经常作为 reduce 的初始值出现。

调试组合函数

链式组合最大的问题是“中间看不见”。一个常用的技巧是插入一个 trace 函数:

const trace = (label) => (x) => {
  console.log(`[${label}]`, x);
  return x; // 原样返回,不影响管道
};

const slugify = pipe(
  s => s.trim(),
  trace('after trim'),
  s => s.toLowerCase(),
  trace('after lower'),
  s => s.replace(/\s+/g, '-')
);

trace 是纯的——它只做输出,不改变数据,所以可以随时插进管道再拿掉,不会影响原有行为。这是纯函数带来的又一个便利。

Promise 链其实就是组合

如果你觉得 pipe 很抽象,看看这个:

fetch('/api/user')
  .then(res => res.json())
  .then(data => data.profile)
  .then(profile => profile.name)
  .then(name => console.log(name));

这就是一个从左到右的管道。then 接收一个函数,返回一个新的 Promise,下一个 then 再接收它——结构上和 pipe 完全一致,只不过它处理的还包含“未来才会到达的值”。理解了组合,你就理解了 Promise 链为什么能这样写。

六、柯里化与偏函数应用

组合的前提是“每个函数只接收一个参数”。但现实中我们的函数往往有多个参数。柯里化就是解决这个矛盾的桥梁。

两个容易混淆的概念

柯里化 把 f(a, b, c) 变成 f(a)(b)(c),一次只收一个参数
偏应用 固定部分参数,得到接收剩余参数的新函数 f(a)(b, c)
共同点 都是“预置参数、延迟执行”,目的都是提高复用性

手写一个 curry

function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    }
    return (...rest) => curried(...args, ...rest);
  };
}

const add = curry((a, b, c) => a + b + c);

add(1)(2)(3); // 6
add(1, 2)(3); // 6
add(1)(2, 3); // 6

关键在于 fn.length——它返回函数声明时形参的个数。当累计收到的参数达到这个数量时,才真正执行。

为什么要柯里化?三个真实理由

// 理由一:方便组合
const prop = curry((key, obj) => obj[key]);
const getName = prop('name');
const names = users.map(getName);

// 理由二:参数复用
const multiply = curry((a, b) => a * b);
const double = multiply(2);
const triple = multiply(3);
[1, 2, 3].map(double); // [2,4,6]

// 理由三:构造专用函数
const log = curry((level, msg) => {
  console.log(`[${level}] ${msg}`);
});
const error = log('ERROR');
const warn = log('WARN');
error('接口超时');
warn('缓存即将过期');
可读性 getName 比 u => u.name 更能表达意图
复用 一次柯里化,多处分身使用
解耦 配置与数据分离,配置可以提前确定
注意 大量柯里化会有闭包开销,不要为了炫技而用

日常写法中其实到处是柯里化

你可能没意识到,下面这些 API 的设计思路就是柯里化:

  • Array.prototype.map(callback) 的 callback 拿到的是“元素”,而 map 本身拿的是“函数”——这是高阶函数。
  • addEventListener(type, handler) 中的 type 一确定,onClick = handler => el.addEventListener('click', handler) 就是一个柯里化后的专用函数。
  • Redux 的 connect(mapState)(Component)、中间件 store => next => action => {...} 的三层箭头,都是柯里化的典型形态。
  • Node 的 fs.readFile(path, options, callback) 用 util.promisify 包装后,也常配合柯里化使用。

柯里化 + 组合 = 强大到危险

const prop = curry((k, o) => o[k]);
const filter = curry((pred, arr) => arr.filter(pred));
const map = curry((fn, arr) => arr.map(fn));
const sortBy = curry((key, arr) =>
  [...arr].sort((a, b) => a[key] > b[key] ? 1 : -1)
);

// 现在可以像搭积木一样描述业务
const topUserNames = pipe(
  filter(u => u.active),
  sortBy('score'),
  map(prop('name'))
);

topUserNames(users);

这段代码读起来几乎就是需求的自然语言描述:过滤活跃用户,按分数排序,取名字。没有任何循环变量、临时数组、索引运算。这就是函数式编程追求的终极形态——代码即描述。

但也要警惕:过度 point-free 会让不熟悉这套风格的同事完全读不懂。在团队协作中,适度的显式参数往往比极致的抽象更有价值。

七、声明式思维:描述“是什么”而不是“怎么做”

函数式编程最直观的特征就是声明式。下面这段代码展示了同一个需求的两种写法,点击按钮切换查看:

function getActiveNames(users) {
  const result = [];
  for (let i = 0; i < users.length; i++) {
    const user = users[i];
    if (user.active === true) {
      const name = user.name;
      if (name !== null && name !== undefined) {
        result.push(name.trim().toUpperCase());
      }
    }
  }
  return result;
}

两种思维的对比

维度 命令式 声明式
关注点 如何一步步完成 结果应该是什么样
中间状态 需要维护循环变量、临时数组 没有显式中间状态
可读性 需要在大脑中模拟执行 接近自然语言描述
可组合性 低,逻辑与数据耦合 高,每步都可以单独复用
性能 通常略优(少一次遍历) 多次遍历,但现代引擎优化良好
调试 可以打断点逐步看 需要借助 trace 或拆分函数

声明式不等于“一定要链式调用”

很多人把声明式等同于“写一长串点号”。这是误解。声明式的核心是把“怎么做”封装起来,只暴露“做什么”。下面这些同样是声明式:

// CSS 是声明式的
.card {
  display: flex;
  gap: 12px;
}

// SQL 是声明式的
SELECT name FROM users WHERE active = true;

// 正则也是声明式的
const email = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

你不需要关心浏览器怎么实现 flex 布局,不需要关心数据库怎么扫描索引,不需要关心正则引擎怎么回溯。你只描述了目标状态,剩下的交给实现方。这就是抽象的价值,而函数是前端里最容易创建的抽象单元。

把命令式代码“提升”为声明式的三步

  1. 找出“对每个元素做什么”:把循环体里的操作抽成一个函数。
  2. 找出“筛选条件”和“转换规则”:分别对应 filter 和 map。
  3. 找出“累积结果”:如果结果是单一值,用 reduce;如果结果是数组,通常前两步就够了。

举个例子。原始代码:

const result = [];
for (const order of orders) {
  if (order.status === 'paid') {
    for (const item of order.items) {
      result.push({
        sku: item.sku,
        qty: item.qty,
        orderNo: order.no
      });
    }
  }
}

改造后:

const result = orders
  .filter(o => o.status === 'paid')
  .flatMap(o =>
    o.items.map(i => ({
      sku: i.sku,
      qty: i.qty,
      orderNo: o.no
    }))
  );

flatMap 在这里非常关键:它等价于 map 之后再 flat() 一层,专门用来处理“一对多”的展开场景。两个嵌套循环被压缩成了一次声明式的展开。

八、副作用管理与函子的直觉

前面说过,副作用无法消灭,只能隔离。那么在函数式编程里,副作用是怎么被“装起来”的?答案是一个听起来很唬人的词:函子(Functor)。

函子其实就是“可以 map 的东西”

不要被这个名字吓到。一个函子,本质上就是一个带 map 方法的容器。它把你关心的值装进盒子里,map 让你在不打开盒子的前提下,对里面的值做变换。

// 数组是最常见的函子
[1, 2, 3].map(x => x * 2); // [2,4,6]

// 但数组的 map 其实是"对每个元素映射"
// 更纯粹的函子长这样:
class Box {
  constructor(value) {
    this._value = value;
  }
  map(fn) {
    return new Box(fn(this._value));
  }
  fold(fn) {
    return fn(this._value);
  }
}

const result = new Box(5)
  .map(x => x + 1)
  .map(x => x * 2)
  .fold(x => x); // 12

函子必须满足的两条定律

恒等律 box.map(x => x) 等价于 box,什么都不改变
组合律 box.map(f).map(g) 等价于 box.map(x => g(f(x)))

这两条定律保证了 map 是可组合的、可预测的。正是因为有它们,我们才能放心地把一长串 map 链起来,而不担心中间产生意外。

Maybe:把“可能为空”装进盒子里

空值处理是前端最常见的副作用来源之一。user && user.profile && user.profile.name 这种防御式代码到处都是。Maybe 函子提供了一种更优雅的方式:

class Maybe {
  constructor(value) {
    this._value = value;
  }
  static of(value) {
    return value === null || value === undefined
      ? new Nothing()
      : new Just(value);
  }
}

class Just extends Maybe {
  map(fn) {
    return Maybe.of(fn(this._value));
  }
  getOrElse(fallback) {
    return this._value;
  }
}

class Nothing extends Maybe {
  map() {
    return this; // 短路,什么都不做
  }
  getOrElse(fallback) {
    return fallback;
  }
}

// 使用:无论中间哪一步为空,都不会报错
const name = Maybe.of(user)
  .map(u => u.profile)
  .map(p => p.name)
  .map(n => n.toUpperCase())
  .getOrElse('匿名用户');

这就是现代 JS 里可选链 user?.profile?.name 背后的思想。可选链是把这套模式做进了语法,让它在不引入额外抽象的前提下触手可及。

Either:把“可能失败”装进盒子里

如果操作可能失败并需要携带错误信息,就用 Either:左值表示错误,右值表示成功,并且约定 map 只作用于右值。

const parseAge = (input) => {
  const n = Number(input);
  if (Number.isNaN(n)) return Left('不是合法数字');
  if (n < 0) return Left('年龄不能为负');
  return Right(n);
};

const result = parseAge('25')
  .map(n => n + 1)
  .fold(
    err => `失败:${err}`,
    val => `成功:${val}`
  );

注意这种写法的好处:错误处理不再是散落各处的 if,而是被收进了数据结构的 map 里。整条链上只要出现一次错误,后续的 map 全部自动短路,直到最后统一在 fold 里处理。这比一层层的 try/catch 要清晰得多。

Promise 也是函子

// then 就是 Promise 的 map
Promise.resolve(5)
  .then(x => x + 1)
  .then(x => x * 2)
  .then(x => console.log(x)); // 12

// 组合律在这里成立
// p.then(f).then(g) === p.then(x => g(f(x)))

// 而且它处理了"未来才到达的值"这个副作用
fetch('/api')
  .then(r => r.json())
  .then(data => data.items)
  .then(items => items.length);

所以你看,你每天写的 .then() 链,本质上就是一个函子的 map 链。理解了函子,也就理解了 Promise 为什么能这样设计。

九、递归与惰性求值

在函数式编程里,循环也是一种“命令式”的结构:它有可变的计数器、有终止条件、有中间状态。替代方案是递归——用函数调用自身来表达重复。

用递归重写循环

// 命令式:求和
function sum(arr) {
  let total = 0;
  for (const n of arr) total += n;
  return total;
}

// 递归式:求和
const sum = ([head, ...tail]) =>
  head === undefined ? 0 : head + sum(tail);

// 但更实用的还是 reduce
const sum = arr => arr.reduce((a, b) => a + b, 0);
优点 代码更接近数学定义,没有可变状态
优点 天然适合处理树形结构、嵌套数据
代价 每次调用都会新增一个栈帧
风险 递归过深会触发栈溢出(Stack Overflow)

所以实践中有一条务实的建议:能用 reduce 表达的线性遍历,就用 reduce;只有处理树、嵌套结构、分治算法时,才真正需要递归。

递归的真正战场:树形数据

前端处理菜单、评论、文件树、DOM 节点,全都离不开递归。用纯函数的方式写一遍:

// 深度优先遍历:收集所有节点的 id
const collectIds = (node) => [
  node.id,
  ...(node.children || []).flatMap(collectIds)
];

// 递归查找:找到符合条件的第一项
const findNode = (predicate) => (node) => {
  if (predicate(node)) return node;
  for (const child of node.children || []) {
    const found = findNode(predicate)(child);
    if (found) return found;
  }
  return null;
};

// 递归映射:生成新的树(不可变)
const mapTree = (fn) => (node) => ({
  ...fn(node),
  children: (node.children || []).map(mapTree(fn))
});

注意 mapTree:它没有修改原树,而是返回了一棵全新的树。这就是不可变与递归的结合——对复杂嵌套结构的更新,也可以保持纯粹。

尾调用与栈溢出

“尾调用优化”(TCO)是一个常被提及但常被误解的概念。如果一个函数在最后一步直接返回另一个函数的调用结果(而不是“调用后再做点什么”),那么理论上引擎可以复用当前栈帧,从而支持无限递归。

// ❌ 非尾调用:返回后还要做乘法
const factorial = (n) =>
  n <= 1 ? 1 : n * factorial(n - 1);

// ✅ 尾调用:把累积值作为参数传下去
const factorial = (n, acc = 1) =>
  n <= 1 ? acc : factorial(n - 1, n * acc);

// 把循环状态"搬"进参数里,是尾递归的通用套路

需要提醒的是:JavaScript 目前只有 Safari 实现了尾调用优化,V8 曾经实现过又移除了。所以不要指望用尾递归来跑百万次循环,那还是 for 或 reduce 更合适。

惰性求值:需要时才计算

生成器(Generator)让“惰性”在 JavaScript 里触手可及。它允许你定义一个可能无限的序列,但只在需要时产生下一个值:

// 无限自然数序列——不会死循环
function* naturals() {
  let n = 1;
  while (true) yield n++;
}

// 惰性管道:每一步都是按需的
function* take(n, iter) {
  let i = 0;
  for (const v of iter) {
    if (i++ >= n) return;
    yield v;
  }
}

function* filter(pred, iter) {
  for (const v of iter) {
    if (pred(v)) yield v;
  }
}

// 只计算前 5 个偶数
const evens = take(5, filter(n => n % 2 === 0, naturals()));
[...evens]; // [2, 4, 6, 8, 10]

惰性求值的价值在于:它把“计算”和“取用”分离了。上面的 naturals() 是无限的,但因为下游只取了 5 个,实际只计算了 10 次。在处理大文件流、分页数据、无限滚动列表时,这种模式非常有用。

另一个常见用法是“管道 + 惰性”的组合:只有当最终调用 next() 时,整条链才会向前推进一格。这与数组方法每次都会完整遍历一遍形成了鲜明对比。

十、十个最常见的函数式编程误区

误区 1 以为函数式编程就是“多用 map 和 filter”,把 for 循环全部换掉
误区 2 为了追求 point-free,把简单逻辑写成一行看不懂的长链
误区 3 认为纯函数意味着完全不能有副作用,于是拒绝写任何 I/O
误区 4 在 map 的回调里做副作用(修改外部变量、发请求)
误区 5 用展开运算符以为做了深拷贝,结果嵌套对象还是共享的
误区 6 对超大数组频繁做不可变复制,导致性能问题
误区 7 滥用 reduce,把 filter、map、find 的活全用 reduce 干
误区 8 到处柯里化,导致调用链断点难以调试、类型提示混乱
误区 9 认为“函数式 = 没有 class、没有 this”,强行改造整个项目
误区 10 把函子、单子当成必须掌握的“高级知识”,本末倒置

展开说说其中三个

关于“在 map 里做副作用”:这是最常见、也最隐蔽的错误。map 的语义是“把一个数组映射成另一个数组”,它不应该被用来遍历执行副作用。如果你写 list.map(item => saveToServer(item)),那么这段代码的返回值是 [undefined, undefined, ...],而且一旦将来有人为了性能优化把 map 换成惰性求值版本,你的保存操作可能就整个不执行了。要遍历做副作用,用 forEach。

关于“函数式 = 没有 class”:函数式编程和面向对象不是互斥的。JavaScript 的对象、原型、class 都可以正常使用,关键不在于语法形式,而在于数据是否被共享修改。一个 class 只要方法不修改实例状态、返回新实例,它依然是函数式友好的。

关于“必须掌握函子与单子”:这些概念在前端日常开发中的实际收益,远低于纯函数、不可变数据和函数组合。先用好前面三个,函子的直觉自然会在你写 .then() 和可选链时浮现出来。不要为了知识体系完整而学习,要为了解决实际问题而学习。

十一、重构实战:把一段命令式代码改成函数式

下面这段代码来自一个真实的购物车模块。它读取订单数据,计算统计数据,同时写入了几个外部变量。先看看它的问题:

let totalRevenue = 0;
let topProduct = null;
const productCount = {};

function analyzeOrders(orders) {
  for (let i = 0; i < orders.length; i++) {
    const order = orders[i];
    if (order.status !== 'paid') continue;
    totalRevenue += order.amount;
    for (let j = 0; j < order.items.length; j++) {
      const sku = order.items[j].sku;
      productCount[sku] = (productCount[sku] || 0) + order.items[j].qty;
    }
  }

  let max = 0;
  for (const sku in productCount) {
    if (productCount[sku] > max) {
      max = productCount[sku];
      topProduct = sku;
    }
  }

  return { totalRevenue, topProduct, productCount };
}

问题清单

  • 函数依赖三个外部变量,不纯。连续调用两次,totalRevenue 会翻倍。
  • 三个变量分散在函数内外,读者需要来回跳转才能拼出完整逻辑。
  • 无法单独测试“计算总收入”或“统计商品数量”中的任何一步。
  • 嵌套两层循环 + 一次对象遍历,逻辑纠缠在一起。
  • 如果将来需要按月份分组统计,几乎要重写一遍。

改造后的版本

// 第一步:过滤出已支付订单(纯)
const paidOnly = orders =>
  orders.filter(o => o.status === 'paid');

// 第二步:计算总收入(纯)
const totalRevenue = orders =>
  orders.reduce((sum, o) => sum + o.amount, 0);

// 第三步:把订单展开成商品条目(纯)
const toLineItems = orders =>
  orders.flatMap(o => o.items);

// 第四步:按 sku 聚合数量(纯)
const countBySku = items =>
  items.reduce((acc, i) => {
    acc[i.sku] = (acc[i.sku] || 0) + i.qty;
    return acc;
  }, {});

// 第五步:取数量最多的 sku(纯)
const topSku = counts =>
  Object.entries(counts)
    .reduce((best, cur) => cur[1] > best[1] ? cur : best, [null, 0])[0];

// 组合成一个完整的分析函数(纯)
const analyzeOrders = orders => {
  const paid = paidOnly(orders);
  const counts = countBySku(toLineItems(paid));
  return {
    totalRevenue: totalRevenue(paid),
    topProduct: topSku(counts),
    productCount: counts
  };
};

改造带来的六个收益

可测试
每个小函数单独可测
可复用
countBySku 可用于任何商品列表
  • 纯函数:所有中间函数都不依赖外部状态,连续调用结果一致。
  • 可组合:每个步骤都是独立单元,将来要加“按月份分组”,只需要在中间插一个 groupBy。
  • 可读:函数名本身就是文档,paidOnly、countBySku、topSku 一看就懂。
  • 可调试:出错时能立刻定位到是哪一步,而不是在一大段循环里找。
  • 无副作用:不再有函数外部变量,不会出现“调用两次结果翻倍”的诡异问题。
  • 易扩展:把 analyzeOrders 改成 pipe 形式只需要几行,就能支持任意分析维度。

用 pipe 再进一步

const analyzeOrders = pipe(
  paidOnly,
  paid => ({
    totalRevenue: totalRevenue(paid),
    productCount: countBySku(toLineItems(paid)),
    get topProduct() {
      return topSku(this.productCount);
    }
  })
);

需要说明的是,最后这一版为了演示 pipe 而牺牲了一点可读性。不要为了“更函数式”而把一个已经清晰的版本改得更绕——这是实践中最容易走偏的地方。重构的目标是让代码更容易理解和维护,而不是让抽象层次更高。

十二、落地检查清单与思维模型

把全文的结论浓缩成一份可以贴在工位上的清单。每次写业务逻辑前扫一眼,能挡掉绝大多数问题。

  • 函数只依赖参数:不再读取全局变量、DOM、Date.now() 等外部状态。
  • 函数不改参数:不 push 传入的数组,不修改传入的对象,返回新值。
  • 不修改外部变量:不出现“函数执行后,外面某个变量变了”的情况。
  • map 里只做转换:不在 map 回调里发请求、写日志、改外部状态。
  • filter 里只做判断:filter 的回调必须是纯谓词,不产生副作用。
  • reduce 用在真正的折叠场景:求和、分组、建索引;map / filter 能做的不要用 reduce。
  • 深层复制要小心:展开运算符只复制一层,嵌套对象仍然共享引用。
  • 不可变更新有固定套路:增用展开、删用 filter、改用 map、深层用逐层展开。
  • 小函数先写、再组合:先写一堆只做一件事的纯函数,再用 pipe 串起来。
  • 柯里化有明确目的:为了复用或为了组合,而不是为了“看起来高级”。
  • point-free 保持克制:2~4 步为宜,超过就拆成具名函数。
  • 副作用集中在最外层:业务逻辑纯,I/O 和 DOM 操作放在外壳。
  • 时间、随机数、ID 作为参数注入:让核心逻辑保持可测试。
  • 能用原生方法就用原生方法:flatMap、Object.entries、可选链、structuredClone 都能省不少代码。
  • 用 trace 调试管道:不要在组合函数内部到处 console.log,插一个 trace 就够了。
  • 性能敏感处保留命令式:十万级数据的循环,用 for 循环完全没问题。
  • 不要在团队里强行推广:函数式是一种工具,不是信仰。能解决问题的用法才值得用。
  • 重构时保持对外接口不变:内部实现改成函数式,函数签名尽量不动,降低回归风险。
// 一份可直接复用的函数式工具集

// 组合
const pipe = (...fns) => x =>
  fns.reduce((acc, fn) => fn(acc), x);

const compose = (...fns) => x =>
  fns.reduceRight((acc, fn) => fn(acc), x);

// 柯里化
const curry = (fn) => {
  const curried = (...args) =>
    args.length >= fn.length
      ? fn(...args)
      : (...rest) => curried(...args, ...rest);
  return curried;
};

// 常用柯里化函数
const prop = curry((k, o) => o?.[k]);
const map = curry((fn, arr) => arr.map(fn));
const filter = curry((pred, arr) => arr.filter(pred));
const reduce = curry((fn, init, arr) => arr.reduce(fn, init));
const sortBy = curry((key, arr) =>
  [...arr].sort((a, b) => (a[key] > b[key] ? 1 : -1))
);

// 调试
const trace = (label) => (x) => {
  console.log(`[${label}]`, x);
  return x;
};

// 不可变更新辅助
const updateAt = curry((id, patch, list) =>
  list.map(item =>
    item.id === id ? { ...item, ...patch } : item
  )
);

// 使用示例:一句话描述一个数据管道
const topActiveNames = pipe(
  filter(u => u.active),
  sortBy('score'),
  map(prop('name')),
  arr => arr.slice(0, 10)
);

topActiveNames(users);

最后,一个可以随时自问的思维模型

当你准备写下一个函数时,试着按顺序问自己四个问题:

问题一 这个函数的输出,除了参数之外,还取决于别的东西吗?
问题二 调用它之后,函数外面的世界会发生变化吗?
问题三 它做的事情,能不能拆成几个更小、各自独立的步骤?
问题四 这几个步骤,将来有没有可能在不同场景下重新排列或复用?

第一个问题帮你判断它是否纯;第二个问题帮你找到副作用的位置;第三个问题引导你拆分;第四个问题告诉你哪里值得抽象。

函数式编程最迷人的地方在于它的“性价比”:它不需要你换框架、换语言、换工具链,只需要你在写每一个函数时多想两秒。一个纯函数和一段带副作用的代码,敲键盘的次数差不多,但它们在未来三个月里给你带来的调试时间,差距可能是几个小时。

所以,下一次当你准备在函数里直接修改一个外部变量,或者写下一个嵌套三层的 for 循环时,不妨先停半秒问自己:“这段逻辑,能不能用几个纯函数说清楚?”——答案往往是可以的,而且写出来会比你想象的更短、更清晰。