轻松实现不可变状态:深入理解 Immer
在 JavaScript 中更新嵌套状态而不进行变更是一件繁琐的事情。你需要在每一层展开对象,跟踪哪些引用发生了变化,并希望自己没有意外地改变某些内容。对于一个简单的嵌套更新,你可能需要编写五行小心翼翼的复制逻辑。
Immer 用一种不同的方法解决了这个问题:编写看起来像变更的代码,但自动生成不可变状态。本文将解释 Immer 基于代理的不可变性是如何工作的,为什么 Redux Toolkit 在内部使用它,以及在采用它之前你应该了解哪些实际注意事项。
核心要点
- Immer 允许你编写变更风格的代码,通过其
produce函数和基于代理的草稿来生成不可变状态 - 结构共享确保只有修改的分支获得新的引用,为 React 和 Redux 的重新渲染检查保持性能
- Redux Toolkit 在内部使用 Immer,因此
createSlice的 reducer 会自动处理不可变性,无需额外导入 - 注意常见陷阱:不要重新赋值草稿本身,避免混合草稿变更和返回值,并谨慎处理类实例
Immer 如何生成不可变状态
Immer 的核心 API 是 produce 函数。你向它传递当前状态和一个”配方”函数。在该配方内部,你会收到一个 draft——一个包装了原始状态的代理。你使用普通的 JavaScript 变更来修改草稿。当配方执行完成时,Immer 会生成一个反映你更改的新的不可变状态。
import { produce } from "immer"
const baseState = {
user: { name: "Alice", settings: { theme: "dark" } }
}
const nextState = produce(baseState, draft => {
draft.user.settings.theme = "light"
})
// baseState.user.settings.theme 仍然是 "dark"
// nextState.user.settings.theme 是 "light"
心智模型很简单:假装你在进行变更,但 Immer 在幕后处理不可变更新。
基于代理的不可变性和结构共享
Immer 使用 JavaScript 的 Proxy 对象来拦截你对草稿的读取和写入操作。当你访问嵌套属性时,Immer 会延迟创建该路径的代理。当你写入属性时,Immer 会标记该节点(及其祖先)为已修改,并仅在需要的地方创建浅拷贝。
这种写时复制方法实现了结构共享。状态树中未更改的部分保持相同的对象引用。只有修改的分支获得新的引用。这对 React 和 Redux 很重要,因为引用相等性检查决定了组件是否重新渲染。
现代 Immer(v10+)需要原生 Proxy 支持——没有 ES5 降级方案。这对于当前的浏览器和 Node.js 来说没问题,但如果你的目标是不寻常的环境,则值得注意。
Redux Toolkit 的 Immer 集成
如果你使用 Redux Toolkit,你已经在使用 Immer 了。RTK 的 createSlice 会自动用 produce 包装你的 reducer 逻辑。你在 case reducer 中编写”变更”代码,RTK 会处理不可变性:
import { createSlice } from "@reduxjs/toolkit"
const todosSlice = createSlice({
name: "todos",
initialState: [],
reducers: {
addTodo: (state, action) => {
state.push(action.payload) // 看起来像变更,但实际上是安全的
},
toggleTodo: (state, action) => {
const todo = state.find(t => t.id === action.payload)
if (todo) todo.completed = !todo.completed
}
}
})
Redux Toolkit 的 Immer 集成消除了使原生 Redux reducer 冗长的展开运算符样板代码。你不需要单独导入 produce——RTK 会为你配置它。
Discover how at OpenReplay.com.
自动冻结和迭代行为
Immer 默认会自动冻结生成的状态(使用 Object.freeze)。这在开发过程中可以捕获意外的变更,但会增加开销。如果性能分析显示这很重要,你可以通过配置在生产环境中禁用它:
import { setAutoFreeze } from "immer"
setAutoFreeze(false) // 在生产环境中禁用以提高性能
现代 Immer 默认使用宽松迭代以提高性能:草稿中只迭代可枚举的字符串键。这通常是你在应用程序状态中想要的,但这意味着除非你明确启用严格迭代,否则会跳过 symbol 键和不可枚举属性。这主要在边缘情况或底层数据操作中才重要。
JavaScript 不可变更新的实际注意事项
有几个陷阱会让 Immer 新手措手不及:
不要重新赋值草稿本身。 修改 draft.property 是可行的。重新赋值 draft = newValue 没有任何作用,因为你只是改变了局部参数绑定。
返回值很重要。 如果你的配方返回一个值,Immer 会使用该值作为新状态,而不是修改后的草稿。返回 undefined 与不返回任何内容的处理方式相同,但混合草稿变更和显式返回是常见的错误来源——使用其中一种方法即可。
类和特殊对象需要小心处理。 Immer 最适合处理普通对象、数组、Map 和 Set。类实例不会自动正确代理。你可能需要将它们标记为不可变或单独处理(如官方陷阱文档中所述)。
性能并非免费。 Immer 适用于典型的 UI 状态——表单、列表、用户偏好设置。对于每帧处理数千个项目的热循环,在假设 Immer 没有开销之前先进行测量。代理机制是有成本的。
树形状态效果最好。 Immer 假设你的状态是一棵树。循环引用或跨分支的共享对象引用可能会产生意外结果。
结论
Immer 在处理中等复杂度的嵌套状态更新时表现出色——这正是你在 React 组件和 Redux reducer 中遇到的情况。它消除了样板代码,捕获意外变更,并与 Redux Toolkit 无缝集成。
对于简单的扁平状态,原生展开运算符就可以胜任。对于性能关键的数据处理,请先进行基准测试。但对于典型的前端状态管理,Immer 基于代理的方法在开发者体验和正确性之间提供了最佳平衡。
常见问题
可以。你可以直接用 produce 包装状态更新,或者使用 use-immer 包中的 useImmer hook。这为本地组件状态提供了与 Redux Toolkit 为全局状态提供的相同的变更风格语法。
Immer 对 TypeScript 有出色的支持。produce 函数会自动从基础状态推断类型,草稿对象保持适当的类型。在编写变更风格代码时,你可以获得完整的自动补全和类型检查。
Immer 原生支持 Map 和 Set。在现代版本中,你可以直接在草稿上使用标准的 Map 和 Set 方法,如 set、delete 和 add,无需任何额外配置。
只有在性能分析显示它导致性能问题时才这样做。自动冻结通过在意外变更时抛出错误来帮助捕获 bug。对于大多数应用程序,开销可以忽略不计,但高频更新可能会受益于通过 setAutoFreeze false 禁用它。
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers. Check our GitHub repo and join the thousands of developers in our community.