跳到主要内容

函数式工具库

虽然原生 JavaScript 提供了 mapfilterreduce 等函数式方法,但在实际项目中,我们经常需要更强大的工具。函数式工具库提供了自动柯里化、更丰富的操作符、不可变数据结构等特性,让函数式编程更加便捷。

本章介绍四个主流的函数式编程库:Lodash/FP、Ramda、Immutable.js 和 Immer。理解它们的设计理念和适用场景,能帮助你选择最适合项目的工具。

为什么需要函数式工具库?

在深入各个库之前,让我们先理解为什么需要它们。

原生方法的局限性

// 原生方法的参数顺序不利于组合
const numbers = [1, 2, 3, 4, 5];

// map 的回调接收三个参数:(value, index, array)
// 这可能导致意外行为
['1', '2', '3'].map(parseInt);
// 结果: [1, NaN, NaN](不是预期的 [1, 2, 3])
// 因为 parseInt 接收了 (value, index) 作为 (string, radix)

// 手动柯里化很繁琐
const filterGT = threshold => arr => arr.filter(x => x > threshold);
const mapDouble = arr => arr.map(x => x * 2);

const process = arr => mapDouble(filterGT(2)(arr));
// 需要手动组合,不够优雅

函数式库解决的问题

  1. 自动柯里化:所有函数自动支持部分应用
  2. 参数顺序优化:数据参数放在最后,便于组合
  3. 更丰富的操作符:提供大量实用的高阶函数
  4. 不可变数据结构:高效的结构共享实现
  5. 更好的类型支持:TypeScript 类型定义完善

Lodash/FP

Lodash 是最流行的 JavaScript 工具库之一,而 lodash/fp 是它的函数式编程版本。

核心特点

lodash/fp 的核心设计原则:

  1. 不可变:所有操作都不修改原数据
  2. 自动柯里化:所有函数都可以部分应用
  3. 迭代器优先:回调函数作为第一个参数
  4. 数据在后:要处理的数据作为最后一个参数

这种设计让函数组合变得极其自然:

const fp = require('lodash/fp');

// 参数顺序:(iteratee, collection)
const getActiveNames = fp.pipe(
fp.filter(user => user.active),
fp.map(user => user.name),
fp.sortBy(fp.identity)
);

const users = [
{ name: '张三', active: true },
{ name: '李四', active: false },
{ name: '王五', active: true }
];

console.log(getActiveNames(users)); // ['张三', '王五']

与普通 Lodash 的区别

理解 lodash/fp 和普通 lodash 的区别非常重要:

const _ = require('lodash');
const fp = require('lodash/fp');

// 1. 参数顺序不同
// 普通 lodash:数据在前
_.map([1, 2, 3], x => x * 2);

// lodash/fp:数据在后
fp.map(x => x * 2)([1, 2, 3]);
fp.map(x => x * 2, [1, 2, 3]); // 也可以一次传入

// 2. 柯里化支持
// 普通 lodash:需要手动 partial
const doubleAll = _.partialRight(_.map, x => x * 2);
doubleAll([1, 2, 3]);

// lodash/fp:自动柯里化
const doubleAllFP = fp.map(x => x * 2);
doubleAllFP([1, 2, 3]);

// 3. 迭代器参数限制
// 普通 lodash:迭代器接收完整参数
_.map(['6', '8', '10'], parseInt);
// [6, NaN, 2](parseInt 的第二个参数被当作 radix)

// lodash/fp:迭代器参数被限制,避免意外
fp.map(parseInt)(['6', '8', '10']);
// [6, 8, 10](parseInt 只接收一个参数)

占位符的使用

当需要跳过某些参数时,可以使用占位符:

const fp = require('lodash/fp');

// 占位符允许以任意顺序提供参数
// 例如:创建一个"大于"判断函数
const gt = fp.gt; // gt(a)(b) 等同于 b > a

console.log(fp.gt(2)(5)); // false,等同于 5 > 2
console.log(fp.gt(_, 2)(5)); // true,等同于 5 > 2 的反转

// 实际应用:筛选大于阈值的值
const greaterThanTwo = fp.filter(fp.gt(2));
console.log(greaterThanTwo([1, 2, 3, 4, 5])); // [3, 4, 5]

常用 API 详解

对象操作

const fp = require('lodash/fp');

const user = {
name: '张三',
age: 25,
email: '[email protected]',
password: 'secret123'
};

// get:安全获取嵌套属性
const city = fp.get('address.city', user); // undefined(属性不存在也不会报错)
const cityOrDefault = fp.getOr('未知', 'address.city', user); // '未知'

