使用 Immer 编写 Reducer
Redux Toolkit 的 createReducer
和 createSlice
在内部自动使用 Immer,让您能够使用“变异”语法编写更简单的不可变更新逻辑。这有助于简化大多数 reducer 实现。
由于 Immer 本身是一个抽象层,因此了解 Redux Toolkit 为什么使用 Immer 以及如何正确使用它非常重要。
不可变性和 Redux
不可变性的基础
“可变”意味着“可更改”。如果某物是“不可变的”,则它永远无法更改。
JavaScript 对象和数组默认情况下都是可变的。如果我创建一个对象,我可以更改其字段的内容。如果我创建一个数组,我也可以更改其内容。
const obj = { a: 1, b: 2 }
// still the same object outside, but the contents have changed
obj.b = 3
const arr = ['a', 'b']
// In the same way, we can change the contents of this array
arr.push('c')
arr[1] = 'd'
这被称为变异对象或数组。它在内存中是相同的对象或数组引用,但现在对象内部的内容已更改。
为了不可变地更新值,您的代码必须复制现有对象/数组,然后修改副本。.
我们可以使用 JavaScript 的数组/对象展开运算符以及返回数组新副本而不是修改原始数组的数组方法来手动完成此操作。
const obj = {
a: {
// To safely update obj.a.c, we have to copy each piece
c: 3,
},
b: 2,
}
const obj2 = {
// copy obj
...obj,
// overwrite a
a: {
// copy obj.a
...obj.a,
// overwrite c
c: 42,
},
}
const arr = ['a', 'b']
// Create a new copy of arr, with "c" appended to the end
const arr2 = arr.concat('c')
// or, we can make a copy of the original array:
const arr3 = arr.slice()
// and mutate the copy:
arr3.push('c')
有关 JavaScript 中不变性工作原理的更多信息,请参阅
Reducers 和不可变更新
Redux 的主要规则之一是,**我们的 reducers 绝不允许修改原始/当前状态值!**
// ❌ Illegal - by default, this will mutate the state!
state.value = 123
在 Redux 中,您不能修改状态的原因有很多。
- 它会导致错误,例如 UI 无法正确更新以显示最新值。
- 它使理解状态更新的原因和方式变得更加困难。
- 它使编写测试变得更加困难。
- 它破坏了正确使用“时间旅行调试”的能力。
- 它违背了 Redux 的预期精神和使用模式。
那么,如果我们不能更改原始值,我们如何返回更新后的状态呢?
Reducers 只能对原始值进行复制,然后才能修改这些副本。
// ✅ This is safe, because we made a copy
return {
...state,
value: 123,
}
我们已经看到,我们可以通过使用 JavaScript 的数组/对象展开运算符和其他返回原始值副本的函数来手动编写不可变更新。
当数据嵌套时,这会变得更加困难。不可变更新的关键规则是,您必须对需要更新的每个嵌套级别进行复制。
这可能看起来像一个典型的例子
function handwrittenReducer(state, action) {
return {
...state,
first: {
...state.first,
second: {
...state.first.second,
[action.someId]: {
...state.first.second[action.someId],
fourth: action.someValue,
},
},
},
}
}
但是,如果您认为“以这种方式手动编写不可变更新看起来很难记住并且难以正确执行”...是的,您是对的!:)
手动编写不可变更新逻辑确实很困难,而且意外地在 reducers 中修改状态是 Redux 用户最常见的错误。
使用 Immer 进行不可变更新
Immer 是一个简化编写不可变更新逻辑过程的库。
Immer 提供了一个名为 produce
的函数,它接受两个参数:您的原始 state
和一个回调函数。回调函数将获得该状态的“草稿”版本,并且在回调函数内部,可以安全地编写修改草稿值的代码。Immer 会跟踪所有尝试修改草稿值的尝试,然后使用它们的不可变等效项重放这些修改,以创建一个安全且不可变的更新结果
import produce from 'immer'
const baseState = [
{
todo: 'Learn typescript',
done: true,
},
{
todo: 'Try immer',
done: false,
},
]
const nextState = produce(baseState, (draftState) => {
// "mutate" the draft array
draftState.push({ todo: 'Tweet about it' })
// "mutate" the nested state
draftState[1].done = true
})
console.log(baseState === nextState)
// false - the array was copied
console.log(baseState[0] === nextState[0])
// true - the first item was unchanged, so same reference
console.log(baseState[1] === nextState[1])
// false - the second item was copied and updated
Redux Toolkit 和 Immer
Redux Toolkit 的 createReducer
API 在内部自动使用 Immer。因此,在传递给 createReducer
的任何 case reducer 函数内部,安全地“修改”状态。
const todosReducer = createReducer([], (builder) => {
builder.addCase('todos/todoAdded', (state, action) => {
// "mutate" the array by calling push()
state.push(action.payload)
})
})
反过来,createSlice
在内部使用 createReducer
,因此在其中“修改”状态也是安全的。
const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
todoAdded(state, action) {
state.push(action.payload)
},
},
})
即使 case reducer 函数是在 createSlice/createReducer
调用之外定义的,这也适用。例如,您可以拥有一个可重用的 case reducer 函数,该函数期望“修改”其状态,并根据需要将其包含在内。
const addItemToArray = (state, action) => {
state.push(action.payload)
}
const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
todoAdded: addItemToArray,
},
})
这是因为在执行时,内部会将“修改”逻辑包装在 Immer 的 produce
方法中。
请记住,“修改”逻辑仅在包装在 Immer 内部时才能正常工作!否则,该代码将真正修改数据。
Immer 使用模式
在使用 Redux Toolkit 中的 Immer 时,有一些有用的模式需要了解,以及一些需要注意的陷阱。
修改和返回状态
Immer 通过跟踪对现有草稿状态值的修改尝试来工作,无论是通过分配给嵌套字段还是通过调用修改值的函数。这意味着 **state
必须是 JS 对象或数组,以便 Immer 看到尝试的更改**。(你仍然可以将切片的 state 设置为字符串或布尔值等原始值,但由于原始值永远无法修改,所以你只能返回一个新值。)
在任何给定的 case reducer 中,**Immer 期望你 either 修改 现有状态,或者 自己构造一个新的状态值并返回它,但不要 在同一个函数中同时进行!** 例如,以下两种都是使用 Immer 的有效 reducer
const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
todoAdded(state, action) {
// "Mutate" the existing state, no return value needed
state.push(action.payload)
},
todoDeleted(state, action.payload) {
// Construct a new result array immutably and return it
return state.filter(todo => todo.id !== action.payload)
}
}
})
但是,可以 使用不可变更新来完成部分工作,然后通过“修改”保存结果。这方面的一个例子可能是过滤嵌套数组
const todosSlice = createSlice({
name: 'todos',
initialState: {todos: [], status: 'idle'}
reducers: {
todoDeleted(state, action.payload) {
// Construct a new array immutably
const newTodos = state.todos.filter(todo => todo.id !== action.payload)
// "Mutate" the existing state to save the new array
state.todos = newTodos
}
}
})
请注意,**在具有隐式返回的箭头函数中修改状态会违反此规则并导致错误!** 这是因为语句和函数调用可能会返回值,而 Immer 会同时看到尝试的修改和以及 新的返回值,并且不知道哪个应该用作结果。一些可能的解决方案是使用 void
关键字来跳过返回值,或者使用花括号来为箭头函数提供一个主体,并且没有返回值
const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
// ❌ ERROR: mutates state, but also returns new array size!
brokenReducer: (state, action) => state.push(action.payload),
// ✅ SAFE: the `void` keyword prevents a return value
fixedReducer1: (state, action) => void state.push(action.payload),
// ✅ SAFE: curly braces make this a function body and no return
fixedReducer2: (state, action) => {
state.push(action.payload)
},
},
})
虽然编写嵌套的不可变更新逻辑很困难,但有时确实 更简单的是使用对象展开操作来一次更新多个字段,而不是分配单个字段
function objectCaseReducer1(state, action) {
const { a, b, c, d } = action.payload
return {
...state,
a,
b,
c,
d,
}
}
function objectCaseReducer2(state, action) {
const { a, b, c, d } = action.payload
// This works, but we keep having to repeat `state.x =`
state.a = a
state.b = b
state.c = c
state.d = d
}
作为替代方案,你可以使用 Object.assign
来一次修改多个字段,因为 Object.assign
总是修改它接收的第一个对象
function objectCaseReducer3(state, action) {
const { a, b, c, d } = action.payload
Object.assign(state, { a, b, c, d })
}
重置和替换状态
有时你可能想要替换整个现有的 state
,无论是因为你加载了一些新数据,还是因为你想要将状态重置为其初始值。
一个常见的错误是尝试直接分配 state = someValue
。这将不起作用!** 这只会将本地 state
变量指向一个不同的引用。这既没有修改内存中现有的 state
对象/数组,也没有返回一个全新的值,因此 Immer 不会进行任何实际更改。
相反,要替换现有状态,您应该直接返回新值。
const initialState = []
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
brokenTodosLoadedReducer(state, action) {
// ❌ ERROR: does not actually mutate or return anything new!
state = action.payload
},
fixedTodosLoadedReducer(state, action) {
// ✅ CORRECT: returns a new value to replace the old one
return action.payload
},
correctResetTodosReducer(state, action) {
// ✅ CORRECT: returns a new value to replace the old one
return initialState
},
},
})
调试和检查草稿状态
通常,您可能希望从 reducer 中记录正在进行的状态,以查看它在更新时的样子,例如 console.log(state)
。不幸的是,浏览器以难以阅读或理解的格式显示记录的 Proxy 实例。
为了解决这个问题,Immer 包含一个 current
函数,它可以提取包装数据的副本,而 RTK 重新导出了 current
。如果您需要记录或检查正在进行的工作状态,可以在 reducer 中使用它。
import { current } from '@reduxjs/toolkit'
const todosSlice = createSlice({
name: 'todos',
initialState: todosAdapter.getInitialState(),
reducers: {
todoToggled(state, action) {
// ❌ ERROR: logs the Proxy-wrapped data
console.log(state)
// ✅ CORRECT: logs a plain JS copy of the current data
console.log(current(state))
},
},
})
正确的输出应该如下所示
Immer 还提供了 original
和 isDraft
函数,它们可以检索原始数据(不应用任何更新)并检查给定值是否为 Proxy 包装的草稿。从 RTK 1.5.1 开始,这两个函数也从 RTK 重新导出。
更新嵌套数据
Immer 极大地简化了更新嵌套数据。嵌套对象和数组也被包装在代理中并被草拟,并且可以安全地将嵌套值提取到它自己的变量中,然后对其进行变异。
但是,这仍然只适用于对象和数组。如果我们将一个原始值提取到它自己的变量中并尝试更新它,Immer 将没有任何东西可以包装,也无法跟踪任何更新。
const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
brokenTodoToggled(state, action) {
const todo = state.find((todo) => todo.id === action.payload)
if (todo) {
// ❌ ERROR: Immer can't track updates to a primitive value!
let { completed } = todo
completed = !completed
}
},
fixedTodoToggled(state, action) {
const todo = state.find((todo) => todo.id === action.payload)
if (todo) {
// ✅ CORRECT: This object is still wrapped in a Proxy, so we can "mutate" it
todo.completed = !todo.completed
}
},
},
})
这里有一个陷阱。 Immer 不会包装新插入状态的对象。大多数情况下,这无关紧要,但可能在某些情况下您希望插入一个值,然后对其进行进一步更新。
与之相关的是,RTK 的 createEntityAdapter
更新函数 可以用作独立的 reducer,也可以用作“变异”更新函数。这些函数通过检查它们接收到的状态是否包装在草稿中来确定是“变异”还是返回一个新值。如果您在 case reducer 中自己调用这些函数,请确保您知道是否将草稿值或普通值传递给它们。
最后,值得注意的是,Immer 不会自动为您创建嵌套对象或数组 - 您必须自己创建它们。例如,假设我们有一个包含嵌套数组的查找表,我们希望将一个项目插入其中一个数组中。如果我们无条件地尝试插入而没有检查该数组是否存在,则在数组不存在时逻辑将崩溃。相反,您需要先确保数组存在。
const itemsSlice = createSlice({
name: 'items',
initialState: { a: [], b: [] },
reducers: {
brokenNestedItemAdded(state, action) {
const { id, item } = action.payload
// ❌ ERROR: will crash if no array exists for `id`!
state[id].push(item)
},
fixedNestedItemAdded(state, action) {
const { id, item } = action.payload
// ✅ CORRECT: ensures the nested array always exists first
if (!state[id]) {
state[id] = []
}
state[id].push(item)
},
},
})
对状态变异进行代码风格检查
许多 ESLint 配置包含 https://eslint.org.cn/docs/rules/no-param-reassign 规则,该规则也可能警告嵌套字段的变异。这会导致该规则警告 Immer 支持的 reducer 中对 state
的变异,这并不有用。
为了解决这个问题,你可以告诉 ESLint 规则仅在切片文件中忽略对名为 state
的参数的变异和赋值。
// @filename .eslintrc.js
module.exports = {
// add to your ESLint config definition
overrides: [
{
// feel free to replace with your preferred file pattern - eg. 'src/**/*Slice.ts'
files: ['src/**/*.slice.ts'],
// avoid state param assignment
rules: { 'no-param-reassign': ['error', { props: false }] },
},
],
}
为什么 Immer 是内置的
随着时间的推移,我们收到了很多请求,要求将 Immer 作为 RTK 的 createSlice
和 createReducer
API 的可选部分,而不是严格要求。
我们的答案始终如一:Immer 是 RTK 中必需的,而且这一点不会改变。
值得回顾一下我们为什么认为 Immer 是 RTK 的关键部分,以及为什么我们不会将其设为可选。
Immer 的优势
Immer 有两个主要优势。首先,Immer 极大地简化了不可变更新逻辑。 正确的不可变更新非常冗长。这些冗长的操作总体上难以阅读,并且还会掩盖更新语句的实际意图。Immer 消除了所有嵌套的扩展和数组切片。代码不仅更短、更容易阅读,而且更清楚地表明了应该进行的实际更新。
其次,正确编写不可变更新非常困难,而且很容易出错(例如,忘记在对象扩展集中复制嵌套级别,复制顶层数组而不是要更新的数组中的项,或者忘记 array.sort()
会修改数组)。这就是为什么 意外变异一直是 Redux 错误的最常见原因。Immer 有效地消除了意外变异。不仅不再有可能会写错的扩展操作,而且 Immer 还会自动冻结状态。如果意外地进行了变异,即使是在 reducer 之外,也会导致错误抛出。消除 Redux 错误的 #1 原因是一个巨大的改进。
此外,RTK Query 使用 Immer 的补丁功能来启用 乐观更新和手动缓存更新。
权衡和关注点
与任何工具一样,使用 Immer 确实存在权衡,用户也表达了一些关于使用它的担忧。
Immer 会增加整体应用程序包的大小。大约 8K min,3.3K min+gz(参考:Immer 文档:安装,Bundle.js.org 分析)。但是,该库包的大小开始通过减少应用程序中的 reducer 逻辑量来弥补自身。此外,更易读的代码和消除突变错误的好处值得这个大小。
Immer 还会在运行时性能方面增加一些开销。但是,根据 Immer 的“性能”文档页面,这种开销在实践中并不重要。此外,reducer 在 Redux 应用程序中几乎从不成为性能瓶颈。相反,更新 UI 的成本更为重要。
因此,虽然使用 Immer 不是“免费”的,但包大小和性能成本足够小,值得使用。
使用 Immer 最现实的痛点是浏览器调试器以令人困惑的方式显示代理,这使得在调试时难以检查状态变量。这当然很烦人。但是,这实际上不会影响运行时行为,我们在此页面的上方记录了使用 current
创建数据可查看的普通 JS 版本的方法。(鉴于代理作为 Mobx 和 Vue 3 等库的一部分越来越广泛地使用,这也不是 Immer 独有的。)
另一个问题是教育和理解。Redux 一直要求在 reducer 中保持不变性,因此看到“突变”代码可能会令人困惑。新 Redux 用户可能会在示例代码中看到这些“突变”,并认为这是 Redux 使用的正常现象,然后尝试在 createSlice
之外做同样的事情。这确实会导致真正的突变和错误,因为这超出了 Immer 包装更新的能力。
我们通过在我们的文档中反复强调不变性的重要性来解决这个问题,包括多个突出显示的部分,强调“变异”只有在 Immer 的“魔法”内部才能正常工作,并添加了您现在正在阅读的这个特定文档页面。
架构和意图
Immer 不是可选的还有另外两个原因。
一个是 RTK 的架构。createSlice
和 createReducer
是通过直接导入 Immer 来实现的。没有简单的方法来创建这两个的版本,它们将具有假设的 immer: false
选项。您不能进行可选导入,并且我们需要在应用程序的初始加载期间立即同步地使用 Immer。
最后:Immer 默认内置在 RTK 中,因为我们认为它是我们用户的最佳选择!我们希望我们的用户使用 Immer,并将其视为 RTK 的一个关键且不可协商的组件。像更简单的 reducer 代码和防止意外变异这样的巨大优势远远超过了相对较小的担忧。
更多信息
有关 Immer 的 API、边缘情况和行为的更多详细信息,请参阅Immer 文档。
有关为什么需要 Immer 的历史讨论,请参阅以下问题