1. 纯函数:函数式编程的基石
在JavaScript开发中,纯函数(Pure Function)是指满足以下两个条件的函数:
- 相同的输入永远会得到相同的输出
- 不会产生副作用(side effect)
举个例子,下面是一个典型的纯函数:
javascript复制function add(a, b) {
return a + b;
}
而下面这个函数就不是纯函数:
javascript复制let counter = 0;
function increment() {
return ++counter;
}
1.1 纯函数的优势
为什么我们要追求纯函数?这主要基于以下几个实际开发中的考量:
可预测性:纯函数的行为完全由输入决定,不会因为外部状态改变而产生意外结果。这在大型项目中尤为重要,当你在凌晨3点调试代码时,你会感谢这种可预测性。
可缓存性:由于相同的输入总是产生相同的输出,我们可以轻松实现缓存机制。比如:
javascript复制function memoize(fn) {
const cache = {};
return function(...args) {
const key = JSON.stringify(args);
return cache[key] || (cache[key] = fn.apply(this, args));
};
}
可测试性:纯函数不需要复杂的测试环境设置,你只需要给定输入,断言输出即可。这在单元测试中简直是福音。
并行安全:纯函数不会访问共享内存,也不会产生竞态条件,这在现代多线程/多进程环境中尤为重要。
1.2 如何识别副作用
副作用是指函数在执行过程中对外部环境产生的改变,包括但不限于:
- 修改外部变量
- 修改传入的参数
- 发起HTTP请求
- DOM操作
- 打印日志
- 访问系统状态
在实际开发中,我们不可能完全消除副作用(毕竟程序总要跟外界交互),但应该尽量控制副作用的影响范围。一个常见的做法是将副作用集中管理,比如Redux中的reducer必须是纯函数,而副作用则通过中间件处理。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 柯里化:函数的多餐制
柯里化(Currying)是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。
2.1 基础柯里化实现
让我们从一个简单的例子开始:
javascript复制function add(a) {
return function(b) {
return a + b;
};
}
const add5 = add(5);
console.log(add5(3)); // 8
更通用的柯里化函数可以这样实现:
javascript复制function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) {
return fn.apply(this, args);
} else {
return function(...args2) {
return curried.apply(this, args.concat(args2));
};
}
};
}
2.2 柯里化的实际应用
参数复用:这是柯里化最常见的应用场景。比如我们经常需要向服务器发送请求:
javascript复制function fetchData(baseUrl) {
return function(endpoint) {
return function(params) {
return fetch(`${baseUrl}${endpoint}`, params);
};
};
}
const api = fetchData('https://api.example.com');
const getUser = api('/user');
const getPost = api('/post');
延迟执行:柯里化让我们可以先部分应用参数,等到真正需要结果时才传入剩余参数。这在事件处理中特别有用:
javascript复制const handleClick = (id) => (event) => {
console.log(id, event.target);
};
elements.forEach((el, i) => {
el.addEventListener('click', handleClick(i));
});
函数组合:柯里化后的函数更容易组合,这为我们接下来要讲的函数组合打下了基础。
2.3 柯里化的性能考量
虽然柯里化带来了很多好处,但也需要注意:
- 每次柯里化都会创建一个新的闭包,有一定内存开销
- 过度柯里化可能导致调用栈过深
- 在性能关键路径上要谨慎使用
在实际项目中,我通常会使用lodash的_.curry函数,它经过了充分优化,并且支持占位符功能:
javascript复制const _ = require('lodash');
function abc(a, b, c) {
return [a, b, c];
}
const curried = _.curry(abc);
curried(1)(2)(3); // => [1, 2, 3]
curried(1, 2)(3); // => [1, 2, 3]
curried(_, 2)(1)(3); // => [1, 2, 3] (使用占位符)
3. 组合函数:乐高积木式的编程
函数组合(Function Composition)是将多个函数合并成一个新函数的过程。数学上表示为:(f ∘ g)(x) = f(g(x))。
3.1 基础组合函数实现
最简单的组合函数实现:
javascript复制function compose(f, g) {
return function(x) {
return f(g(x));
};
}
更通用的实现可以处理任意数量的函数:
javascript复制function compose(...fns) {
return function(x) {
return fns.reduceRight((acc, fn) => fn(acc), x);
};
}
ES6箭头函数版本:
javascript复制const compose = (...fns) => x => fns.reduceRight((acc, fn) => fn(acc), x);
3.2 组合函数的实际应用
数据处理管道:这是组合函数最典型的应用场景。比如我们要处理用户输入:
javascript复制const trim = str => str.trim();
const toLowerCase = str => str.toLowerCase();
const replaceSpaces = str => str.replace(/\s+/g, '-');
const sanitizeInput = compose(replaceSpaces, toLowerCase, trim);
sanitizeInput(' Hello World '); // "hello-world"
中间件机制:Redux的中间件实现就基于函数组合:
javascript复制function applyMiddleware(...middlewares) {
return createStore => (...args) => {
const store = createStore(...args);
let dispatch = () => {};
const middlewareAPI = {
getState: store.getState,
dispatch: (...args) => dispatch(...args)
};
const chain = middlewares.map(middleware => middleware(middlewareAPI));
dispatch = compose(...chain)(store.dispatch);
return {
...store,
dispatch
};
};
}
3.3 组合函数的调试技巧
当组合的函数很多时,调试可能会变得困难。这里分享几个我在实际项目中总结的技巧:
添加日志:
javascript复制const trace = label => value => {
console.log(`${label}: ${value}`);
return value;
};
const sanitizeInput = compose(
replaceSpaces,
trace('after toLowerCase'),
toLowerCase,
trace('after trim'),
trim
);
使用tap函数:
javascript复制const tap = fn => x => {
fn(x);
return x;
};
const sanitizeInput = compose(
replaceSpaces,
tap(console.log),
toLowerCase,
tap(console.log),
trim
);
使用Promise:虽然这不是纯函数式的做法,但在复杂场景下很实用:
javascript复制const asyncCompose = (...fns) => x =>
fns.reduceRight((acc, fn) => Promise.resolve(acc).then(fn), x);
asyncCompose(
replaceSpaces,
async x => {
console.log(x);
return x;
},
toLowerCase,
async x => {
console.log(x);
return x;
},
trim
)(' Hello World ');
4. 三剑客的联合应用
在实际项目中,纯函数、柯里化和组合函数通常会一起使用,形成强大的函数式编程范式。
4.1 典型应用场景:数据转换
假设我们需要处理从API获取的用户数据:
javascript复制// 纯函数
const getProp = key => obj => obj[key];
const map = fn => arr => arr.map(fn);
const filter = fn => arr => arr.filter(fn);
// 柯里化后的工具函数
const gt = x => y => y > x;
const lt = x => y => y < x;
const propEq = key => value => obj => obj[key] === value;
// 组合函数
const processUsers = compose(
map(getProp('name')),
filter(propEq('active')(true)),
filter(user => lt(30)(user.age) && gt(18)(user.age))
);
const users = [
{ name: 'Alice', age: 25, active: true },
{ name: 'Bob', age: 17, active: true },
{ name: 'Charlie', age: 35, active: false }
];
processUsers(users); // ["Alice"]
4.2 性能优化技巧
虽然函数式编程很优雅,但在性能敏感的场景下需要注意:
避免不必要的柯里化:在热代码路径上,直接使用多参数函数可能更高效。
记忆化:对于纯函数,可以使用记忆化技术缓存结果:
javascript复制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;
};
}
惰性求值:对于大型数据集,可以考虑使用惰性求值:
javascript复制function lazy(fn) {
let evaluated = false;
let result;
return function(...args) {
if (!evaluated) {
result = fn.apply(this, args);
evaluated = true;
}
return result;
};
}
4.3 与面向对象编程的结合
函数式编程并不排斥面向对象,两者可以很好地结合:
javascript复制class User {
constructor(name, age) {
this.name = name;
this.age = age;
}
// 纯方法
getName() {
return this.name;
}
// 柯里化方法
isOlderThan(age) {
return this.age > age;
}
}
// 组合函数使用类方法
const getAdultNames = compose(
map(user => user.getName()),
filter(user => user.isOlderThan(18))
);
const users = [
new User('Alice', 25),
new User('Bob', 17),
new User('Charlie', 30)
];
getAdultNames(users); // ["Alice", "Charlie"]
5. 常见问题与解决方案
在实际项目中应用这些概念时,会遇到一些典型问题:
5.1 如何处理异步操作?
虽然纯函数和同步操作是天作之合,但现代前端离不开异步。解决方案:
使用Promise:
javascript复制const fetchUser = id =>
fetch(`/users/${id}`)
.then(res => res.json());
const fetchPosts = userId =>
fetch(`/users/${userId}/posts`)
.then(res => res.json());
// 组合异步函数
const asyncCompose = (...fns) => x =>
fns.reduceRight((acc, fn) => acc.then(fn), Promise.resolve(x));
const getUserWithPosts = asyncCompose(
posts => ({ posts }),
fetchPosts,
user => user.id,
fetchUser
);
getUserWithPosts(123).then(console.log);
使用async/await:
javascript复制const composeAsync = (...fns) => async x => {
let result = x;
for (const fn of fns.reverse()) {
result = await fn(result);
}
return result;
};
5.2 如何处理错误?
纯函数通常不包含错误处理逻辑,我们需要单独处理:
Either Monad:
javascript复制const Left = x => ({
map: f => Left(x),
fold: (f, g) => f(x),
});
const Right = x => ({
map: f => Right(f(x)),
fold: (f, g) => g(x),
});
const fromNullable = x =>
x == null ? Left(null) : Right(x);
const safeParse = str => {
try {
return Right(JSON.parse(str));
} catch (e) {
return Left(e);
}
};
const getPropSafe = key => obj =>
fromNullable(obj[key]);
const processData = compose(
getPropSafe('data'),
safeParse
);
processData('{"data":123}').fold(
e => console.error('Error:', e),
data => console.log('Success:', data)
);
5.3 如何调试复杂的函数组合?
使用tap函数:
javascript复制const tap = fn => x => {
fn(x);
return x;
};
const log = label => tap(x => console.log(label, x));
const process = compose(
map(add(10)),
log('after filter'),
filter(gt(5)),
log('initial data')
);
process([1, 6, 3, 8]);
使用专门的函数式调试工具:
javascript复制const trace = label => x => {
console.log(`== ${label} ==`);
console.log(x);
return x;
};
const process = compose(
map(add(10)),
trace('after map'),
filter(gt(5)),
trace('after filter')
);
6. 实战案例:构建一个函数式工具库
让我们把这些概念应用到一个实际案例中,构建一个小型但实用的函数式工具库。
6.1 基础工具函数
javascript复制// 柯里化工具
const curry = fn => {
const arity = fn.length;
return function curried(...args) {
if (args.length >= arity) return fn(...args);
return (...more) => curried(...args, ...more);
};
};
// 组合工具
const compose = (...fns) => x => fns.reduceRight((acc, fn) => fn(acc), x);
const pipe = (...fns) => x => fns.reduce((acc, fn) => fn(acc), x);
// 逻辑组合
const both = (f, g) => x => f(x) && g(x);
const either = (f, g) => x => f(x) || g(x);
const complement = f => x => !f(x);
// 条件执行
const when = (pred, fn) => x => pred(x) ? fn(x) : x;
const unless = (pred, fn) => x => !pred(x) ? fn(x) : x;
6.2 集合操作
javascript复制// 柯里化的集合操作
const map = curry((fn, arr) => arr.map(fn));
const filter = curry((fn, arr) => arr.filter(fn));
const reduce = curry((fn, init, arr) => arr.reduce(fn, init));
// 常用操作
const pluck = curry((key, arr) => map(obj => obj[key], arr));
const groupBy = curry((fn, arr) =>
reduce(
(acc, item) => {
const key = fn(item);
acc[key] = acc[key] || [];
acc[key].push(item);
return acc;
},
{},
arr
)
);
6.3 实际应用示例
javascript复制// 数据处理管道
const processData = pipe(
filter(user => user.age >= 18),
groupBy(user => user.department),
map(pluck('name')),
map(names => names.join(', '))
);
const employees = [
{ name: 'Alice', age: 25, department: 'HR' },
{ name: 'Bob', age: 30, department: 'Engineering' },
{ name: 'Charlie', age: 17, department: 'Engineering' },
{ name: 'David', age: 22, department: 'HR' }
];
console.log(processData(employees));
// {
// HR: "Alice, David",
// Engineering: "Bob"
// }
6.4 性能优化版本
对于大型数据集,我们可以实现惰性求值版本:
javascript复制// 惰性序列
const LazySeq = {
map: curry(function*(fn, iter) {
for (const x of iter) yield fn(x);
}),
filter: curry(function*(fn, iter) {
for (const x of iter) if (fn(x)) yield x;
}),
take: curry(function*(n, iter) {
let i = 0;
for (const x of iter) {
if (i++ >= n) break;
yield x;
}
})
};
// 使用惰性序列处理大数据
const bigData = function*() {
let i = 0;
while (true) yield { id: i++, value: Math.random() };
};
const processBigData = pipe(
LazySeq.filter(x => x.value > 0.5),
LazySeq.map(x => x.id),
LazySeq.take(10)
);
console.log([...processBigData(bigData())]);