// pick:选择特定属性
const publicInfo = fp.pick(['name', 'email'], user);
// { name: '张三', email: '[email protected]' }

// omit:排除特定属性
const safeUser = fp.omit(['password'], user);
// { name: '张三', age: 25, email: '[email protected]' }

// 更新属性(不可变)
const updatedUser = fp.set('age', 26, user);
// 返回新对象,原对象不变

// 更新嵌套属性
const userWithAddress = fp.set('address.city', '北京', user);
// { ..., address: { city: '北京' } }

数组操作

const fp = require('lodash/fp');

const users = [
{ name: '张三', age: 25, department: '技术部' },
{ name: '李四', age: 30, department: '市场部' },
{ name: '王五', age: 25, department: '技术部' }
];

// groupBy:分组
const byDepartment = fp.groupBy('department', users);
// {
// 技术部: [{ name: '张三', ... }, { name: '王五', ... }],
// 市场部: [{ name: '李四', ... }]
// }

// keyBy:转为键值对象
const userMap = fp.keyBy('name', users);
// {
// 张三: { name: '张三', age: 25, ... },
// 李四: { name: '李四', age: 30, ... },
// 王五: { name: '王五', age: 25, ... }
// }

// sortBy:排序
const sortedByAge = fp.sortBy('age', users);
// 按年龄升序

// 多字段排序
const sorted = fp.sortBy(['department', 'age'], users);
// 先按部门,再按年龄

// uniq:去重
fp.uniq([1, 1, 2, 2, 3]); // [1, 2, 3]

// uniqBy:按属性去重
fp.uniqBy('age', users);
// 只保留每个年龄的第一个用户

// flatten:扁平化
fp.flatten([[1, 2], [3, [4, 5]]]); // [1, 2, 3, [4, 5]]
fp.flattenDeep([[1, 2], [3, [4, 5]]]); // [1, 2, 3, 4, 5]

函数组合

const fp = require('lodash/fp');

// pipe:从左到右组合(推荐)
const process = fp.pipe(
fp.filter(x => x > 0),
fp.map(x => x * 2),
fp.sum
);
process([1, -2, 3, -4, 5]); // 18

// flow:pipe 的别名
const process2 = fp.flow(
fp.filter(x => x > 0),
fp.map(x => x * 2),
fp.sum
);

// compose:从右到左组合
const process3 = fp.compose(
fp.sum,
fp.map(x => x * 2),
fp.filter(x => x > 0)
);
process3([1, -2, 3, -4, 5]); // 18

实战示例:数据转换管道

const fp = require('lodash/fp');

// API 响应数据
const apiResponse = {
data: {
items: [
{ id: 1, name: ' 张三 ', email: '[email protected]', status: 'active' },
{ id: 2, name: '李四', email: '[email protected]', status: 'inactive' },
{ id: 3, name: ' 王五', email: '[email protected]', status: 'active' },
{ id: 4, name: null, email: 'invalid', status: 'active' }
]
}
};

// 定义处理管道
const processUsers = fp.pipe(
// 提取数据
fp.get('data.items'),

// 过滤有效数据
fp.filter(user => user.id && user.name && user.email.includes('@')),

// 过滤活跃用户
fp.filter(fp.propEq('status', 'active')),

// 规范化数据
fp.map(user => ({
id: user.id,
name: fp.trim(user.name),
email: fp.toLower(user.email)
})),

// 按名字排序
fp.sortBy('name')
);

console.log(processUsers(apiResponse));
// [
// { id: 1, name: '张三', email: '[email protected]' },
// { id: 3, name: '王五', email: '[email protected]' }
// ]

Ramda

Ramda 是专门为函数式编程设计的库,它的设计哲学更加纯粹。

核心设计理念

Ramda 的核心原则:

  1. 纯函数优先:所有函数都是纯函数,无副作用
  2. 自动柯里化:所有函数都自动柯里化
  3. 数据在最后:参数顺序专为函数组合优化
  4. 不可变:永不修改输入数据

与 Lodash/FP 相比,Ramda 更加"函数式原生",提供了更多函数式编程特有的抽象:

const R = require('ramda');

// Ramda 的函数组合
const process = R.pipe(
R.filter(R.gt(R.__, 0)), // 使用占位符
R.map(R.multiply(2)),
R.sum
);

process([1, -2, 3, -4, 5]); // 18

占位符 R.__

Ramda 的占位符 R.__ 非常强大,允许灵活的参数位置:

