“函数式编程”这四个字,在前端圈已经被讲了很多年,但真正把它落到日常代码里的人依然是少数。大多数人第一次接触它,是在 Redux 的 reducer 里;第二次是在 React 的 setState 更新函数里;第三次是在数组的 map / filter / reduce 链式调用里。这些 API 每天都在用,可一旦被问到“为什么 reducer 必须是纯函数”“为什么不要直接改 state”“compose 和 pipe 到底差在哪”,回答往往就含糊了。函数式编程不是玄学,也不是要你把 JavaScript 写成 Haskell。它是一套关于“如何组织数据流动”的实用方法论:用纯函数替代共享状态,用不可变数据替代原地修改,用组合替代继承,用声明式描述替代命令式步骤。本篇 45 分钟长文,从最基础的概念讲起,逐层推进到高阶函数、函数组合、柯里化、函子与惰性求值,最后给出一套可以立即落地的重构方法与检查清单。
一、为什么前端需要函数式编程
前端应用的复杂度来源,和十年前已经完全不同。过去我们关心的是“怎么把一个页面画出来”,现在我们关心的是“怎么让一个持续运行数小时、状态成百上千、同时处理多个异步请求的单页应用不出错”。复杂度的重心,从渲染转移到了状态管理。
而状态管理的绝大多数 bug,都来自同一个根源:同一份数据被多个地方共享,并且被多个地方修改。你永远不知道是谁在什么时候改了它,只能靠 console.log 一步步回溯。函数式编程正是针对这一类问题开出的一整套处方。
三行代码看懂命令式与函数式的差别
假设要计算一个数组里所有偶数的平方和。命令式的写法是这样的:
for (let i = 0; i < nums.length; i++) {
if (nums[i] % 2 === 0) {
sum += nums[i] * nums[i];
}
}
return sum;
函数式的写法是这样的:
.filter(n => n % 2 === 0)
.map(n => n * n)
.reduce((a, b) => a + b, 0);
两者做的事情完全一样,但阅读体验天差地别。命令式版本要求你在大脑里模拟一遍执行过程:i 从 0 开始,每次加一,判断奇偶,累加……函数式版本则是直接描述结果是什么:先筛出偶数,再平方,最后求和。你不需要关心循环变量,不需要关心累加器在哪里被修改。
这就是函数式编程最核心的价值主张:减少你需要同时在大脑里保持的“活动状态”数量。而人的工作记忆容量是有限的,减少状态就是减少 bug。
它已经在你的代码里了
你不需要“开始学习函数式编程”,因为你已经在用了。以下这些每天都在写的东西,全部来自函数式编程:
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,程序的表现都应该完全一致。
第二条说的是:函数除了返回结果之外,不应该与外界发生任何可观察的交互。
什么算“副作用”
副作用的范围比很多人想象的广得多。以下这些都算:
对照着看,差别一目了然
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];
为什么“可缓存”这件事很重要
引用透明带来一个非常实用的推论:如果参数没变,结果一定没变,那么结果就可以复用。这让 memoization 从一种“技巧”变成了“理所当然”。
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;
});
纯函数不等于“完全不能有副作用”
这一点必须先说清楚,否则很容易走极端。任何一个真实应用,最终都必须产生副作用——要把像素画到屏幕上,要把数据发给服务器,要把内容写进 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 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 |
构造新数组更安全 |
四种常用的不可变更新写法
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: '新名字'
}
}
};
深层不可变更容易踩的坑
手动写深层展开非常痛苦,层级一深就变成“缩进地狱”。实践中通常有三种应对方式:
- 扁平化状态结构:把嵌套对象拆成
{ ids: [], byId: {} }的形式,更新时只需要动一层。这是 Redux 官方推荐的做法,也是性能最好的方案。 - 使用 Immer 的思路:
produce(state, draft => { draft.user.name = 'x' })——你写的是“可变”的代码,库内部用 Proxy 记录修改并生成新的不可变结构。 - 限制嵌套深度:如果一份状态需要展开四层才能改一个字段,这通常说明数据结构本身需要重新设计。
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。
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;
}, {});
自己实现一遍,理解会更牢固
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) 本身就是一个完整的、可复用的函数,可以把它存进变量、传给别的函数。这就是下一节“函数组合”的前提。
高阶函数的其他常见形态
这一类函数有个共同的名字:装饰器或函数包装器。它们不改变原函数的逻辑,只是在外面加一层行为。这正是高阶函数最实用的地方——把横切关注点(日志、缓存、限流、重试)从业务逻辑中剥离出来。
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 用得更普遍,因为读代码时不需要来回跳转。
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 的四步转换。点击每个环节查看它做了什么:
' Hello World! ',输出 'Hello World!'。这一步只清理边缘,不改变中间内容。'hello world!'。URL 中大小写敏感,统一小写可以避免同一个页面出现多个地址。/\s+/g 把所有连续空白替换成单个 -,输出 'hello-world!'。/[^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)
);
组合的数学性质
函数组合满足两条重要规律,理解它们有助于你写出更可靠的代码:
- 结合律:
compose(f, compose(g, h))等价于compose(compose(f, g), h)。这意味着你可以自由地把一组函数打包成一个“大函数”,再和别的函数组合,而不改变结果。这也是中间件能够层层叠加的理论基础。 - 单位元:存在一个恒等函数
id = x => x,使得compose(f, id)等价于compose(id, f)等价于f。它看起来没用,但在函数式编程里经常作为 reduce 的初始值出现。
调试组合函数
链式组合最大的问题是“中间看不见”。一个常用的技巧是插入一个 trace 函数:
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 很抽象,看看这个:
.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
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 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 会让不熟悉这套风格的同事完全读不懂。在团队协作中,适度的显式参数往往比极致的抽象更有价值。
七、声明式思维:描述“是什么”而不是“怎么做”
函数式编程最直观的特征就是声明式。下面这段代码展示了同一个需求的两种写法,点击按钮切换查看:
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 或拆分函数 |
声明式不等于“一定要链式调用”
很多人把声明式等同于“写一长串点号”。这是误解。声明式的核心是把“怎么做”封装起来,只暴露“做什么”。下面这些同样是声明式:
.card {
display: flex;
gap: 12px;
}
// SQL 是声明式的
SELECT name FROM users WHERE active = true;
// 正则也是声明式的
const email = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
你不需要关心浏览器怎么实现 flex 布局,不需要关心数据库怎么扫描索引,不需要关心正则引擎怎么回溯。你只描述了目标状态,剩下的交给实现方。这就是抽象的价值,而函数是前端里最容易创建的抽象单元。
把命令式代码“提升”为声明式的三步
- 找出“对每个元素做什么”:把循环体里的操作抽成一个函数。
- 找出“筛选条件”和“转换规则”:分别对应
filter和map。 - 找出“累积结果”:如果结果是单一值,用
reduce;如果结果是数组,通常前两步就够了。
举个例子。原始代码:
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
});
}
}
}
改造后:
.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 函子提供了一种更优雅的方式:
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 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 也是函子
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);
所以实践中有一条务实的建议:能用 reduce 表达的线性遍历,就用 reduce;只有处理树、嵌套结构、分治算法时,才真正需要递归。
递归的真正战场:树形数据
前端处理菜单、评论、文件树、DOM 节点,全都离不开递归。用纯函数的方式写一遍:
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() 时,整条链才会向前推进一格。这与数组方法每次都会完整遍历一遍形成了鲜明对比。
十、十个最常见的函数式编程误区
展开说说其中三个
关于“在 map 里做副作用”:这是最常见、也最隐蔽的错误。map 的语义是“把一个数组映射成另一个数组”,它不应该被用来遍历执行副作用。如果你写 list.map(item => saveToServer(item)),那么这段代码的返回值是 [undefined, undefined, ...],而且一旦将来有人为了性能优化把 map 换成惰性求值版本,你的保存操作可能就整个不执行了。要遍历做副作用,用 forEach。
关于“函数式 = 没有 class”:函数式编程和面向对象不是互斥的。JavaScript 的对象、原型、class 都可以正常使用,关键不在于语法形式,而在于数据是否被共享修改。一个 class 只要方法不修改实例状态、返回新实例,它依然是函数式友好的。
关于“必须掌握函子与单子”:这些概念在前端日常开发中的实际收益,远低于纯函数、不可变数据和函数组合。先用好前面三个,函子的直觉自然会在你写 .then() 和可选链时浮现出来。不要为了知识体系完整而学习,要为了解决实际问题而学习。
十一、重构实战:把一段命令式代码改成函数式
下面这段代码来自一个真实的购物车模块。它读取订单数据,计算统计数据,同时写入了几个外部变量。先看看它的问题:
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
};
};
改造带来的六个收益
- 纯函数:所有中间函数都不依赖外部状态,连续调用结果一致。
- 可组合:每个步骤都是独立单元,将来要加“按月份分组”,只需要在中间插一个
groupBy。 - 可读:函数名本身就是文档,
paidOnly、countBySku、topSku一看就懂。 - 可调试:出错时能立刻定位到是哪一步,而不是在一大段循环里找。
- 无副作用:不再有函数外部变量,不会出现“调用两次结果翻倍”的诡异问题。
- 易扩展:把
analyzeOrders改成pipe形式只需要几行,就能支持任意分析维度。
用 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 循环时,不妨先停半秒问自己:“这段逻辑,能不能用几个纯函数说清楚?”——答案往往是可以的,而且写出来会比你想象的更短、更清晰。