const R = require('ramda');

// subtract(a, b) 返回 a - b
// 创建一个"减去 5"的函数
const subtract5 = R.subtract(R.__, 5);
console.log(subtract5(10)); // 5(10 - 5)

// 创建一个"从 5 减去"的函数
const from5 = R.subtract(5);
console.log(from5(3)); // 2(5 - 3)

// 实际应用:创建比较函数
const isAdult = R.gte(R.__, 18); // age >= 18
const isMinor = R.lt(R.__, 18); // age < 18

console.log(isAdult(25)); // true
console.log(isMinor(15)); // true

镜头(Lens)

Lens 是函数式编程中用于安全地访问和更新不可变数据结构的重要抽象。理解 Lens 对于处理深层嵌套数据至关重要。

为什么需要 Lens?

在函数式编程中,我们经常需要访问和更新深层嵌套的数据结构。传统方式非常繁琐:

const user = {
name: '张三',
profile: {
email: '[email protected]',
address: {
city: '北京',
district: '朝阳'
}
}
};

// 传统方式更新嵌套数据:繁琐且容易出错
const updatedUser = {
...user,
profile: {
...user.profile,
address: {
...user.profile.address,
city: '上海'
}
}
};

Lens 提供了一种优雅的方式来解决这个问题:它将"聚焦"和"更新"数据的概念抽象出来,让我们可以像操作普通属性一样操作深层嵌套的数据。

Lens 的本质

Lens 本质上是一对函数:getter(读取)和 setter(设置)。它"聚焦"在数据结构的某个部分,让我们可以读取或修改那个部分,同时保持数据的不可变性。

// Lens 的概念定义
// lens(getter, setter) 创建一个镜头
// getter: 整体 -> 部分(从整体中读取聚焦的部分)
// setter: (部分, 整体) -> 新整体(用新的部分值更新整体)

// 简单实现:一个基本的 Lens
const lens = (getter, setter) => ({
get: getter,
set: (value) => (target) => setter(value, target)
});

// 使用示例:创建一个聚焦对象 name 属性的 Lens
const nameLens = lens(
obj => obj.name, // getter: 获取 name
(value, obj) => ({ ...obj, name: value }) // setter: 更新 name
);

const person = { name: '张三', age: 25 };
console.log(nameLens.get(person)); // '张三'
console.log(nameLens.set('李四')(person)); // { name: '李四', age: 25 }

Ramda 中的 Lens

Ramda 提供了完整的 Lens 支持和一系列辅助函数:

const R = require('ramda');

const user = {
name: '张三',
profile: {
email: '[email protected]',
address: {
city: '北京',
district: '朝阳'
}
}
};

// 创建 Lens 的几种方式

// 1. lensProp: 聚焦对象的某个属性
const nameLens = R.lensProp('name');

// 2. lensIndex: 聚焦数组的某个索引
const firstItemLens = R.lensIndex(0);

// 3. lensPath: 聚焦深层路径
const cityLens = R.lensPath(['profile', 'address', 'city']);
const emailLens = R.lensPath(['profile', 'email']);

// 4. lens: 自定义 getter 和 setter
const upperNameLens = R.lens(
obj => obj.name.toUpperCase(), // getter: 返回大写名称
(value, obj) => ({ ...obj, name: value.toLowerCase() }) // setter: 存储小写名称
);

// 使用 Lens

// view: 读取值(相当于 getter)
console.log(R.view(nameLens, user)); // '张三'
console.log(R.view(cityLens, user)); // '北京'

// set: 设置值(相当于 setter)
const updatedUser = R.set(cityLens, '上海', user);
console.log(R.view(cityLens, updatedUser)); // '上海'
console.log(R.view(cityLens, user)); // '北京'(原数据不变)

// over: 基于当前值修改
const upperNameUser = R.over(nameLens, R.toUpper, user);
console.log(R.view(nameLens, upperNameUser)); // '张三'.toUpperCase()

Lens 组合

Lens 最强大的特性之一是可以组合。你可以将多个 Lens 组合在一起,创建一个聚焦更深层结构的 Lens:

const R = require('ramda');

const company = {
name: '技术公司',
employees: [
{
name: '张三',
skills: ['JavaScript', 'Python', 'Rust']
},
{
name: '李四',
skills: ['Java', 'Go']
}
]
};

// 组合 Lens:访问第一个员工的第一个技能
const firstEmployeeLens = R.lensPath(['employees', 0]);
const skillsLens = R.lensProp('skills');
const firstSkillLens = R.lensIndex(0);

// 使用 compose 组合 Lens
const firstEmployeeFirstSkillLens = R.compose(
firstEmployeeLens,
R.lensProp('skills'),
R.lensIndex(0)
);

console.log(R.view(firstEmployeeFirstSkillLens, company)); // 'JavaScript'

// 更新第一个员工的第一个技能
const updated = R.set(firstEmployeeFirstSkillLens, 'TypeScript', company);
console.log(R.view(firstEmployeeFirstSkillLens, updated)); // 'TypeScript'

实际应用场景

const R = require('ramda');

// 场景 1: 表单状态管理
const formState = {
user: {
profile: {
name: '',
email: '',
preferences: {
theme: 'light',
notifications: true
}
}
},
validation: {
errors: {},
touched: {}
}
};

// 创建常用的 Lens
const nameLens = R.lensPath(['user', 'profile', 'name']);
const themeLens = R.lensPath(['user', 'profile', 'preferences', 'theme']);
const errorsLens = R.lensPath(['validation', 'errors']);

// 更新表单字段
const updateField = (lens, value, state) => R.set(lens, value, state);

// 设置字段错误
const setFieldError = (fieldName, error, state) =>
R.over(
errorsLens,
errors => ({ ...errors, [fieldName]: error }),
state
);

// 场景 2: 不可变数据更新
const todos = {
items: [
{ id: 1, text: '学习 Lens', completed: false },
{ id: 2, text: '使用 Ramda', completed: false }
],
filter: 'all'
};

// 切换某个 todo 的完成状态
const toggleTodo = (id, state) => {
const index = state.items.findIndex(t => t.id === id);
const todoLens = R.lensPath(['items', index, 'completed']);
return R.over(todoLens, R.not, state);
};

const updated = toggleTodo(1, todos);
// todos.items[0].completed 变为 true

// 场景 3: 配置管理
const config = {
api: {
endpoints: {
users: '/api/users',
posts: '/api/posts'
},
timeout: 5000
},
ui: {
theme: 'dark',
language: 'zh-CN'
}
};

const usersEndpointLens = R.lensPath(['api', 'endpoints', 'users']);
const themeLens2 = R.lensPath(['ui', 'theme']);

// 读取配置
console.log(R.view(usersEndpointLens, config)); // '/api/users'

// 更新配置(创建新配置对象)
const newConfig = R.pipe(
R.set(usersEndpointLens, '/api/v2/users'),
R.set(themeLens2, 'light')
)(config);

Lens 定律

正确实现的 Lens 必须满足以下定律,这确保了 Lens 的行为可预测:

// 定律 1: set 后 view 返回设置的值
// view(lens, set(lens, value, data)) === value

// 定律 2: 设置当前值不会改变数据
// set(lens, view(lens, data), data) === data

// 定律 3: 连续 set,最后一个生效
// set(lens, value2, set(lens, value1, data)) === set(lens, value2, data)

// 验证示例
const R = require('ramda');

const data = { name: '张三', age: 25 };
const nameLens = R.lensProp('name');

// 定律 1
const set1 = R.set(nameLens, '李四', data);
console.log(R.view(nameLens, set1) === '李四'); // true

// 定律 2
const set2 = R.set(nameLens, R.view(nameLens, data), data);
console.log(set2); // { name: '张三', age: 25 }(相同)

// 定律 3
const set3 = R.pipe(
R.set(nameLens, '王五'),
R.set(nameLens, '赵六')
)(data);
console.log(R.view(nameLens, set3)); // '赵六'

其他 Optics 类型

除了 Lens,函数式编程中还有其他"光学"类型用于不同的数据访问场景:

// Prism: 处理可能不存在的数据(如 Optional/Sum 类型)
// 适合处理可能为 null/undefined 的情况

// Iso: 双向转换,两个类型之间的完全映射
const lengthLens = R.lens(
str => str.length,
(len, str) => 'a'.repeat(len) // 示例:用 'a' 填充
);

// Traversal: 处理集合中的所有元素
// 例如:更新数组中所有元素的某个属性

// Ramda 中使用 traverse 的示例
const data = {
users: [
{ name: '张三', active: false },
{ name: '李四', active: false }
]
};

// 将所有用户的 active 设置为 true
const allActive = R.over(
R.lensPath(['users']),
R.map(R.assoc('active', true)),
data
);

Lens 是函数式编程中处理不可变数据的强大工具。虽然概念上有些抽象,但一旦掌握,它能极大地简化深层嵌套数据的操作代码。

条件执行

Ramda 提供了优雅的条件执行方式:

const R = require('ramda');

// cond:类似模式匹配
const getStatus = R.cond([
[R.propEq('score', 100), R.always('满分')],
[R.propSatisfies(R.gte(R.__, 60), 'score'), R.always('及格')],
[R.T, R.always('不及格')] // 默认情况
]);

console.log(getStatus({ score: 100 })); // '满分'
console.log(getStatus({ score: 85 })); // '及格'
console.log(getStatus({ score: 45 })); // '不及格'

// ifElse:条件分支
const processValue = R.ifElse(
R.gt(R.__, 0), // 条件:大于 0
R.multiply(2), // 真:乘以 2
R.always(0) // 假:返回 0
);

console.log(processValue(5)); // 10
console.log(processValue(-3)); // 0

// when 和 unless:单分支条件
const doubleIfPositive = R.when(
R.gt(R.__, 0),
R.multiply(2)
);

console.log(doubleIfPositive(5)); // 10
console.log(doubleIfPositive(-3)); // -3(不满足条件,原样返回)

实用函数组合

const R = require('ramda');

// 使用 compose 和 pipe 组合复杂逻辑
const users = [
{ name: '张三', age: 25, department: '技术部', salary: 15000 },
{ name: '李四', age: 30, department: '市场部', salary: 12000 },
{ name: '王五', age: 35, department: '技术部', salary: 20000 },
{ name: '赵六', age: 28, department: '技术部', salary: 18000 }
];

// 计算技术部的平均工资
const getTechAverageSalary = R.pipe(
R.filter(R.propEq('department', '技术部')),
R.map(R.prop('salary')),
R.mean
);

console.log(getTechAverageSalary(users)); // 17666.67

// 按部门分组并计算统计信息
const departmentStats = R.pipe(
R.groupBy(R.prop('department')),
R.map(R.pipe(
R.map(R.prop('salary')),
R.applySpec({
count: R.length,
total: R.sum,
average: R.mean,
max: R.reduce(R.max, -Infinity),
min: R.reduce(R.min, Infinity)
})
))
);

console.log(departmentStats(users));
// {
// 技术部: { count: 3, total: 53000, average: 17666.67, max: 20000, min: 15000 },
// 市场部: { count: 1, total: 12000, average: 12000, max: 12000, min: 12000 }
// }

点无风格(Point-free)

Ramda 非常适合点无风格编程:

const R = require('ramda');

// 有参数的写法
const getActiveUserNames = users =>
users
.filter(user => user.active)
.map(user => user.name);

// 点无风格:不显式提到参数
const getActiveUserNamesPF = R.pipe(
R.filter(R.prop('active')),
R.map(R.prop('name'))
);

const users = [
{ name: '张三', active: true },
{ name: '李四', active: false },
{ name: '王五', active: true }
];

console.log(getActiveUserNamesPF(users)); // ['张三', '王五']

Immutable.js

Immutable.js 由 Facebook 开发,提供了持久化不可变数据结构。它的核心优势在于高效的"结构共享"机制。

什么是持久化数据结构?

持久化数据结构在"修改"时会保留旧版本,同时通过共享未修改的部分来节省内存:

原始数据:    { a: 1, b: 2, c: { d: 3, e: 4 } }
修改 b 后: { a: 1, b: 5, c: { d: 3, e: 4 } }

这部分被共享,未复制

这种机制使得不可变操作非常高效,特别是在大型数据结构上。

核心数据类型

const { Map, List, Set, OrderedMap, OrderedSet, Record, fromJS } = require('immutable');

// Map:键值对集合
const map1 = Map({ a: 1, b: 2, c: 3 });
const map2 = map1.set('b', 50);
console.log(map1.get('b')); // 2(原 Map 未改变)
console.log(map2.get('b')); // 50

// List:有序列表
const list1 = List([1, 2, 3]);
const list2 = list1.push(4);
console.log(list1.size); // 3
console.log(list2.size); // 4

// Set:集合(去重)
const set1 = Set([1, 1, 2, 2, 3]);
console.log(set1.toJS()); // [1, 2, 3]

// OrderedMap:保持插入顺序的 Map
const orderedMap = OrderedMap()
.set('c', 3)
.set('a', 1)
.set('b', 2);
// 键的顺序:c, a, b(按插入顺序)

// Record:带默认值的记录类型
const Person = Record({
name: '未知',
age: 0
});

const person = Person({ name: '张三', age: 25 });
console.log(person.name); // '张三'(可以直接访问属性)
console.log(person.age); // 25

深层操作

Immutable.js 提供了强大的深层操作能力:

const { fromJS } = require('immutable');

const data = fromJS({
users: [
{ id: 1, name: '张三', profile: { city: '北京' } },
{ id: 2, name: '李四', profile: { city: '上海' } }
]
});

// getIn:深层读取
console.log(data.getIn(['users', 0, 'profile', 'city'])); // '北京'

// setIn:深层设置
const updated = data.setIn(['users', 0, 'profile', 'city'], '广州');
console.log(updated.getIn(['users', 0, 'profile', 'city'])); // '广州'
console.log(data.getIn(['users', 0, 'profile', 'city'])); // '北京'(原数据不变)

// updateIn:深层更新
const ageAdded = data.updateIn(['users', 0], user =>
user.set('age', 30)
);

// mergeDeep:深度合并
const merged = data.mergeDeep({
users: [
{ id: 1, name: '张三(已更新)' }
]
});

与原生 JS 互转

const { Map, List, fromJS } = require('immutable');

// fromJS:深度转换原生 JS 为 Immutable 对象
const jsObj = {
users: [{ name: '张三' }, { name: '李四' }]
};
const immutable = fromJS(jsObj);

// toJS:深度转换为原生 JS
console.log(immutable.toJS());
// { users: [{ name: '张三' }, { name: '李四' }] }

// toObject/toArray:浅层转换
const map = Map({ a: 1, b: 2 });
console.log(map.toObject()); // { a: 1, b: 2 }

const list = List([1, 2, 3]);
console.log(list.toArray()); // [1, 2, 3]

// 注意:频繁的 toJS 操作会影响性能
// 建议:只在边界处(如 API 响应、日志输出)进行转换

值相等性

Immutable.js 使用值相等而非引用相等:

const { Map, is } = require('immutable');

const map1 = Map({ a: 1, b: 2 });
const map2 = Map({ a: 1, b: 2 });

// 引用不相等
console.log(map1 === map2); // false

// 值相等
console.log(map1.equals(map2)); // true
console.log(is(map1, map2)); // true(推荐使用)

// 这在 React shouldComponentUpdate 中非常有用
// 可以直接比较引用,而不需要深度比较

惰性序列 Seq

Seq 提供惰性求值能力,避免创建中间集合:

const { Seq, Range } = require('immutable');

// Range 创建无限范围
const oddSquares = Range(1, Infinity)
.filter(x => x % 2 !== 0)
.map(x => x * x)
.take(5);

console.log(oddSquares.toArray()); // [1, 9, 25, 49, 81]

// Seq 不会立即执行,只有在需要结果时才计算
const seq = Seq([1, 2, 3, 4, 5, 6, 7, 8])
.filter(x => {
console.log('filtering', x);
return x % 2 !== 0;
})
.map(x => {
console.log('mapping', x);
return x * x;
});

// 此时没有输出,因为还没有求值

console.log(seq.get(1)); // 只计算需要的值
// filtering 1, mapping 1, filtering 2, filtering 3, mapping 3
// 输出: 9

Immer

Immer 是 MobX 作者开发的库,它采用了一种更直观的方式处理不可变数据:用可变的写法,生成不可变的数据。

核心理念

Immer 的核心思想是:你写代码时仿佛在直接修改数据,但实际上 Immer 会自动生成新的不可变数据。

工作原理:

  1. 创建当前状态的代理(draft)
  2. 你在 draft 上进行"修改"
  3. Immer 记录所有修改
  4. 生成新的不可变状态
import { produce } from 'immer';

const baseState = [
{ title: '学习 TypeScript', done: true },
{ title: '学习 Immer', done: false }
];

// 使用 produce 创建新状态
const nextState = produce(baseState, draft => {
// 在 draft 上直接"修改"
draft[1].done = true;
draft.push({ title: '分享学习心得', done: false });
});

console.log(baseState[1].done); // false(原状态不变)
console.log(nextState[1].done); // true
console.log(nextState.length); // 3

// 未修改的部分被共享
console.log(baseState[0] === nextState[0]); // true

与传统方式的对比

import { produce } from 'immer';

const baseState = {
users: {
list: [
{ id: 1, name: '张三', active: true },
{ id: 2, name: '李四', active: false }
],
total: 2
}
};

// 传统不可变写法:繁琐且容易出错
const traditionalUpdate = {
...baseState,
users: {
...baseState.users,
list: baseState.users.list.map(user =>
user.id === 2 ? { ...user, active: true } : user
)
}
};

// Immer 写法:直观且简洁
const immerUpdate = produce(baseState, draft => {
draft.users.list.find(user => user.id === 2).active = true;
});

在 React 中使用

Immer 与 React Hooks 配合极佳:

import { produce } from 'immer';
import { useState, useCallback } from 'react';

function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: '学习 Immer', done: false },
{ id: 2, text: '写一个 Demo', done: false }
]);

// 使用 Immer 更新状态
const toggleTodo = useCallback(id => {
setTodos(produce(draft => {
const todo = draft.find(t => t.id === id);
if (todo) todo.done = !todo.done;
}));
}, []);

const addTodo = useCallback(text => {
setTodos(produce(draft => {
draft.push({ id: Date.now(), text, done: false });
}));
}, []);

const removeTodo = useCallback(id => {
setTodos(produce(draft => {
const index = draft.findIndex(t => t.id === id);
if (index !== -1) draft.splice(index, 1);
}));
}, []);

return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.done}
onChange={() => toggleTodo(todo.id)}
/>
{todo.text}
<button onClick={() => removeTodo(todo.id)}>删除</button>
</li>
))}
</ul>
);
}

useImmer Hook

Immer 提供了专门的 React Hook:

import { useImmer } from 'use-immer';

function TodoList() {
// useImmer 返回的状态和更新函数
const [todos, updateTodos] = useImmer([
{ id: 1, text: '学习 Immer', done: false }
]);

// 更新函数直接接收 draft
const toggleTodo = id => {
updateTodos(draft => {
const todo = draft.find(t => t.id === id);
if (todo) todo.done = !todo.done;
});
};

// 也可以使用简洁的 updater 形式
const addTodo = text => {
updateTodos(draft => {
draft.push({ id: Date.now(), text, done: false });
});
};

// ...
}

在 Redux 中使用

Immer 让 Redux reducer 更加简洁:

import { produce } from 'immer';

// 传统 reducer
const todosReducer = (state = [], action) => {
switch (action.type) {
case 'ADD_TODO':
return [...state, { id: action.id, text: action.text, done: false }];
case 'TOGGLE_TODO':
return state.map(todo =>
todo.id === action.id
? { ...todo, done: !todo.done }
: todo
);
case 'REMOVE_TODO':
return state.filter(todo => todo.id !== action.id);
default:
return state;
}
};

// 使用 Immer 的 reducer
const todosReducerImmer = (state = [], action) =>
produce(state, draft => {
switch (action.type) {
case 'ADD_TODO':
draft.push({ id: action.id, text: action.text, done: false });
break;
case 'TOGGLE_TODO':
const todo = draft.find(t => t.id === action.id);
if (todo) todo.done = !todo.done;
break;
case 'REMOVE_TODO':
const index = draft.findIndex(t => t.id === action.id);
if (index !== -1) draft.splice(index, 1);
break;
}
});

// Redux Toolkit 内置了 Immer
import { createSlice } from '@reduxjs/toolkit';

const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
addTodo: (state, action) => {
// 可以直接"修改"state!
state.push({ id: action.payload.id, text: action.payload.text, done: false });
},
toggleTodo: (state, action) => {
const todo = state.find(t => t.id === action.payload);
if (todo) todo.done = !todo.done;
}
}
});

性能特性

Immer 有几个重要的性能特性:

import { produce } from 'immer';

// 1. 自动检测无变化
const state = { count: 0 };
const newState = produce(state, draft => {
// 没有做任何修改
});
console.log(state === newState); // true(返回原引用)

// 2. 结构共享
const base = {
a: { value: 1 },
b: { value: 2 },
c: { value: 3 }
};

const updated = produce(base, draft => {
draft.a.value = 10;
});

console.log(base.a === updated.a); // false(修改过的部分)
console.log(base.b === updated.b); // true(未修改的部分被共享)
console.log(base.c === updated.c); // true

// 3. 冻结保护(开发模式)
// Immer 默认会冻结生成的状态,防止意外修改

库的选择指南

不同的场景适合不同的库,以下是选择建议:

按使用场景选择

场景推荐库理由
通用数据处理Lodash/FPAPI 丰富,社区成熟,文档完善
纯函数式编程Ramda设计纯粹,适合学习和深度函数式开发
React/Redux 状态管理Immer写法直观,与 Redux Toolkit 集成
大型不可变数据Immutable.js结构共享高效,API 完整
已有 Lodash 项目Lodash/FP无需引入新库,平滑过渡

按项目特点选择

// 场景 1:React 组件状态管理
// 推荐:Immer
import { useImmer } from 'use-immer';

function Form() {
const [formData, updateForm] = useImmer({
user: { name: '', email: '' },
preferences: { theme: 'light', notifications: true }
});

const updateEmail = email => {
updateForm(draft => {
draft.user.email = email;
});
};
}

// 场景 2:数据转换管道
// 推荐:Ramda 或 Lodash/FP
const processUsers = R.pipe(
R.filter(R.propEq('active', true)),
R.groupBy(R.prop('department')),
R.map(R.pipe(
R.map(R.prop('salary')),
R.mean
))
);

// 场景 3:大型数据集的不可变操作
// 推荐:Immutable.js
const { fromJS } = require('immutable');

class DataStore {
constructor() {
this.data = fromJS({
users: Map(), // 数百万用户
posts: Map(), // 数百万文章
comments: Map()
});
}

updateUser(id, updates) {
this.data = this.data.mergeIn(['users', id], updates);
}
}

// 场景 4:简单的工具函数
// 推荐:Lodash/FP
const { pick, omit, get } = require('lodash/fp');

const getPublicUserInfo = pick(['id', 'name', 'avatar']);
const sanitizeUser = omit(['password', 'token']);

体积对比

Gzip 大小特点
Lodash/FP~24KB可按需引入单个函数
Ramda~12KB可 tree-shaking
Immutable.js~16KB核心数据结构
Immer~3KB非常轻量

最佳实践

1. 不要混用不同库的风格

// 不推荐:混用不同风格
import _ from 'lodash';
import R from 'ramda';

const result = R.pipe(
_.filter(x => x > 0), // Lodash 方法
R.map(x => x * 2) // Ramda 方法
);

// 推荐:统一使用一个库
import R from 'ramda';

const result = R.pipe(
R.filter(x => x > 0),
R.map(x => x * 2)
);

2. 在边界处进行类型转换

// 使用 Immutable.js 时,只在边界转换
import { fromJS, toJS } from 'immutable';

// 输入边界:转换为 Immutable
function processAPIResponse(json) {
const data = fromJS(json);
// 内部全部使用 Immutable API
const result = data
.get('users')
.filter(user => user.get('active'))
.map(user => user.set('processed', true));
// 输出边界:转换为原生 JS
return result.toJS();
}

3. 利用柯里化创建可复用函数

import { filter, map, prop, propEq } from 'ramda';

// 创建特定用途的函数
const getActiveUsers = filter(propEq('active', true));
const getNames = map(prop('name'));
const getActiveUserNames = pipe(getActiveUsers, getNames);

// 可以在不同地方复用
const activeUsers = getActiveUsers(allUsers);
const activeNames = getActiveUserNames(allUsers);

4. 注意性能陷阱

import { produce } from 'immer';

// 避免在循环中频繁调用 produce
const items = [1, 2, 3, 4, 5];

// 不推荐:每次循环都创建新状态
let state = {};
for (const item of items) {
state = produce(state, draft => {
draft[item] = true;
});
}

// 推荐:一次 produce 中完成所有修改
const state = produce({}, draft => {
for (const item of items) {
draft[item] = true;
}
});

5. TypeScript 支持

// 所有库都有良好的 TypeScript 支持
import { produce } from 'immer';
import { Map, List, fromJS } from 'immutable';
import * as R from 'ramda';

// Immer 推断类型
interface State {
count: number;
name: string;
}

const state: State = { count: 0, name: 'test' };
const newState = produce(state, draft => {
draft.count++; // 类型安全
// draft.wrong = 1; // 类型错误
});

// Immutable.js 泛型
const map = Map<string, number>({ a: 1, b: 2 });
const list = List<number>([1, 2, 3]);

// Ramda 类型推断
const addOne = R.add(1);
const result: number = addOne(5); // 6

小结

函数式工具库为 JavaScript 开发提供了强大的支持:

  • Lodash/FP:功能全面,适合通用项目,可按需引入
  • Ramda:纯粹的函数式设计,适合深度函数式编程
  • Immutable.js:高效的持久化数据结构,适合大型不可变数据
  • Immer:直观的写法,轻量级,与 React 生态完美配合

选择合适的库取决于项目需求:

  • 需要通用工具函数 → Lodash/FP
  • 追求纯函数式风格 → Ramda
  • React/Redux 状态管理 → Immer
  • 大型不可变数据集 → Immutable.js

无论选择哪个库,核心都是理解函数式编程的思维方式:组合小函数、保持不可变、避免副作用。工具库只是让这些实践变得更加便捷。

参考资源